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.

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:
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:
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).
// 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).
// 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.