Un procesador por lotes de transacciones en Starknet
Resumen
Este artículo presenta el procesador por lotes de transacciones utilizado en Metacube para enviar NFTs ganados por los jugadores al instante. Explica la arquitectura escalable basada en actores del procesador y proporciona una implementación detallada en Go. Todos los fragmentos de código están disponibles en el repositorio de GitHub asociado.
Esta publicación se basa en el artículo publicado originalmente en dev.to por Bastien Faivre.
Arquitectura
El Batcher se compone de dos actores principales:
• El Builder recibe las transacciones, las agrupa en una sola transacción multicall y la envía al actor Sender.
• El Sender finaliza la transacción con campos apropiados (nonce, tarifa máxima, etc.), la firma, la envía a la red Starknet y monitorea su estado.
Esta separación de actores permite un procesador escalable y eficiente. El builder prepara las transacciones mientras el sender las envía, permitiendo un flujo continuo y eficiente de transacciones.

Implementación
La siguiente implementación es específica para Go, pero los conceptos pueden adaptarse fácilmente a otros lenguajes, ya que las funcionalidades siguen siendo las mismas. Además, ten en cuenta que esta implementación es específica para enviar NFTs del mismo contrato. Sin embargo, se menciona un enfoque más genérico más adelante en el artículo. Por último, el código se basa en la biblioteca starknet.go desarrollada por Nethermind.
Comencemos con el `Batcher` en sí:
type Batcher struct {
accnt *account.Account
contractAddress *felt.Felt
maxSize int
inChan <-chan []string
failChan chan<- []string
}El Batcher ejecuta tanto los actores `Builder` como `Sender` concurrentemente:
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
Analicemos el actor Build. Ten en cuenta que el código se simplifica para una mejor legibilidad (código completo disponible en 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
Analicemos ahora el actor Sender. Ten en cuenta que el código se simplifica para una mejor legibilidad (código completo disponible en 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:
}
}
}
}Hacia un procesador por lotes genérico
El procesador por lotes presentado es específico para enviar NFTs del mismo contrato. Sin embargo, la arquitectura puede adaptarse fácilmente para enviar cualquier tipo de transacción.
Primero, los datos de transacción enviados al Batcher deben ser más genéricos y, por lo tanto, contener más información. Deben contener la dirección del contrato, el selector del punto de entrada y los datos de llamada. La función `buildFunctionCall` debe entonces adaptarse para analizar esta información.
Uno podría ir un paso más allá haciendo que la cuenta del remitente sea genérica. Esto requeriría más refactorización, ya que las transacciones deben procesarse por lotes por cuenta de remitente. Sin embargo, es factible y permitiría un procesador por lotes más versátil.
Sin embargo, recuerda que la optimización prematura es la raíz de todo mal. Por lo tanto, si solo necesitas enviar NFTs o un token específico como ETH o STRK, el procesador por lotes presentado es más que suficiente.
Herramienta CLI
El código del repositorio se puede usar como una herramienta CLI para enviar un montón de NFTs por lotes. La herramienta es fácil de usar, y deberías poder adaptarla a tus necesidades después de leer este artículo. Por favor, consulta el README para más información.
Conclusión
Espero que este artículo te haya ayudado a entender mejor cómo Metacube envía NFTs a sus jugadores. El procesador por lotes es un componente clave de la infraestructura, y estamos felices de compartirlo con la comunidad. Si tienes alguna pregunta o comentario, no dudes en comentar o contactarme. ¡Gracias por leer!
Para más información, consulta el artículo original en dev.to y el repositorio de GitHub.