Ein Starknet-Transaktions-Batcher
Zusammenfassung
Dieser Artikel präsentiert den Transaktions-Batcher, der in Metacube verwendet wird, um NFTs, die von Spielern verdient wurden, sofort zu senden. Er erklärt die skalierbare, akteurbasierte Architektur des Batchers und bietet eine detaillierte Implementierung in Go. Alle Code-Snippets sind im zugehörigen GitHub-Repository verfügbar.
Dieser Beitrag basiert auf dem Artikel, der ursprünglich auf dev.to von Bastien Faivre veröffentlicht wurde.
Architektur
Der Batcher besteht aus zwei Hauptakteuren:
• Der Builder empfängt die Transaktionen, bündelt sie in eine einzige Multicall-Transaktion und sendet sie an den Sender-Akteur.
• Der Sender finalisiert die Transaktion mit geeigneten Feldern (Nonce, Max Fee, usw.), signiert sie, sendet sie an das Starknet-Netzwerk und überwacht ihren Status.
Diese Akteurtrennung ermöglicht einen skalierbaren und effizienten Batcher. Der Builder bereitet die Transaktionen vor, während der Sender sie sendet, was einen kontinuierlichen und effizienten Transaktionsfluss ermöglicht.

Implementierung
Die folgende Implementierung ist spezifisch für Go, aber die Konzepte können leicht auf andere Sprachen angepasst werden, da die Funktionalitäten gleich bleiben. Beachten Sie außerdem, dass diese Implementierung spezifisch für das Senden von NFTs aus demselben Vertrag ist. Ein generischerer Ansatz wird jedoch später im Artikel erwähnt. Schließlich basiert der Code auf der starknet.go-Bibliothek, die von Nethermind entwickelt wurde.
Beginnen wir mit dem `Batcher` selbst:
type Batcher struct {
accnt *account.Account
contractAddress *felt.Felt
maxSize int
inChan <-chan []string
failChan chan<- []string
}Der Batcher führt sowohl den `Builder`- als auch den `Sender`-Akteur gleichzeitig aus:
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
Analysieren wir den Build-Akteur. Beachten Sie, dass der Code zur besseren Lesbarkeit vereinfacht wurde (vollständiger Code verfügbar auf 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
Analysieren wir nun den Sender-Akteur. Beachten Sie, dass der Code zur besseren Lesbarkeit vereinfacht wurde (vollständiger Code verfügbar auf 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:
}
}
}
}Hin zu einem generischen Batcher
Der vorgestellte Batcher ist spezifisch für das Senden von NFTs aus demselben Vertrag. Die Architektur kann jedoch leicht angepasst werden, um jede Art von Transaktion zu senden.
Zuerst müssen die an den Batcher gesendeten Transaktionsdaten generischer sein und daher mehr Informationen enthalten. Sie müssen die Vertragsadresse, den Entry Point Selector und die Calldata enthalten. Die `buildFunctionCall`-Funktion muss dann angepasst werden, um diese Informationen zu parsen.
Man könnte auch einen Schritt weiter gehen, indem man das Sender-Konto generisch macht. Dies würde mehr Refactoring erfordern, da die Transaktionen pro Sender-Konto gebündelt werden müssen. Es ist jedoch machbar und würde einen vielseitigeren Batcher ermöglichen.
Denken Sie jedoch daran, dass vorzeitige Optimierung die Wurzel allen Übels ist. Wenn Sie also nur NFTs oder einen bestimmten Token wie ETH oder STRK senden müssen, ist der vorgestellte Batcher mehr als ausreichend.
CLI-Tool
Der Repository-Code kann als CLI-Tool verwendet werden, um eine Reihe von NFTs in Batches zu senden. Das Tool ist einfach zu verwenden, und Sie sollten in der Lage sein, es nach dem Lesen dieses Artikels an Ihre Bedürfnisse anzupassen. Weitere Informationen finden Sie in der README.
Fazit
Ich hoffe, dass dieser Artikel Ihnen geholfen hat, besser zu verstehen, wie Metacube NFTs an seine Spieler sendet. Der Batcher ist eine wichtige Infrastrukturkomponente, und wir freuen uns, ihn mit der Community zu teilen. Wenn Sie Fragen oder Feedback haben, können Sie gerne kommentieren oder mich kontaktieren. Vielen Dank fürs Lesen!
Für weitere Informationen schauen Sie sich den Originalartikel auf dev.to und das GitHub-Repository an.