Skip to main content

Scaling Transactions from a Single Account

Flow is designed for consumer-scale internet applications and is one of the fastest blockchains globally. Transaction traffic on deployed contracts can be divided into two main categories:

  1. User Transactions

    These are transactions initiated by users, such as:

    • Buying or selling NFTs
    • Transferring tokens
    • Swapping tokens on decentralized exchanges (DEXs)
    • Staking or unstaking tokens

    In this category, each transaction originates from a unique account and is sent to the Flow network from a different machine. Developers don’t need to take special measures to scale for this category, beyond ensuring their logic is primarily on-chain and their supporting systems (e.g., frontend, backend) can handle scaling if they become bottlenecks. Flow’s protocol inherently manages scaling for user transactions.

  2. System Transactions

    These are transactions initiated by an app’s backend or various tools, such as:

    • Minting thousands of tokens from a single minter account
    • Creating transaction workers for custodians
    • Running 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, which can make scaling tricky. This guide focuses on strategies for scaling transactions from a single account.

In the following sections, we’ll explore how to execute concurrent transactions from a single account on Flow using multiple proposer keys.

info

This guide is specific to non-EVM transactions. For EVM-compatible transactions, you can use any EVM-compatible scaling strategy.

Problem

Blockchains use sequence numbers, also known as nonces, for each transaction to prevent replay attacks and allow users to specify the order of their transactions. The Flow network requires a specific sequence number for each incoming transaction and will reject any transaction where the sequence number does not exactly match the expected next value.

This behavior presents a challenge for scaling, as sending multiple transactions does not guarantee that they will be executed in the order they were sent. This is a fundamental aspect of Flow’s resistance to MEV (Maximal Extractable Value), as transaction ordering is randomized within each block.

If a transaction arrives out of order, the network will reject it and return an error message similar to the following:


_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 objective is to execute multiple concurrent transactions without encountering the sequence number error described above. While designing a solution, we must consider the following key factors:

  • Reliability

    Ideally, we want to avoid local sequence number management, as it is error-prone. In a local sequence number implementation, the sender must determine which error types increment the sequence number and which do not. For instance, network issues do not increment the sequence number, but application errors do. Furthermore, if the sender's sequence number becomes unsynchronized with the network, multiple transactions may fail.

    The most reliable approach to managing sequence numbers is to query the network for the latest sequence number before signing and sending each transaction.

  • Scalability

    Allowing multiple workers to manage the same sequence number can introduce coupling and synchronization challenges. To address this, we aim to decouple workers so that they can operate independently without interfering with one another.

  • Capacity Management

    To ensure reliability, the system must recognize when it has reached capacity. Additional transactions should be queued and executed once there is sufficient throughput. Fire-and-forget strategies are unreliable for handling arbitrary traffic, as they do not account for system capacity.

Solution

Flow's transaction model introduces a unique role called the proposer. Each Flow transaction is signed by three roles: authorizer, proposer, and payer. The proposer key determines the sequence number for the transaction, effectively decoupling sequence number management from the authorizer and enabling independent scaling. You can learn more about this concept here.

We can leverage this model to design an ideal system transaction architecture as follows:

  • Multiple Proposer Keys

    Flow accounts can have multiple keys. By assigning a unique proposer key to each worker, each worker can independently manage its own sequence number without interference from others.

  • Sequence Number Management

    Each worker ensures it uses the correct sequence number by fetching the latest sequence number from the network. Since workers operate with different proposer keys, there are no conflicts or synchronization issues.

  • Queue and Processing Workflow

    • Each worker picks a transaction request from the incoming requests queue, signs it with its assigned proposer key, and submits it to the network.
    • The worker remains occupied until the transaction is finalized by the network.
    • If all workers are busy, the incoming requests queue holds additional requests until there is enough capacity to process them.
  • Key Reuse for Optimization

    To simplify the system further, we can reuse the same cryptographic key multiple times within the same account by adding it as a new key. These additional keys can have a weight of 0 since they do not need to authorize transactions.

Here’s a visual example of how such an account configuration might look:

Example.Account

As shown, the account includes additional weightless keys designated for proposals, each with its own independent sequence number. This setup ensures that multiple workers can operate concurrently without conflicts or synchronization issues.

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 the Go SDK Example.

This example deploys a simple Counter contract:


_16
access(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 invoke the increase() function 420 times concurrently from a single account. By adding 420 concurrency keys and using 420 workers, all these transactions can be executed almost simultaneously.

Prerequisites

We're using Testnet to demonstrate real network conditions. To run this example, you need to create a new testnet account. Start by generating a key pair:


_10
flow keys generate

You can use the generated key with the faucet to create a testnet account. Update the corresponding variables in the main.go file:


_10
const PRIVATE_KEY = "123"
_10
const ACCOUNT_ADDRESS = "0x123"

Code Walkthrough

When the example starts, it will deploy the Counter contract to the account and add 420 proposer keys with the following transaction:


_20
transaction(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
}

Next, the main loop starts. Each worker will process a transaction request from the queue and execute it. Here’s the code for the main loop:


_36
// populate the job channel with the number of transactions to execute
_36
txChan := make(chan int, numTxs)
_36
for i := 0; i < numTxs; i++ {
_36
txChan <- i
_36
}
_36
_36
startTime := time.Now()
_36
_36
var wg sync.WaitGroup
_36
// start the workers
_36
for 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
_36
close(txChan)
_36
_36
// wait for all workers to finish
_36
wg.Wait()

The IncreaseCounter function calls the increase() function on the Counter contract:


_30
// Increase the counter by 1 by running a transaction using the given proposal key
_30
func 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
}

The above code is executed concurrently by each worker. Since each worker operates with a unique proposer key, there are no conflicts or synchronization issues. Each worker independently manages its sequence number, ensuring smooth execution of all transactions.

Finally, the RunTransaction function serves as a helper utility to send transactions to the network and wait for them to be finalized. It is important to note that the proposer key sequence number is set within the IncreaseCounter function before calling RunTransaction.


_26
// Run a transaction and wait for it to be sealed. Note that this function does not set the proposal key.
_26
func 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

Running the example will execute 420 transactions at the same time:


_10
→ cd ./examples
_10
→ go run ./transaction_scaling/main.go
_10
.
_10
.
_10
.
_10
Final 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.