A Starknet transactions batcher
2025-05-07Development • By The Metacube Team

A Starknet transactions batcher

Abstract

This article presents the transactions batcher used in Metacube to send NFTs earned by players instantly. It explains the batcher's scalable actor-based architecture and provides a detailed implementation in Go. All the code snippets are available in the associated GitHub repository.

This post is based on the article originally published on dev.to by Bastien Faivre.

Architecture

The Batcher is composed of two main actors:

The Builder receives the transactions, batches them into a single multicall transaction, and sends it to the Sender actor.
The Sender finalizes the transaction with appropriate fields (nonce, max fee, etc.), signs it, sends it to the Starknet network, and monitors its status.

This actor separation allows for a scalable and efficient batcher. The builder prepares the transactions while the sender sends them, allowing for a continuous and efficient flow of transactions.

Starknet Batcher Architecture

Implementation

The following implementation is specific to Go, but the concepts can easily be adapted to other languages, as the functionalities remain the same. Moreover, note that this implementation is specific to sending NFTs from the same contract. However, a more generic approach is mentioned later in the article. Lastly, the code is based on the starknet.go library developed by Nethermind.

Let's start with the `Batcher` itself:

go
type Batcher struct {
    accnt           *account.Account
    contractAddress *felt.Felt
    maxSize         int
    inChan          <-chan []string
    failChan        chan<- []string
}

The Batcher runs both the `Builder` and the `Sender` actors concurrently:

go
type TxnDataPair struct {
    Txn  rpc.BroadcastInvokev1Txn
    Data [][]string
}

func (b *Batcher) Run() {
    txnDataPairChan := make(chan TxnDataPair)

    go b.runBuildActor(txnDataPairChan)
    go b.runSendActor(txnDataPairChan)
}

Builder

Let's analyze the Build actor. Note that the code is simplified for better readability (full code available on GitHub).

go
// This function builds a function call from the transaction data.
func (b *Batcher) buildFunctionCall(data []string) (*rpc.FunctionCall, error) {
    // Parse the recipient address
    toAddressInFelt, err := utils.HexToFelt(data[0])
    if err != nil {
        // ... error handling ...
    }

    // Parse the NFT ID
    nftID, err := strconv.Atoi(data[1])
    if err != nil {
        // ... error handling ...
    }

    // The entry point is a standard ERC721 function
    // https://docs.openzeppelin.com/contracts-cairo/0.20.0/erc721
    return &rpc.FunctionCall{
        ContractAddress: b.contractAddress,
        EntryPointSelector: utils.GetSelectorFromNameFelt(
            "safe_transfer_from",
        ),
        Calldata: []*felt.Felt{
            b.accnt.AccountAddress, // from
            toAddressInFelt, // to
            new(felt.Felt).SetUint64(uint64(nftID)), // NFT ID
            new(felt.Felt).SetUint64(0), // data -> None
            new(felt.Felt).SetUint64(0), // extra data -> None
        },
    }, nil
}

// This function builds the batch transaction from the function calls.
func (b *Batcher) buildBatchTransaction(functionCalls []rpc.FunctionCall) (rpc.BroadcastInvokev1Txn, error) {
    // Format the calldata (i.e., the function calls)
    calldata, err := b.accnt.FmtCalldata(functionCalls)
    if err != nil {
        // ... error handling ...
    }

    return rpc.BroadcastInvokev1Txn{
        InvokeTxnV1: rpc.InvokeTxnV1{
            MaxFee:        new(felt.Felt).SetUint64(MAX_FEE), // Define MAX_FEE appropriately
            Version:       rpc.TransactionV1,
            Nonce:         new(felt.Felt).SetUint64(0), // Will be set by the send actor
            Type:          rpc.TransactionType_Invoke,
            SenderAddress: b.accnt.AccountAddress,
            Calldata:      calldata,
        },
    }, nil
}

// Actual Build actor event loop
func (b *Batcher) runBuildActor(txnDataPairChan chan<- TxnDataPair) {
    size := 0
    functionCalls := make([]rpc.FunctionCall, 0, b.maxSize)
    currentData := make([][]string, 0, b.maxSize)
    var WAITING_TIME = 5 * time.Second // Example waiting time

    for {
        trigger := false
        select {
        case data, ok := <-b.inChan:
            if !ok {
                // Channel closed, handle shutdown
                return
            }
            functionCall, err := b.buildFunctionCall(data)
            if err != nil {
                // Handle error, maybe send to failChan
                continue
            }
            functionCalls = append(functionCalls, *functionCall)
            size++
            currentData = append(currentData, data)
            if size >= b.maxSize {
                trigger = true
            }
        case <-time.After(WAITING_TIME):
            if size > 0 {
                trigger = true
            }
        }

        if trigger {
            builtTxn, err := b.buildBatchTransaction(functionCalls)
            if err != nil {
                // Handle error, maybe send all in currentData to failChan
            } else {
                txnDataPairChan <- TxnDataPair{
                    Txn:  builtTxn,
                    Data: currentData,
                }
            }
            size = 0
            functionCalls = make([]rpc.FunctionCall, 0, b.maxSize)
            currentData = make([][]string, 0, b.maxSize)
        }
    }
}

Sender

Let's now analyze the Sender actor. Note that the code is simplified for better readability (full code available on GitHub).

go
// Actual Send actor event loop
func (b *Batcher) runSendActor(txnDataPairChan <-chan TxnDataPair) {
    oldNonce := new(felt.Felt).SetUint64(0)

    for {
        txnDataPair, ok := <-txnDataPairChan
        if !ok {
            // Channel closed, handle shutdown
            return
        }
        txn := txnDataPair.Txn
        data := txnDataPair.Data // To send to failChan if needed

        nonce, err := b.accnt.Nonce(
            context.Background(),
            rpc.BlockID{Tag: "latest"},
            b.accnt.AccountAddress,
        )
        if err != nil {
            // Handle error, send data to failChan
            continue
        }

        if nonce.Cmp(oldNonce) <= 0 {
            nonce.Add(oldNonce, new(felt.Felt).SetUint64(1))
        }

        txn.InvokeTxnV1.Nonce = nonce

        err = b.accnt.SignInvokeTransaction(
            context.Background(),
            &txn.InvokeTxnV1,
        )
        if err != nil {
            // Handle error, send data to failChan
            continue
        }

        // Optional: Estimate fee
        // fee, err := b.accnt.EstimateFee(...)
        // if err != nil { ... }
        // if fee > MAX_ACCEPTABLE_FEE { ... re-sign if MaxFee changed ... }

        resp, err := b.accnt.SendTransaction(
            context.Background(),
            &txn,
        )
        if err != nil {
            // Handle error, send data to failChan
            continue
        }

    statusLoop:
        for {
            time.Sleep(time.Second * 5) // Wait before checking status
            txStatus, err := b.accnt.GetTransactionStatus(
                context.Background(),
                resp.TransactionHash,
            )
            if err != nil {
                // Log error, maybe retry or send to failChan after X retries
                continue
            }

            switch txStatus.ExecutionStatus {
            case rpc.TxnExecutionStatusSUCCEEDED:
                oldNonce = nonce
                // Successfully sent, log or notify
                break statusLoop
            case rpc.TxnExecutionStatusREVERTED:
                oldNonce = nonce // Nonce is consumed even on revert
                // Send data to failChan
                break statusLoop
            default: // PENDING, etc.
            }

            switch txStatus.FinalityStatus {
            case rpc.TxnStatus_Received:
                continue
            case rpc.TxnStatus_Accepted_On_L2, rpc.TxnStatus_Accepted_On_L1:
                oldNonce = nonce
                // Finalized, log or notify
                break statusLoop
            case rpc.TxnStatus_Rejected:
                // Send data to failChan (nonce might not be consumed, check Starknet docs)
                break statusLoop
            default:
            }
        }
    }
}

Towards a generic batcher

The batcher presented is specific to sending NFTs from the same contract. However, the architecture can easily be adapted to send any type of transaction.

First, the transaction data sent to the Batcher must be more generic and, therefore, contain more information. They must contain the contract address, the entry point selector, and the call data. The `buildFunctionCall` function must then be adapted to parse this information.

One could also go one step further by making the sender account generic. This would require more refactoring, as the transactions must be batched per sender account. However, it is feasible and would allow for a more versatile batcher.

However, remember that premature optimization is the root of all evil. Therefore, if you just need to send NFTs or a specific token such as ETH or STRK, the batcher presented is more than enough.

CLI tool

The repository code can be used as a CLI tool to send a bunch of NFTs by batch. The tool is easy to use, and you should be able to adapt it to your needs after reading this article. Please refer to the README for more information.

Conclusion

I hope that this article helped you to better understand how Metacube sends NFTs to its players. The batcher is a key infrastructure component, and we are happy to share it with the community. If you have any questions or feedback, feel free to comment or reach out to me. Thank you for reading!

For more information, check out the original article on dev.to and the GitHub repository.