forked from stellar/stellar-docs
-
Notifications
You must be signed in to change notification settings - Fork 0
/
channel-accounts.mdx
990 lines (792 loc) · 39.3 KB
/
channel-accounts.mdx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
---
title: Channel Accounts
sidebar_position: 10
---
import { CodeExample } from "@site/src/components/CodeExample";
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](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0006.md) hot signing keys. It is best practice to extensively test your systems on [the testnet](../../fundamentals/networks.mdx#testnet) before deploying in high-speed production. Moreover, you may consider using hardware security modules or [multisignature wallets](../security/signatures-multisig.mdx#thresholds) to secure your accounts.
:::
## Background Motivation
Channel accounts take advantage of the fact that the [source account](../../glossary.mdx#source-account) of a [transaction](../../../data/horizon/api-reference/resources/transactions/README.mdx) ($S_T$) can be different than the source account of the [operations](../../../data/horizon/api-reference/resources/operations/README.mdx) inside a transaction ($S_O$).
![Separate Sources](/assets/channel-accounts/layering.png)
<!-- edit all imgs at https://www.canva.com/design/DAGKequMtSI/X8aIKrg76X8M-Ik69RAHiQ/view -->
:::info Packet Propogation
Stellar validators are spread across the globe, making it challenging to garauntee immediate order of arrival from one source. Only by sequencing [a validator](../../../validators/README.mdx) can you control physical delays for your transactions, as signals vary in [network lag](https://stellarbeat.io/nodes/GARYGQ5F2IJEBCZJCBNPWNWVDOFK7IBOHLJKKSG2TMHDQKEEC6P4PE4V?center=1#:~:text=Externalize,lag) to reach [other nodes](../../../data/horizon/admin-guide/ingestion.mdx). 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 ($A_P$) to hold [lumens](../../fundamentals/lumens.mdx) and five channel accounts for submitting transactions ($A_{C_{1\text{--}5}}$).
![Asset Allocation](/assets/channel-accounts/custody.png)
### Sequence Numbers
The network rejects transactions with [sequence numbers](../../glossary.mdx#sequence-number) that are not strictly increasing. Previously, if you sent even just two transactions in the same ledger, there was a [reasonable chance](https://stellar.stackexchange.com/questions/1675/channel-concept-in-stellar) they would arrive out of sequence. Accordingly, to prevent failures, the network [now restricts](https://stellar.org/blog/developers/proposed-changes-to-transaction-submission#:~:text=1,ledger) each source account to submit no more than one transaction per ledger.
<CodeExample>
```python
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)
```
```js
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);
```
```java
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();
}
}
```
```go
package main
import (
"github.com/stellar/go/clients/horizonclient"
"github.com/stellar/go/keypair"
"github.com/stellar/go/network"
"github.com/stellar/go/protocols/horizon"
"github.com/stellar/go/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)
}
```
</CodeExample>
### 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](../../fundamentals/transactions/operations-and-transactions.mdx#transactions) 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-deman applications. Here are the accounts for our example, which might be separated into different levels of custody security in production:
1. **Primary Account ($A_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 ($A_{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 ($A_{MPC}$)**: Enhance security by ensuring that no single account has unilateral control over the assets. For example, multiple signers can have authority over $A_P$'s `medium` [threshold](../security/signatures-multisig.mdx#thresholds) to facilitate valid channel transactions without using $A_P$'s (cold) master key(s).
![Separated Isolation](/assets/channel-accounts/segregation.png)
The simple solution of sending all 500 payments payments from $A_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.[^fee-size-ex]
[^fee-size-ex]: For our example, we'll assume the goal is high throughput rather than operational delegation. In the [delegation examples](../security/signatures-multisig.mdx#example-3-expense-accounts) only a single hot signer may be necessary. Accordingly, let us use an [abnormally-high](https://horizon.stellar.org/fee_stats) base fee of `640` for inclusion in an example ledger.
:::info
This approach ensures that the channel accounts only perform the necessary network submissions, while the primary account retains secure custody over the assets.
:::
<CodeExample>
```python
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}")
```
```js
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}`);
}
}
};
```
```java
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());
}
}
```
```go
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)
}
}
}
```
</CodeExample>
### 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 $A_{C_{1\text{--}5}}$.
![Key Relationship](/assets/channel-accounts/signers.png)
:::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](../../glossary.mdx#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](https://github.com/stellar/stellar-docs/issues/1022) based on where the transacting assets exist.
- **Wrapping Context** (Optional): Use [fee-bump transactions](https://quest.stellar.org/side-quests/1) to [wrap envelopes](fee-bump-transactions.mdx#fee-account) if you need to [adjust fees](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0015.md) on [stuck transactions](https://bitcoin.stackexchange.com/questions/118184/stuck-transaction-with-enough-fee-rate).
:::info
The network only accepts the final transaction once it is wholly constructed and signed by all required accounts, but [not more](https://github.com/stellar/stellar-docs/issues/773).
:::
## 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](https://github.com/stellar/stellar-core/tree/master) while maintaining high throughput.
:::note
Instead of channel accounts, you can set [minimum sequence number](../../fundamentals/transactions/operations-and-transactions.mdx#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. $A_P$ needs the least lumens to cover transaction fees, but you still need to fund the account with minimal base reserves. In contrast, $A_{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:
<CodeExample>
```python
# 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"
```
```js
// 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";
}
}
```
```java
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;
}
}
```
```go
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"
}
}
```
</CodeExample>
### Bundle Size
The bundle size is how many operations you fit in each transaction sent by channel accounts. While the network [defines a maximum](../../../networks/software-versions.mdx) [of 100](../../../validators/admin-guide/monitoring.mdx#general-node-information), 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.
<img
src="/assets/channel-accounts/maximization.png"
alt="Desired Flow"
width="360"
></img>
In our example, we assume the bulk payment operations greatly exceed a single transaction, must occur promptly, and come pre-signed by $A_P$. Here, the main constraint is network capactity 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](../../fundamentals/fees-resource-limits-metering.mdx#surge-pricing) 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](/assets/channel-accounts/congesting.png)
:::note Block Size
You can create as many channel accounts as needed to maintain your desired transaction rate. However, one ledger [can only fit](https://stellar.expert/explorer/public/protocol-history) 1,000 operations from all peers. To keep [fees](../../fundamentals/fees-resource-limits-metering.mdx#inclusion-fee) 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](../../glossary.mdx#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 intstances 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](../../../data/horizon/api-reference/structure/rate-limiting.mdx) 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](../../../validators/admin-guide/README.mdx) 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](../../../../platforms/anchor-platform/admin-guide/custody-services), you may prefer that channel accounts hold no assets at all, maintaing trustlines with [sponsored reserves](sponsored-reserves.mdx) for example. While you can use payments from $A_P$ to $A_C$ to cover fees each transaction, it is best practice to simply leave channels with adequate funding.