Skip to main content

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.

caution

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 (STS_T) can be different than the source account of the operations inside a transaction (SOS_O).

Separate Sources

Packet Propagation

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 (APA_P) to hold lumens and five channel accounts for submitting transactions (AC15A_{C_{1\text{--}5}}).

Asset Allocation

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.

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)

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:

  1. Primary Account (APA_P): 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.
  2. Channel Accounts (AC15A_{C_{1\text{--}5}}): 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.
  3. Multisig Signers (AMPCA_{MPC}): Enhance security by ensuring that no single account has unilateral control over the assets. For example, multiple signers can have authority over APA_P's medium threshold to facilitate valid channel transactions without using APA_P's (cold) master key(s).

Separated Isolation

The simple solution of sending all 500 payments from APA_P 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.

info

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.

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}")

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 AC15A_{C_{1\text{--}5}}.

Key Relationship

note

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.
info

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.

note

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. APA_P needs the least lumens to cover transaction fees, but you still need to fund the account with minimal base reserves. In contrast, AC15A_{C_{1\text{--}5}} 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:

  1. 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.
  2. 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:

# 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"

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.

Desired Flow

In our example, we assume the bulk payment operations greatly exceed a single transaction, must occur promptly, and come pre-signed by APA_P. 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.

Filling Ledger

Block Size

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 APA_P to ACA_C to cover fees each transaction, it is best practice to simply leave channels with adequate funding.

Guides in this category: