Channel Accounts
Channel accounts are a design pattern for submitting transactions to the network in bursts. Channel accounts are not what might come to mind in terms of layer-2 channels. Rather, they are a set of specialized accounts that act as proxies to submit transactions quickly.
Submitting transactions quickly often requires hot signing keys. It is best practice to extensively test your systems on the testnet before deploying in high-speed production. Moreover, you may consider using hardware security modules or multisignature wallets to secure your accounts.
Background Motivation
Channel accounts take advantage of the fact that the source account of a transaction () can be different than the source account of the operations inside a transaction ().

Stellar validators are spread across the globe, making it challenging to guarantee immediate order of arrival from one source. Only by sequencing a validator can you control physical delays for your transactions, as signals vary in network lag to reach other nodes. Accordingly, channel accounts are the only way to guarantee layer-1 settlement for many consecutive transactions.
This guide walks through an example using channel accounts to send 500 payment operations in close ledgers. It uses a primary account () to hold lumens and five channel accounts for submitting transactions ().

Sequence Numbers
The network rejects transactions with sequence numbers that are not strictly increasing. Previously, if you sent even just two transactions in the same ledger, there was a reasonable chance they would arrive out of sequence. Accordingly, to prevent failures, the network now restricts each source account to submit no more than one transaction per ledger.
- Python
- JavaScript
- Java
- Go
from stellar_sdk import Asset, Keypair, Network, Server, TransactionBuilder
# While you might know where this Horizon instance should be,
# You'd need to manually delay transmission to control order.
server = Server("https://horizon-testnet.stellar.org")
secret_key = "SDY5TRQSEUSHS7UX26QMNMZ4X543UWZQPJZ7LQQUA3NAKDFVYTAWAB74"
source_keypair = Keypair.from_secret(secret_key)
source_account = server.load_account(source_keypair.public_key)
transaction1 = TransactionBuilder(
source_account = source_account,
network_passphrase = Network.TESTNET_NETWORK_PASSPHRASE,
base_fee = 100
)
transaction2 = TransactionBuilder(
source_account = source_account,
network_passphrase = Network.TESTNET_NETWORK_PASSPHRASE,
base_fee = 100
)
# Initialize arrays of 200 recipient public keys
first100Users = [...]
second100Users = [...]
# Attempting to send 200 operations at once from a single source
for n in range(100):
transaction1.append_payment_op(
destination = first100Users[n],
amount = "10",
asset = Asset.native()
)
transaction2.append_payment_op(
destination = second100Users[n],
amount = "10",
asset = Asset.native()
)
transaction1 = transaction1.set_timeout(3600).build()
transaction1.sign(sourceKeypair)
transaction2 = transaction2.set_timeout(3600).build()
transaction2.sign(sourceKeypair)
# Likely to fail due to misordered sequence numbers at validator
server.submit_transaction(transaction1)
# Adding a delay here or checking for transaction1 confirmation lets you
# account for network latency and submission timing in slower use cases.
server.submit_transaction(transaction2)
const {
Server,
Keypair,
TransactionBuilder,
Networks,
Asset,
} = require("stellar-sdk");
const server = new Server('https://horizon-testnet.stellar.org');
const secretKey = 'SDY5TRQSEUSHS7UX26QMNMZ4X543UWZQPJZ7LQQUA3NAKDFVYTAWAB74';
const sourceKeypair = Keypair.fromSecret(secretKey);
const sourceAccount = await server.loadAccount(sourceKeypair.publicKey());
const transaction1 = new TransactionBuilder(sourceAccount, {
networkPassphrase: Networks.TESTNET,
fee: 100
});
const transaction2 = new TransactionBuilder(sourceAccount, {
networkPassphrase: Networks.TESTNET,
fee: 100
});
// Initialize arrays of 200 recipient public keys
const first100Users = [...];
const second100Users = [...];
// Attempting to send 200 operations at once from a single source
for (let n = 0; n < 100; n++) {
transaction1.addOperation(StellarSdk.Operation.payment({
destination: first100Users[n],
asset: Asset.native(),
amount: "10"
}));
transaction2.addOperation(StellarSdk.Operation.payment({
destination: second100Users[n],
asset: Asset.native(),
amount: "10"
}));
}
const builtTransaction1 = transaction1.setTimeout(3600).build();
builtTransaction1.sign(sourceKeypair);
const builtTransaction2 = transaction2.setTimeout(3600).build();
builtTransaction2.sign(sourceKeypair);
// Submit transactions
await server.submitTransaction(builtTransaction1);
// Add a delay or confirmation check for builtTransaction1 here or face conflicts
await server.submitTransaction(builtTransaction2);
import java.util.List;
import org.stellar.sdk.*;
import org.stellar.sdk.requests.Server;
import org.stellar.sdk.responses.AccountResponse;
public static void main(String[] args) {
try {
Server server = new Server("https://horizon-testnet.stellar.org");
String secretKey = "SDY5TRQSEUSHS7UX26QMNMZ4X543UWZQPJZ7LQQUA3NAKDFVYTAWAB74";
KeyPair sourceKeypair = KeyPair.fromSecretSeed(secretKey);
AccountResponse sourceAccount = server.accounts().account(sourceKeypair.getAccountId());
Transaction.Builder transactionBuilder1 = new Transaction.Builder(sourceAccount, Network.TESTNET)
.setBaseFee(100).set_timeout(3600);
Transaction.Builder transactionBuilder2 = new Transaction.Builder(sourceAccount, Network.TESTNET)
.setBaseFee(100).set_timeout(3600);
// Initialize arrays of 200 recipient public keys
List<String> first100Users = List.of(...);
List<String> second100Users = List.of(...);
// Attempting to send 200 operations at once from a single source
for (int n = 0; n < 100; n++) {
transactionBuilder1.addOperation(
new PaymentOperation.Builder(
first100Users.get(n),
AssetTypeNative.INSTANCE, "10"
).build());
transactionBuilder2.addOperation(
new PaymentOperation.Builder(
second100Users.get(n),
AssetTypeNative.INSTANCE, "10"
).build());
}
Transaction transaction1 = transactionBuilder1.build();
transaction1.sign(sourceKeypair);
Transaction transaction2 = transactionBuilder2.build();
transaction2.sign(sourceKeypair);
// Submit transactions
server.submitTransaction(transaction1);
// Add a delay or confirmation check for transaction1 here or face conflicts
server.submitTransaction(transaction2);
} catch (Exception e) {
e.printStackTrace();
}
}
package main
import (
"github.com/stellar/go-stellar-sdk/clients/horizonclient"
"github.com/stellar/go-stellar-sdk/keypair"
"github.com/stellar/go-stellar-sdk/network"
"github.com/stellar/go-stellar-sdk/protocols/horizon"
"github.com/stellar/go-stellar-sdk/txnbuild"
)
func main() {
server := horizonclient.DefaultTestNetClient
secretKey := "SDY5TRQSEUSHS7UX26QMNMZ4X543UWZQPJZ7LQQUA3NAKDFVYTAWAB74"
sourceKeypair, _ := keypair.ParseFull(secretKey)
sourceAccount, _ := server.AccountDetail(horizonclient.AccountRequest{
AccountID: sourceKeypair.Address(),
})
txParams := txnbuild.TransactionParams{
SourceAccount: &sourceAccount,
IncrementSequenceNum: true,
BaseFee: 100,
Network: network.TestNetworkPassphrase,
}
transaction1, _ := txnbuild.NewTransaction(txParams)
transaction2, _ := txnbuild.NewTransaction(txParams)
// Initialize arrays of 200 recipient public keys
first100Users := []string{...}
second100Users := []string{...}
// Attempting to send 200 operations at once from a single source
for n := 0; n < 100; n++ {
paymentOp1 := txnbuild.Payment{
Destination: first100Users[n],
Amount: "10",
Asset: txnbuild.NativeAsset{},
}
transaction1.Operations = append(transaction1.Operations, &paymentOp1)
paymentOp2 := txnbuild.Payment{
Destination: second100Users[n],
Amount: "10",
Asset: txnbuild.NativeAsset{},
}
transaction2.Operations = append(transaction2.Operations, &paymentOp2)
}
tx1, _ := transaction1.BuildSignEncode(sourceKeypair)
tx2, _ := transaction2.BuildSignEncode(sourceKeypair)
// Submit transactions
server.SubmitTransactionXDR(tx1)
// Add a delay or confirmation check for tx1 here or face conflicts
server.SubmitTransactionXDR(tx2)
}
Account Separation
By distributing transactions across multiple channel accounts, you can achieve high transaction rates without sequence number conflicts. Each channel account can handle up to 100 operations per transaction.
By using multiple accounts, you bypass the limitation of one transaction for each source account per ledger. This helps you with high-frequency, multi-party, and spiked-demand applications. Here are the accounts for our example, which might be separated into different levels of custody security in production:
- Primary Account (): Holds the main balance of lumens (or any asset) and is responsible for authorizing operations. This account doesn't directly submit transactions but instead delegates this task to channel accounts.
- Channel Accounts (): Act as intermediaries that submit transactions on behalf of the primary account. Each channel account has its own sequence number, allowing multiple transactions to be submitted in parallel without conflicts.
- Multisig Signers (): Enhance security by ensuring that no single account has unilateral control over the assets. For example, multiple signers can have authority over 's
mediumthreshold to facilitate valid channel transactions without using 's (cold) master key(s).

The simple solution of sending all 500 payments from would be rate-limited and prone to sequence number errors. Accordingly, we can split the operations up between five channel accounts. Each channel account submits transactions containing 100 payment operations. This approach ensures that the channel accounts only perform the necessary network submissions, while the primary account retains secure custody over the assets.
For our example, we'll assume the goal is high throughput rather than operational delegation. In the delegation examples only a single hot signer may be necessary. Accordingly, let us use an abnormally-high base fee of 640 for inclusion in an example ledger.
- Python
- JavaScript
- Java
- Go
channelAccountSecrets = [
"SBXEVUPBW66BU5F2NU4S4QMOBTAU7TVTF4HXWD37VKPTHEC4ULXFGRUH",
"SBQMVILQKB2MXIDQIUN6FGS26PDNBRYKZM7WYZWHEU5MAGCP6HDNV74S",
"SAN3ZTCSWSLVQIBBB4IHN6566K4LMRCIAAXE7THYGB3L45JDTX7WVWGY",
"SACBLHU7OJJR2GTVBNX6WR7OR77CZ3NQHHIHRTEKV5OPO4IKMR2GDRIQ",
"SCFFCZS3FV4VVTIJY7SN2T4GDDW37GPKGTVA4XWOPACPMJHYXDUXXNUS"
]
channelKeypairs = [Keypair.from_secret(secret) for secret in channelAccountSecrets]
channelAccounts = [server.load_account(keypair.public_key) for keypair in channelKeypairs]
# Example hot primary account secret
# Generally only use the public key
# Can pre-sign offline or use MPCs
primaryKeypair = Keypair.from_secret("SB6NB3SRNRQTHUXF7PQPWJP7RWY2LCL43IDWRUUSXKJOTY5SBUOVPKWL")
# 500 example recipient public keys
# Duplicate values for simplicity
allRecipients = [["GD72...B2D"] * 100,
["GDX7...G7M"] * 100,
["GBI3...Q4V"] * 100,
["GBH2...N6E"] * 100,
["GCJY...OJ2"] * 100]
txOutput = []
# Generating the initial envelope, which can be done before
for channelIndex, channels in enumerate(channelAccounts):
transaction = TransactionBuilder(
source_account = channels,
network_passphrase = Network.TESTNET_NETWORK_PASSPHRASE,
base_fee = 640
)
# The channel index just iterates over channelAccountSecrets[]
for recipients in allRecipients[channelIndex]:
transaction.append_payment_op(
source = primaryKeypair.public_key,
destination = recipients,
amount = "10",
asset = Asset.native()
)
transaction = transaction.set_timeout(3600).build()
transaction.sign(primaryKeypair) # This can be done before sending to the channel.
# You can implement other MPC signers over the primary account here.
transaction.sign(channelKeypairs[channelIndex]) # Approve being the transaction source.
txOutput.append(transaction)
# With all channel accounts in one script, you can speed up submission via thredding.
for transactions in txOutput:
try:
response = server.submit_transaction(transaction)
print(f"Transaction succeeded with hash: {response['hash']}")
except Exception as e:
print(f"Transaction failed: {e}")
const channelAccountSecrets = [
"SBXEVUPBW66BU5F2NU4S4QMOBTAU7TVTF4HXWD37VKPTHEC4ULXFGRUH",
"SBQMVILQKB2MXIDQIUN6FGS26PDNBRYKZM7WYZWHEU5MAGCP6HDNV74S",
"SAN3ZTCSWSLVQIBBB4IHN6566K4LMRCIAAXE7THYGB3L45JDTX7WVWGY",
"SACBLHU7OJJR2GTVBNX6WR7OR77CZ3NQHHIHRTEKV5OPO4IKMR2GDRIQ",
"SCFFCZS3FV4VVTIJY7SN2T4GDDW37GPKGTVA4XWOPACPMJHYXDUXXNUS",
];
const channelKeypairs = channelAccountSecrets.map((secret) =>
Keypair.fromSecret(secret),
);
const channelAccounts = await Promise.all(
channelKeypairs.map((keypair) => server.loadAccount(keypair.publicKey())),
);
const main = async () => {
// Example hot primary account, generally only use the public key with pre-signed tx
const primaryAccountSecret =
"SB6NB3SRNRQTHUXF7PQPWJP7RWY2LCL43IDWRUUSXKJOTY5SBUOVPKWL";
const primaryKeypair = Keypair.fromSecret(primaryAccountSecret);
const primaryAccount = await server.loadAccount(primaryKeypair.publicKey());
// 500 example recipients
// Assume 100 per list
const allRecipients = [
["GD7F...B2D", "..."],
["GDX7...G7M", "..."],
["GBI3...Q4V", "..."],
["GBH2...N6E", "..."],
["GCJY...OJ2", "..."],
];
const txOutput = [];
// Generating the initial envelope, which can be done before
for (
let channelIndex = 0;
channelIndex < channelAccounts.length;
channelIndex++
) {
let transaction = new TransactionBuilder(channelAccounts[channelIndex], {
fee: 640,
networkPassphrase: Networks.TESTNET,
});
// Channel index just iterates over channelAccountSecrets[]
for (let recipients of allRecipients[channelIndex]) {
transaction.addOperation({
source: primaryKeypair.publicKey(),
destination: recipients,
asset: Asset.native(),
amount: "10",
type: "payment",
});
}
transaction = transaction.setTimeout(3600).build();
transaction.sign(primaryKeypair); // This can be done before sending to the channel.
// You can implement other MPC signers over the primary account here.
transaction.sign(channelKeypairs[channelIndex]); // Approve being the transaction source.
txOutput.push(transaction);
}
// With all channel accounts in one script, you can speed up submission via threading.
for (let transaction of txOutput) {
try {
const response = await server.submitTransaction(transaction);
console.log(`Transaction succeeded with hash: ${response.hash}`);
} catch (e) {
console.log(`Transaction failed: ${e}`);
}
}
};
import org.stellar.sdk.responses.TransactionResponse;
List<String> channelAccountSecrets = Arrays.asList(
"SBXEVUPBW66BU5F2NU4S4QMOBTAU7TVTF4HXWD37VKPTHEC4ULXFGRUH",
"SBQMVILQKB2MXIDQIUN6FGS26PDNBRYKZM7WYZWHEU5MAGCP6HDNV74S",
"SAN3ZTCSWSLVQIBBB4IHN6566K4LMRCIAAXE7THYGB3L45JDTX7WVWGY",
"SACBLHU7OJJR2GTVBNX6WR7OR77CZ3NQHHIHRTEKV5OPO4IKMR2GDRIQ",
"SCFFCZS3FV4VVTIJY7SN2T4GDDW37GPKGTVA4XWOPACPMJHYXDUXXNUS"
);
Server server = new Server("https://horizon-testnet.stellar.org");
List<KeyPair> channelKeypairs = channelAccountSecrets.stream()
.map(KeyPair::fromSecretSeed)
.toList();
List<AccountResponse> channelAccounts = channelKeypairs.stream()
.map(keypair -> {
try {
return server.accounts().account(keypair.getAccountId());
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.toList();
// Example hot primary account, generally only use the public key with pre-signed tx
String primaryAccountSecret = "SB6NB3SRNRQTHUXF7PQPWJP7RWY2LCL43IDWRUUSXKJOTY5SBUOVPKWL";
KeyPair primaryKeypair = KeyPair.fromSecretSeed(primaryAccountSecret);
AccountResponse primaryAccount = server.accounts().account(primaryKeypair.getAccountId());
// 500 example recipients
List<String[]> allRecipients = Arrays.asList(
new String[] {"GD7F...B2D", "..."},
new String[] {"GDX7...G7M", "..."},
new String[] {"GBI3...Q4V", "..."},
new String[] {"GBH2...N6E", "..."},
new String[] {"GCJY...OJ2", "..."}
);
// `i` here just iterates over the `channelAccountSecrets` list
for (int i = 0; i < channelAccounts.size(); i++) {
AccountResponse channelAccount = channelAccounts.get(i);
Transaction.Builder transactionBuilder = new Transaction.Builder(channelAccount, Network.TESTNET)
.setBaseFee(Transaction.MIN_BASE_FEE);
for (String recipient : allRecipients.get(i)) {
transactionBuilder.addOperation(
new PaymentOperation.Builder(recipient, AssetTypeNative.INSTANCE, "10")
.setSourceAccount(primaryKeypair.getAccountId())
.build()
);
}
Transaction transaction = transactionBuilder.setTimeout(3600).build();
transaction.sign(primaryKeypair); // This can be done before sending to the channel.
// You can implement other MPC signers over the primary account here.
transaction.sign(channelKeypairs.get(i)); // Approve being the transaction source.
try {
SubmitTransactionResponse response = server.submitTransaction(transaction);
System.out.println("Transaction succeeded");
} catch (Exception e) {
System.out.println("Transaction failed: " + e.getMessage());
}
}
func main() {
channelAccountSecrets := []string{
"SBXEVUPBW66BU5F2NU4S4QMOBTAU7TVTF4HXWD37VKPTHEC4ULXFGRUH",
"SBQMVILQKB2MXIDQIUN6FGS26PDNBRYKZM7WYZWHEU5MAGCP6HDNV74S",
"SAN3ZTCSWSLVQIBBB4IHN6566K4LMRCIAAXE7THYGB3L45JDTX7WVWGY",
"SACBLHU7OJJR2GTVBNX6WR7OR77CZ3NQHHIHRTEKV5OPO4IKMR2GDRIQ",
"SCFFCZS3FV4VVTIJY7SN2T4GDDW37GPKGTVA4XWOPACPMJHYXDUXXNUS",
}
server := horizonclient.DefaultTestNetClient
var channelKeypairs []*keypair.Full
for _, secret := range channelAccountSecrets {
kp, err := keypair.ParseFull(secret)
check(err)
channelKeypairs = append(channelKeypairs, kp)
}
var channelAccounts []horizon.Account
for _, kp := range channelKeypairs {
accountRequest := horizonclient.AccountRequest{AccountID: kp.Address()}
account, err := server.AccountDetail(accountRequest)
check(err)
channelAccounts = append(channelAccounts, account)
}
// Example hot primary account, generally only use the public key with pre-signed tx
primaryAccountSecret := "SB6NB3SRNRQTHUXF7PQPWJP7RWY2LCL43IDWRUUSXKJOTY5SBUOVPKWL"
primaryKeypair, err := keypair.ParseFull(primaryAccountSecret)
check(err)
primaryAccountRequest := horizonclient.AccountRequest{AccountID: primaryKeypair.Address()}
primaryAccount, err := server.AccountDetail(primaryAccountRequest)
check(err)
// 500 example recipients
allRecipients := [][]string{
{"GD7F...B2D", "..."},
{"GDX7...G7M", "..."},
{"GBI3...Q4V", "..."},
{"GBH2...N6E", "..."},
{"GCJY...OJ2", "..."},
}
// Channel index iterates over channelAccountSecrets slice keys
for channelIndex, channelAccount := range channelAccounts {
var txOps []txnbuild.Operation
for _, recipients := range allRecipients[channelIndex] {
paymentOp := txnbuild.Payment{
Destination: recipients,
Amount: "10",
Asset: txnbuild.NativeAsset{},
SourceAccount: &primaryKeypair.Address(),
}
txOps = append(txOps, &paymentOp)
}
tx, err := txnbuild.NewTransaction(
txnbuild.TransactionParams{
SourceAccount: &channelAccount,
IncrementSequenceNum: true,
Operations: txOps,
BaseFee: 640,
},
)
check(err)
// You can implement other MPC signers over the primary account here.
tx, err = tx.Sign(
network.TestNetworkPassphrase,
primaryKeypair, // Signature can be made before sending to the channel.
channelKeypairs[channelIndex] // Approve being the transaction source.
)
check(err)
resp, err := server.SubmitTransaction(tx)
check(err)
}
}
}
Principle of Least Trust
In the custody chain for channels, assets generally leave the primary account. The channel accounts only consume transaction fees and current sequence numbers. By separating transaction approvals from network submissions, you can manage business logic offline, signing more securely.
This separation of duties also allows you to manage approvals in real time with MPC keys configured to only sign in specific approved instances. You can send the transaction envelope to channel accounts after signing for your operations. This leaves you protected even if an attacker uncovers the hot keys for .

Channel accounts should have no signing authority over the primary account. However, a primary account or other transaction generator should know channel account public keys. This lets you build initial envelopes with specific channels as the transaction source.
This approach works because of Stellar's unique origin-agnostic design. Namely, accounts can submit operations signed by and for any other accounts. This flexibility lets you encode different sources throughout a transaction:
- Transaction Envelope (Channel): Every transaction requires an envelope signer. This is the source account for the entire transaction envelope. This becomes the default source of transaction fees and the exclusive source of sequence numbers.
- Individual Operations (Main): Set different source accounts for specific operations within the transaction (individual operations) based on where the transacting assets exist.
- Wrapping Context (Optional): Use fee-bump transactions to wrap envelopes if you need to adjust fees on stuck transactions.
The network only accepts the final transaction once it is wholly constructed and signed by all required accounts, but not more.
Configuration Requirements
Channel accounts let you reliably send transactions without waiting for submission acknowledgments. While this greatly increases your potential transaction rate, channel accounts also introduce operational requirements. These considerations keep your operations in sync with Stellar Core while maintaining high throughput.
Instead of channel accounts, you can set minimum sequence number preconditions, ensuring transactions are processed in the correct order. This option will not speed up your submissions or solve failed transactions in bursts. However, if you only have a medium-frequency application, then preconditions can ensure execution integrity.
Necessary Signers
The above example assumes that your accounts are all properly configured on the network. needs the least lumens to cover transaction fees, but you still need to fund the account with minimal base reserves. In contrast, need the most lumens since they pay for all transactional costs.
State Rotation
You can distribute transaction submissions evenly across channel accounts to maximize throughput. This example walks through one way to monitor and manage the lifecycle of channel accounts. It combines our preparations with funding, transaction submission, and securing each account.
Principally, channel account groups use two states for effective rotation:
-
In Use / Submitting: These are accounts currently submitting transactions. They are temporarily locked until their transactions are confirmed. If you have your own validator, then you can include them directly in your proposed transaction set.
- Locking Mechanism: Once a transaction is built and submitted by a channel account, that account should be marked as "in use" and should not be assigned new transactions until the current ones are confirmed or dropped.
- Monitoring: Continuously monitor the status of these transactions to determine when the channel accounts become available again.
-
Available: These are channel accounts that are ready to be assigned new transactions. They have either completed their previous transactions or are idle.
- Assignment: Distribute new transactions to these available accounts to maintain high throughput and avoid delays.
Here's an example of what that might look like:
- Python
- JavaScript
- Java
- Go
# Dynamic recipients' public keys
allRecipients = ["GD72...B2D", "GDX7...G7M", "GBI3...Q4V", "GBH2...N6E", "GCJY...OJ2", ...]
channelAccountsTracker = [
{ # index = 0-4 in our example
"account": channelAccounts[i],
"keypair": channelKeypairs[i],
"state": "available"
} for i in range(len(channelAccounts))
]
txOutput = []
recipientIndex = 0
while recipientIndex < len(allRecipients):
for channelData in channelAccountsTracker:
if recipientIndex >= len(allRecipients): break
if channelData["state"] == "available":
channelData["state"] = "active"
txBundle = TransactionBuilder(
source_account = channelData["account"],
network_passphrase = Network.TESTNET_NETWORK_PASSPHRASE,
base_fee = 640
)
for _ in range(100):
if recipientIndex >= len(allRecipients): break
txBundle.append_payment_op(
source = primaryKeypair.public_key,
destination = allRecipients[recipientIndex],
amount = "10",
asset = Asset.native()
)
recipientIndex += 1
txBundle = txBundle.set_timeout(3600).build()
txBundle.sign(primaryKeypair)
txBundle.sign(channelData["keypair"])
txOutput.append((transaction, channelData))
else:
print("No available channel account. Waiting...")
# Perhaps sleep here
continue
failedRecipients = []
for bundle, channelData in txOutput:
try:
response = server.submit_transaction(bundle)
print(f"Transaction succeeded with hash: {response['hash']}")
except Exception as e:
print(f"Transaction failed: {e}")
# Partial failure logic example
for op in bundle.operations:
if isinstance(op, Asset): # Assuming only payments
failedRecipients.append(op.destination)
finally:
channelData["state"] = "available"
// Dynamic recipients' public keys
const allRecipients = ["GD72...B2D", "GDX7...G7M", "GBI3...Q4V", "GBH2...N6E", "GCJY...OJ2", ...];
// index = 0-4 in our example
const channelAccountsTracker = channelAccounts.map((account, index) => ({
account: account,
keypair: channelKeypairs[index],
state: "available"
}));
const txOutput = [];
let recipientIndex = 0;
while (recipientIndex < allRecipients.length) {
for (const channelData of channelAccountsTracker) {
if (recipientIndex >= allRecipients.length) break;
if (channelData.state === "available") {
channelData.state = "active";
let txBundle = new TransactionBuilder(channelData.account, {
networkPassphrase: Networks.TESTNET,
fee: "640"
});
for (let i = 0; i < 100; i++) {
if (recipientIndex >= allRecipients.length) break;
txBundle = txBundle.addOperation(Operation.payment({
source: primaryKeypair.publicKey(),
destination: allRecipients[recipientIndex],
asset: Asset.native(),
amount: "10"
}));
recipientIndex++;
}
const tx = txBundle.setTimeout(3600).build();
tx.sign(primaryKeypair);
tx.sign(channelData.keypair);
txOutput.push({ transaction: tx, channelData: channelData });
} else {
console.log("No available channel account. Waiting...");
// Perhaps sleep here
continue;
}
}
}
const failedRecipients = [];
for (const {transaction, channelData} of txOutput) {
try {
server.submitTransaction(transaction).then(response => {
console.log(`Transaction succeeded with hash: ${response.hash}`);
}).catch(error => {
console.log(`Transaction failed: ${error}`);
for (const op of transaction.operations) {
if (op.type === "payment") {
failedRecipients.push(op.destination);
}
}
});
} finally {
channelData.state = "available";
}
}
import java.util.ArrayList
public static void main(String[] args) {
// Dynamic recipients' public keys
String[] allRecipients = {"GD72...B2D", "GDX7...G7M", "GBI3...Q4V", "GBH2...N6E", "GCJY...OJ2"};
// index i = 0-4 in our example
List<ChannelAccount> channelAccountsTracker = new ArrayList<>();
for (int i = 0; i < channelAccounts.length; i++) {
channelAccountsTracker.add(new ChannelAccount(channelAccounts[i], channelKeypairs[i], "available"));
}
List<TransactionBundle> txOutput = new ArrayList<>();
int recipientIndex = 0
while (recipientIndex < allRecipients.length) {
for (ChannelAccount channelData : channelAccountsTracker) {
if (recipientIndex >= allRecipients.length) break;
if (channelData.state.equals("available")) {
channelData.state = "active";
Transaction.Builder txBuilder = new Transaction.Builder(channelData.account, Network.TESTNET)
.setBaseFee(640)
for (int i = 0; i < 100; i++) {
if (recipientIndex >= allRecipients.length) break;
txBuilder.addOperation(new PaymentOperation.Builder(
primaryKeypair.getAccountId(),
allRecipients[recipientIndex],
AssetTypeNative.INSTANCE, "10"
).build());
recipientIndex++;
}
Transaction txBundle = txBuilder.setTimeout(3600).build();
txBundle.sign(primaryKeypair);
txBundle.sign(channelData.keypair);
txOutput.add(new TransactionBundle(txBundle, channelData));
} else {
System.out.println("No available channel account. Waiting...");
// Perhaps sleep here
continue;
}
}
}
List<String> failedRecipients = new ArrayList<>();
for (TransactionBundle txBundle : txOutput) {
try {
TransactionResponse response = server.submitTransaction(txBundle.transaction);
System.out.println("Transaction succeeded with hash: " + response.getHash());
} catch (Exception e) {
System.out.println("Transaction failed: " + e.getMessage());
for (Operation op : txBundle.transaction.getOperations()) {
if (op instanceof PaymentOperation) {
failedRecipients.add(((PaymentOperation) op).getDestination());
}
}
} finally {
txBundle.channelData.state = "available";
}
}
}
static class ChannelAccount {
Account account;
KeyPair keypair;
String state
ChannelAccount(Account account, KeyPair keypair, String state) {
this.account = account;
this.keypair = keypair;
this.state = state;
}
}
static class TransactionBundle {
Transaction transaction;
ChannelAccount channelData
TransactionBundle(Transaction transaction, ChannelAccount channelData) {
this.transaction = transaction;
this.channelData = channelData;
}
}
import "fmt"
// Dynamic recipients' public keys
var allRecipients = []string{"GD72...B2D", "GDX7...G7M", "GBI3...Q4V", "GBH2...N6E", "GCJY...OJ2"}
func main() {
channelAccountsTracker := make([]map[string]interface{}, len(channelAccounts))
// index = 0-4 in our example
for index, account := range channelAccounts {
channelAccountsTracker[index] = map[string]interface{}{
"account": account,
"keypair": channelKeypairs[i],
"state": "available",
}
}
txOutput := []struct {
transaction *txnbuild.Transaction
channelData map[string]interface{}
}{}
recipientIndex := 0
for recipientIndex < len(allRecipients) {
for _, channelData := range channelAccountsTracker {
if recipientIndex >= len(allRecipients) {
break
}
if channelData["state"] == "available" {
channelData["state"] = "active"
sourceAccount := channelData["account"].(*txnbuild.SimpleAccount)
txBundle := txnbuild.TransactionParams{
SourceAccount: sourceAccount,
BaseFee: txnbuild.MinBaseFee * 10,
Preconditions: txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(3600)},
IncrementSequenceNum: true,
Operations: []txnbuild.Operation{},
}
for i := 0; i < 100; i++ {
if recipientIndex >= len(allRecipients) {
break
}
paymentOp := txnbuild.Payment{
SourceAccount: primaryKeypair.Address(),
Destination: allRecipients[recipientIndex],
Amount: "10",
Asset: txnbuild.NativeAsset{},
}
txBundle.Operations = append(txBundle.Operations, &paymentOp)
recipientIndex++
}
tx, err := txnbuild.NewTransaction(txBundle)
check(err)
tx, err = tx.Sign(
network.TestNetworkPassphrase,
primaryKeypair,
channelData["keypair"].(*keypair.Full)
)
check(err)
txOutput = append(
txOutput, struct {
transaction *txnbuild.Transaction
channelData map[string]interface{}
}{
transaction: tx,
channelData: channelData
})
} else {
fmt.Println("No available channel account. Waiting...")
// Perhaps sleep here
continue
}
}
}
failedRecipients := []string{}
for _, output := range txOutput {
resp, err := server.SubmitTransaction(output.transaction)
if err != nil {
fmt.Printf("Transaction failed: %v\n", err)
for _, op := range output.transaction.Operations() {
if paymentOp, ok := op.(*txnbuild.Payment); ok {
failedRecipients = append(failedRecipients, paymentOp.Destination)
}
}
} else {
fmt.Printf("Transaction succeeded with hash: %s\n", resp.Hash)
}
output.channelData["state"] = "available"
}
}
Bundle Size
The bundle size is how many operations you fit in each transaction sent by channel accounts. While the network defines a maximum of 100, it may take longer than desired for your flow of operations to reach this threshold. If that's the case, each channel can submit their own transaction each ledger with all incoming operations.
In our example, we assume the bulk payment operations greatly exceed a single transaction, must occur promptly, and come pre-signed by . Here, the main constraint is network capacity itself, as referenced earlier with payment channels. Accordingly, it is best practice to considerately send channel transactions based on how much you want to pay in fees.
If you fill up the ledger with all of your own transactions, you can expect to pay exponentially-higher fees than dynamic bundle sizes which spread operations out over time. This chiefly depends on the rate you want to submit transactions. As long as you aren't consistently above 100 operations per ledger, each channel can just submit a transaction each ledger.

You can create as many channel accounts as needed to maintain your desired transaction rate. However, one ledger can only fit 1,000 operations from all peers. To keep fees within reason and minimize congestion, it is best practice to limit yourself to 300 operations per ledger. For higher throughput in non-emergency applications, consider using other scaling solutions like Starlight.
Implementation Considerations
We walked through one example of using channel accounts, but ultimately their application depends on your use case. Therefore, we will wrap up by reiterating ecosystem design choices related to high-frequency transaction submission. These foundational choices can help you get the most out of Stellar no matter your size.
Security
You must sign the transaction with both the primary-account and channel-account keys since the final transaction implicates both keypairs. It's best practice to keep these signatures in isolated instances or storage devices so as to minimize breach risks. For instance, to protect your main account, you can deploy security practices around hot keys, signature weights, and access rotations.
Receiving Node
Horizon instances limit request frequency by default. If you both submit transactions and read data quickly, then your channel accounts can exceed a public validator's request threshold. If you consistently have excessive queries, you may need your own node to submit transaction sets quickly.
Fee Sponsorships
While we've discussed sending lumens directly to the channel accounts, you can also have transaction fees sponsored by the primary account. In a custodial solution, you may prefer that channel accounts hold no assets at all, maintaining trustlines with sponsored reserves for example. While you can use payments from to to cover fees each transaction, it is best practice to simply leave channels with adequate funding.
Guides in this category:
📄 Create an Account
Learn about creating Stellar accounts, keypairs, funding, and account basics.
📄 Send to and receive payments from Contract Accounts
Learn to send payments to and receive payments from Contract Accounts on the Stellar network.
📄 Send and receive payments
Learn to send payments and watch for received payments on the Stellar network.
📄 Channel Accounts
Create channel accounts to submit transactions to the network at a high rate.
📄 Signing Soroban contract invocations
Learn two methods to sign Soroban smart contract invocations: full transaction signing for G-accounts and auth-entry signing for G or C-accounts with sponsored fees.
📄 Claimable Balances
Split a payment into two parts by creating a claimable balance.
📄 Clawbacks
Use clawbacks to burn a specific amount of a clawback-enabled asset from a trustline or claimable balance.
📄 Fee-bump Transactions
Use fee-bump transactions to pay for transaction fees on behalf of another account without re-signing the transaction.
📄 Sponsored Reserves
Use sponsored reserves to pay for base reserves on behalf of another account.
📄 Path Payments
Send a payment where the asset received differs from the asset sent.
📄 Pooled accounts: muxed accounts and memos
Use muxed accounts to differentiate between individual accounts in a pooled account.
📄 Install and deploy a smart contract with code
Install and deploy a smart contract with code.
📄 Invoke a contract function in a transaction using SDKs
Use the Stellar SDK to create, simulate, and assemble a transaction.
📄 simulateTransaction RPC method guide
simulateTransaction examples and tutorials guide.
📄 Submit a transaction to Stellar RPC using the JavaScript SDK
Use a looping mechanism to submit a transaction to the RPC.
📄 Upload WebAssembly (Wasm) bytecode using code
Upload the Wasm of the contract using js-stellar-sdk.