Skip to content

Commit 043a417

Browse files
committed
tx scaling guide - initial version
1 parent 82eca0f commit 043a417

File tree

2 files changed

+250
-0
lines changed

2 files changed

+250
-0
lines changed
Loading
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
---
2+
title: Executing Concurrent Transactions from a Single Account
3+
sidebar_label: Executing Concurrent Transactions from a Single Account
4+
---
5+
6+
# Executing Concurrent Transactions from a Single Account
7+
8+
Flow is designed for the consumer internet scale and is one of the fastest blockchains in the world. Transaction traffic on deployed contracts can be categorized into two groups:
9+
10+
1. **User Transactions:** This includes transactions that are initiated by users. Examples of this include:
11+
- Buying or selling NFTs
12+
- Transferring tokens
13+
- Swapping tokens on DEXs
14+
- Staking or unstaking tokens
15+
16+
In this category, every transaction originates from a different account and is sent to the Flow network from a different machine. Developers are not required to do anything special to scale for this category, other than making sure their logic is mostly onchain and their systems (like frontend, backend, etc.) can scale if they become bottlenecks. Flow handles scaling for this category as part of the protocol.
17+
18+
2. **System Transactions:** This includes any transactions that are initiated by the app backend or various tools. Examples of this category include:
19+
- Minting 1000s of tokens from a single Minter
20+
- Creating a Transaction worker for custodians
21+
- Runnig maintenance jobs and batch operations
22+
23+
In this category, many transactions originate from the same account and are sent to the Flow network from the same machine. Scaling transactions from a single account can be tricky. This guide is focused on this type of scaling.
24+
25+
In this guide, we'll explore how to execute concurrent transactions from a single account on Flow using multiple proposer keys.
26+
27+
Please note that this guide only applies to non-EVM transactions as for EVM transactions you can use any EVM-compatible strategy to scale.
28+
29+
## Problem
30+
31+
Blockchains use sequence numbers, aka nonces, per transaction to prevent [replay attacks](https://en.wikipedia.org/wiki/Replay_attack) and enable users to specify transaction ordering. The Flow network expects a specific sequence number for each incoming transaction and it will reject the transaction if the sequence number is not the exact next number. This will be problematic to scaling because if you send multiple transactions there's no guarantee that the order they will be executed will match the order that they were sent. This is core to Flow's MEV resistance as transaction ordering is randomized per block. When an out-of-order transaction is received by the network, an error message like this will be returned:
32+
```
33+
* checking sequence number failed: [Error Code: 1007] invalid proposal key: public key X on account 123 has sequence number 7, but given 6
34+
```
35+
Our goal is to run several concurrent transactions without running into the above error. While designing our solution, we must consider the following:
36+
37+
- **Reliability:** We ideally like to avoid local sequence number management as it's error prone. In a local sequence number implementation, the sender has to know which error types increase the sequence number and which do not. For example, network issues do not increase the sequence number, but application errors do. Additionally, if the sender is out of sync with the network, multiple transactions can fail.
38+
39+
The most reliable way to manage sequence numbers is to ask the network what the latest number is before signing and sending a transaction.
40+
41+
- **Scalability:** Having several workers manage the same sequence number can lead to coupling and synchroniztion issues. We would like to decouple our workers so they can work independantly.
42+
43+
- **Queue Support:** Guaranteeing no errors means that the system needs to know when it is at capacity. Extra transactions should be queued up and executed when there is enough throughput to do so. Fire and forget strategies are not reliable with arbitrary traffic since they don't know when they are at capacity.
44+
45+
## Solution
46+
47+
Flow's transaction model introduces a new role called the proposer. Each Flow transaction is signed by 3 roles: authorizer, proposer, and payer. The proposer key is used to determine the sequence number for the transaction. In this way, the sequence number is decoupled from the transaction authorizer and can be scaled independently. You can learn more about this [here](https://developers.flow.com/build/basics/transactions#proposal-key).
48+
49+
We can leverage this concept to build the ideal system transaction architecture:
50+
51+
* Flow accounts can have multiple keys. We can assign a different proposer key to each worker, so that each worker can manage its own sequence number independently from other workers.
52+
53+
* Each worker can guarantee the correct sequence number by fetching the latest sequence number from the network. Since workers use different proposer keys, they will not conflict with each other or run into synchronization issues.
54+
55+
* Each worker grabs a transaction request from the incoming requests queue, signs it with it's assigned proposer key, and sends it to the network. The worker will remain busy until the transaction is finalized by the network.
56+
57+
* When all workers are busy, the incoming requests queue will hold the remaining requests until there is enough capacity to execute them.
58+
59+
* We can further optimize this by re-adding the same key multiple times to the same account. This way, we can avoid generating new cryptographic keys for each worker. These new keys can have 0 weight since they never authorize transactions.
60+
61+
Here's a screenshot of how such an [account](https://www.flowdiver.io/account/0x18eb4ee6b3c026d2?tab=keys) can look like:
62+
63+
![Example.Account](scaling-example-account.png "Example Account")
64+
65+
You can see that the account has extra weightless keys for proposal with their own independent sequence numbers.
66+
67+
In the next section, we'll demonstrate how to implement this architecture using the Go SDK.
68+
69+
## Example Implementation
70+
71+
An example implementation of this architecture can be found in this [Go SDK Example](https://github.com/onflow/flow-go-sdk/blob/master/examples/transaction_scaling/main.go).
72+
73+
This example deploys a simple `Counter` contract:
74+
75+
```cadence
76+
access(all) contract Counter {
77+
78+
access(self) var count: Int
79+
80+
init() {
81+
self.count = 0
82+
}
83+
84+
access(all) fun increase() {
85+
self.count = self.count + 1
86+
}
87+
88+
access(all) view fun getCount(): Int {
89+
return self.count
90+
}
91+
}
92+
```
93+
94+
The goal is to hit the `increase()` function 420 times concurrently from a single account. By adding 420 concurrency keys and using 420 workers, we should be able to execute all these transactions roughly at the same time.
95+
96+
Please note that you need to create a new testnet account to run this example. You can do this by running the following command:
97+
98+
```bash
99+
flow keys generate
100+
```
101+
102+
You can create a new testnet account with the generated link with the [faucet](https://testnet-faucet.onflow.org/create-account).
103+
104+
When the example starts, we'll deploy the `Counter` contract to the account and add 420 proposer keys to it:
105+
106+
```cadence
107+
transaction(code: String, numKeys: Int) {
108+
109+
prepare(signer: auth(AddContract, AddKey) &Account) {
110+
// deploy the contract
111+
signer.contracts.add(name: "Counter", code: code.decodeHex())
112+
113+
// copy the main key with 0 weight multiple times
114+
// to create the required number of keys
115+
let key = signer.keys.get(keyIndex: 0)!
116+
var count: Int = 0
117+
while count < numKeys {
118+
signer.keys.add(
119+
publicKey: key.publicKey,
120+
hashAlgorithm: key.hashAlgorithm,
121+
weight: 0.0
122+
)
123+
count = count + 1
124+
}
125+
}
126+
}
127+
```
128+
129+
We will then proceed to run the workers concurrently, grabbing a transaction request from the queue and executing it:
130+
131+
```go
132+
// populate the job channel with the number of transactions to execute
133+
txChan := make(chan int, numTxs)
134+
for i := 0; i < numTxs; i++ {
135+
txChan <- i
136+
}
137+
138+
startTime := time.Now()
139+
140+
var wg sync.WaitGroup
141+
// start the workers
142+
for i := 0; i < numProposalKeys; i++ {
143+
wg.Add(1)
144+
145+
// worker code
146+
// this will run in parallel for each proposal key
147+
go func(keyIndex int) {
148+
defer wg.Done()
149+
150+
// consume the job channel
151+
for range txChan {
152+
fmt.Printf("[Worker %d] executing transaction\n", keyIndex)
153+
154+
// execute the transaction
155+
err := IncreaseCounter(ctx, flowClient, account, signer, keyIndex)
156+
if err != nil {
157+
fmt.Printf("[Worker %d] Error: %v\n", keyIndex, err)
158+
return
159+
}
160+
}
161+
}(i)
162+
}
163+
164+
close(txChan)
165+
166+
// wait for all workers to finish
167+
wg.Wait()
168+
```
169+
170+
The next important bit is the `IncreaseCounter` function. This function will execute the `increase()` function on the `Counter` contract. It will fetch the latest sequence number from the network, sign the transaction with the correct proposer key, and send it to the network.
171+
172+
```go
173+
// Increase the counter by 1 by running a transaction using the given proposal key
174+
func IncreaseCounter(ctx context.Context, flowClient *grpc.Client, account *flow.Account, signer crypto.Signer, proposalKeyIndex int) error {
175+
script := []byte(fmt.Sprintf(`
176+
import Counter from 0x%s
177+
178+
transaction() {
179+
prepare(signer: &Account) {
180+
Counter.increase()
181+
}
182+
}
183+
184+
`, account.Address.String()))
185+
186+
tx := flow.NewTransaction().
187+
SetScript(script).
188+
AddAuthorizer(account.Address)
189+
190+
// get the latest account state including the sequence number
191+
account, err := flowClient.GetAccount(ctx, flow.HexToAddress(account.Address.String()))
192+
if err != nil {
193+
return err
194+
}
195+
tx.SetProposalKey(
196+
account.Address,
197+
account.Keys[proposalKeyIndex].Index,
198+
account.Keys[proposalKeyIndex].SequenceNumber-1,
199+
)
200+
201+
return RunTransaction(ctx, flowClient, account, signer, tx)
202+
}
203+
```
204+
205+
Note that the above code is executed concurrently for each worker. Since each worker is operating on a different proposer key, they will not conflict with each other or run into synchronization issues.
206+
207+
Lastly, `RunTransaction` is a helper function that sends the transaction to the network and waits for it to be finalized. Please note that the proposer key sequence number is set in `IncreaseCounter` before calling `RunTransaction`.
208+
209+
```go
210+
// Run a transaction and wait for it to be sealed. Note that this function does not set the proposal key.
211+
func RunTransaction(ctx context.Context, flowClient *grpc.Client, account *flow.Account, signer crypto.Signer, tx *flow.Transaction) error {
212+
latestBlock, err := flowClient.GetLatestBlock(ctx, true)
213+
if err != nil {
214+
return err
215+
}
216+
tx.SetReferenceBlockID(latestBlock.ID)
217+
tx.SetPayer(account.Address)
218+
219+
err = SignTransaction(ctx, flowClient, account, signer, tx)
220+
if err != nil {
221+
return err
222+
}
223+
224+
err = flowClient.SendTransaction(ctx, *tx)
225+
if err != nil {
226+
return err
227+
}
228+
229+
txRes := examples.WaitForSeal(ctx, flowClient, tx.ID())
230+
if txRes.Error != nil {
231+
return txRes.Error
232+
}
233+
234+
return nil
235+
}
236+
```
237+
238+
Running the example will run 420 transactions at the same time:
239+
240+
```bash
241+
cd ./examples
242+
→ go run ./transaction_scaling/main.go
243+
.
244+
.
245+
.
246+
Final Counter: 420
247+
✅ Done! 420 transactions executed in 11.695372059s
248+
```
249+
250+
It takes roughly the time of 1 transaction to run all 420 without any errors.

0 commit comments

Comments
 (0)