Executing Concurrent Transactions from a Single Account
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:
-
User Transactions: This includes transactions that are initiated by users. Examples of this include:
- Buying or selling NFTs
- Transferring tokens
- Swapping tokens on DEXs
- Staking or unstaking tokens
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.
-
System Transactions: This includes any transactions that are initiated by the app backend or various tools. Examples of this category include:
- Minting 1000s of tokens from a single Minter
- Creating a Transaction worker for custodians
- Runnig maintenance jobs and batch operations
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.
In this guide, we'll explore how to execute concurrent transactions from a single account on Flow using multiple proposer keys.
Please note that this guide only applies to non-EVM transactions as for EVM transactions you can use any EVM-compatible strategy to scale.
Problem
Blockchains use sequence numbers, aka nonces, per transaction to prevent replay attacks 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:
_10* checking sequence number failed: [Error Code: 1007] invalid proposal key: public key X on account 123 has sequence number 7, but given 6
Our goal is to run several concurrent transactions without running into the above error. While designing our solution, we must consider the following:
-
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.
The most reliable way to manage sequence numbers is to ask the network what the latest number is before signing and sending a transaction.
-
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.
-
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.
Solution
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.
We can leverage this concept to build the ideal system transaction architecture:
-
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.
-
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.
-
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.
-
When all workers are busy, the incoming requests queue will hold the remaining requests until there is enough capacity to execute them.
-
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.
Here's a screenshot of how such an account can look like:
You can see that the account has extra weightless keys for proposal with their own independent sequence numbers.
In the next section, we'll demonstrate how to implement this architecture using the Go SDK.
Example Implementation
An example implementation of this architecture can be found in this Go SDK Example.
This example deploys a simple Counter
contract:
_16access(all) contract Counter {_16_16 access(self) var count: Int_16_16 init() {_16 self.count = 0_16 }_16_16 access(all) fun increase() {_16 self.count = self.count + 1_16 }_16_16 access(all) view fun getCount(): Int {_16 return self.count_16 }_16}
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.
Please note that you need to create a new testnet account to run this example. You can do this by running the following command:
_10flow keys generate
You can create a new testnet account with the generated link with the faucet.
When the example starts, we'll deploy the Counter
contract to the account and add 420 proposer keys to it:
_20transaction(code: String, numKeys: Int) {_20_20 prepare(signer: auth(AddContract, AddKey) &Account) {_20 // deploy the contract_20 signer.contracts.add(name: "Counter", code: code.decodeHex())_20_20 // copy the main key with 0 weight multiple times_20 // to create the required number of keys_20 let key = signer.keys.get(keyIndex: 0)!_20 var count: Int = 0_20 while count < numKeys {_20 signer.keys.add(_20 publicKey: key.publicKey,_20 hashAlgorithm: key.hashAlgorithm,_20 weight: 0.0_20 )_20 count = count + 1_20 }_20 }_20}
We will then proceed to run the workers concurrently, grabbing a transaction request from the queue and executing it:
_36// populate the job channel with the number of transactions to execute_36txChan := make(chan int, numTxs)_36for i := 0; i < numTxs; i++ {_36 txChan <- i_36}_36_36startTime := time.Now()_36_36var wg sync.WaitGroup_36// start the workers_36for i := 0; i < numProposalKeys; i++ {_36 wg.Add(1)_36_36 // worker code_36 // this will run in parallel for each proposal key_36 go func(keyIndex int) {_36 defer wg.Done()_36_36 // consume the job channel_36 for range txChan {_36 fmt.Printf("[Worker %d] executing transaction\n", keyIndex)_36_36 // execute the transaction_36 err := IncreaseCounter(ctx, flowClient, account, signer, keyIndex)_36 if err != nil {_36 fmt.Printf("[Worker %d] Error: %v\n", keyIndex, err)_36 return_36 }_36 }_36 }(i)_36}_36_36close(txChan)_36_36// wait for all workers to finish_36wg.Wait()
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.
_30// Increase the counter by 1 by running a transaction using the given proposal key_30func IncreaseCounter(ctx context.Context, flowClient *grpc.Client, account *flow.Account, signer crypto.Signer, proposalKeyIndex int) error {_30 script := []byte(fmt.Sprintf(`_30 import Counter from 0x%s_30_30 transaction() {_30 prepare(signer: &Account) {_30 Counter.increase()_30 }_30 }_30_30 `, account.Address.String()))_30_30 tx := flow.NewTransaction()._30 SetScript(script)._30 AddAuthorizer(account.Address)_30_30 // get the latest account state including the sequence number_30 account, err := flowClient.GetAccount(ctx, flow.HexToAddress(account.Address.String()))_30 if err != nil {_30 return err_30 }_30 tx.SetProposalKey(_30 account.Address,_30 account.Keys[proposalKeyIndex].Index,_30 account.Keys[proposalKeyIndex].SequenceNumber-1,_30 )_30_30 return RunTransaction(ctx, flowClient, account, signer, tx)_30}
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.
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
.
_26// Run a transaction and wait for it to be sealed. Note that this function does not set the proposal key._26func RunTransaction(ctx context.Context, flowClient *grpc.Client, account *flow.Account, signer crypto.Signer, tx *flow.Transaction) error {_26 latestBlock, err := flowClient.GetLatestBlock(ctx, true)_26 if err != nil {_26 return err_26 }_26 tx.SetReferenceBlockID(latestBlock.ID)_26 tx.SetPayer(account.Address)_26_26 err = SignTransaction(ctx, flowClient, account, signer, tx)_26 if err != nil {_26 return err_26 }_26_26 err = flowClient.SendTransaction(ctx, *tx)_26 if err != nil {_26 return err_26 }_26_26 txRes := examples.WaitForSeal(ctx, flowClient, tx.ID())_26 if txRes.Error != nil {_26 return txRes.Error_26 }_26_26 return nil_26}
Running the example will run 420 transactions at the same time:
_10→ cd ./examples_10→ go run ./transaction_scaling/main.go_10._10._10._10Final Counter: 420_10✅ Done! 420 transactions executed in 11.695372059s
It takes roughly the time of 1 transaction to run all 420 without any errors.