From 0d62005a56ecf551e7025b2c54606ae139ce7de6 Mon Sep 17 00:00:00 2001 From: pk910 Date: Thu, 10 Sep 2026 12:50:54 +0200 Subject: [PATCH 1/8] Signal batch errors without blocking failed sub-goroutines SendMultiTransactionBatch drains a wallet's size-1 error channel at most once before cancelling the batch. Every further sub-goroutine that failed blocked forever on its send, leaking the goroutine and its semaphore slot. Signal best-effort instead; the error is still recorded per transaction. --- spamoor/submitter.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/spamoor/submitter.go b/spamoor/submitter.go index fc2aad3..093500e 100644 --- a/spamoor/submitter.go +++ b/spamoor/submitter.go @@ -392,7 +392,16 @@ func (p *TxPool) SendMultiTransactionBatch(ctx context.Context, walletTxs map[*W var finalErr error if lastErr != nil { finalErr = fmt.Errorf("failed to submit after %d attempts: %w", maxRetries, lastErr) - state.errorChan <- lastErr // Signal hard error + + // errorChan has capacity 1 and the wallet manager below drains it at + // most once before cancelling and returning. A blocking send would + // leave every further failing sub-goroutine stuck forever (holding + // its semaphore slot), so signal best-effort; the error is still + // recorded in errors[wallet][txIndex] below. + select { + case state.errorChan <- lastErr: + default: + } } resultsMutex.Lock() From 017a8081e2d70290e5fb3211a7a141de27dad9d0 Mon Sep 17 00:00:00 2001 From: pk910 Date: Thu, 10 Sep 2026 12:50:54 +0200 Subject: [PATCH 2/8] Advance the pending nonce counter with compare-and-swap on confirmation The confirmation path updated pendingTxCount with a load-then-store under txNonceMutex while GetNextNonce increments it under nonceMutex. A store computed from a stale load could roll the counter back and hand out an in-flight nonce twice. --- spamoor/txpool.go | 4 +--- spamoor/wallet.go | 19 +++++++++++++++++++ spamoor/wallet_nonce_test.go | 29 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 spamoor/wallet_nonce_test.go diff --git a/spamoor/txpool.go b/spamoor/txpool.go index b9b2690..7f04bb1 100644 --- a/spamoor/txpool.go +++ b/spamoor/txpool.go @@ -1147,9 +1147,7 @@ func (pool *TxPool) processTransactionInclusion(blockNumber uint64, wallet *Wall } wallet.confirmedTxCount = nonce + 1 - if nonce+1 > wallet.pendingTxCount.Load() { - wallet.pendingTxCount.Store(nonce + 1) - } + wallet.advancePendingTxCount(nonce + 1) if blockNumber > wallet.lastConfirmation { wallet.lastConfirmation = blockNumber } diff --git a/spamoor/wallet.go b/spamoor/wallet.go index e6ea7be..100e432 100644 --- a/spamoor/wallet.go +++ b/spamoor/wallet.go @@ -311,6 +311,25 @@ func (wallet *Wallet) SetNonce(nonce uint64) { wallet.confirmedTxCount = nonce } +// advancePendingTxCount raises pendingTxCount to at least target. It runs on +// the confirmation path under txNonceMutex while GetNextNonce advances the +// same counter under nonceMutex, so a plain load-then-store could overwrite a +// concurrent increment with a stale, lower value and hand out an in-flight +// nonce twice. The compare-and-swap loop never moves the counter backwards +// regardless of how the two paths interleave. +func (wallet *Wallet) advancePendingTxCount(target uint64) { + for { + current := wallet.pendingTxCount.Load() + if target <= current { + return + } + + if wallet.pendingTxCount.CompareAndSwap(current, target) { + return + } + } +} + // GetNextNonce atomically increments and returns the next available nonce. // This is used when building transactions to ensure unique nonces. // It first checks for any skipped nonces that can be reused. diff --git a/spamoor/wallet_nonce_test.go b/spamoor/wallet_nonce_test.go new file mode 100644 index 0000000..15a63f4 --- /dev/null +++ b/spamoor/wallet_nonce_test.go @@ -0,0 +1,29 @@ +package spamoor + +import "testing" + +func TestAdvancePendingTxCount(t *testing.T) { + tests := []struct { + name string + current uint64 + target uint64 + want uint64 + }{ + {name: "advances when target is ahead", current: 10, target: 13, want: 13}, + {name: "never moves backwards", current: 15, target: 13, want: 15}, + {name: "no-op when equal", current: 13, target: 13, want: 13}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &Wallet{} + w.pendingTxCount.Store(tt.current) + + w.advancePendingTxCount(tt.target) + + if got := w.pendingTxCount.Load(); got != tt.want { + t.Fatalf("pendingTxCount = %d, want %d", got, tt.want) + } + }) + } +} From 2f9ec82f30d21de0cb7866f2077ab046edd989b5 Mon Sep 17 00:00:00 2001 From: pk910 Date: Thu, 10 Sep 2026 12:50:54 +0200 Subject: [PATCH 3/8] Credit child wallets when funding without a batcher The non-batcher funding path never recorded its transfers in batchTxMap, so the shared credit loop skipped them and the child wallets' tracked balance stayed stale after a successful transfer. --- spamoor/walletpool.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spamoor/walletpool.go b/spamoor/walletpool.go index a049fb6..fdc1a85 100644 --- a/spamoor/walletpool.go +++ b/spamoor/walletpool.go @@ -1194,6 +1194,9 @@ func (pool *WalletPool) processFundingRequests(fundingReqs []*FundingRequest) er return err } txList = append(txList, tx) + // Record as a batch of one so the credit loop below (shared with the + // batcher path) credits the recipient once the transfer confirms. + batchTxMap[tx.Hash()] = []*FundingRequest{req} } } From 8285493c7bb4448c9b0e212d47b5d4bd3f0c4a60 Mon Sep 17 00:00:00 2001 From: pk910 Date: Thu, 10 Sep 2026 12:50:55 +0200 Subject: [PATCH 4/8] Release the spammer map lock while pausing a spammer on delete DeleteSpammer held the map write lock across Pause, which waits up to 10s for the scenario to wind down, freezing every reader meanwhile. Release the lock around Pause like DeleteGroup does and recheck the entry after reacquiring it. --- daemon/daemon.go | 11 ++++++- daemon/daemon_test.go | 73 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 daemon/daemon_test.go diff --git a/daemon/daemon.go b/daemon/daemon.go index 159b926..1d760f6 100644 --- a/daemon/daemon.go +++ b/daemon/daemon.go @@ -243,9 +243,18 @@ func (d *Daemon) DeleteSpammer(id int64, userEmail string) error { // Capture name for audit log spammerName := spammer.GetName() - // Stop if running + // Stop if running. Pause blocks for up to 10 seconds waiting for the scenario + // to wind down, so release the map lock around it (as the group branch above + // does) instead of freezing every GetSpammer/GetAllSpammers caller meanwhile. if spammer.scenarioCancel != nil { + d.spammerMapMtx.Unlock() spammer.Pause() + d.spammerMapMtx.Lock() + + // A concurrent delete of the same id may have completed while unlocked. + if d.spammerMap[id] == nil { + return nil + } } // Delete from DB diff --git a/daemon/daemon_test.go b/daemon/daemon_test.go new file mode 100644 index 0000000..730204c --- /dev/null +++ b/daemon/daemon_test.go @@ -0,0 +1,73 @@ +package daemon + +import ( + "context" + "testing" + "time" + + "github.com/ethpandaops/spamoor/daemon/db" +) + +// newBlockedPauseSpammer returns a running spammer whose Pause blocks until +// runningChan is closed, mimicking a scenario that takes a while to wind down. +func newBlockedPauseSpammer(d *Daemon, id int64, runningChan chan struct{}) *Spammer { + s := &Spammer{ + daemon: d, + dbEntity: &db.Spammer{ID: id, Scenario: "x", Name: "s"}, + logger: silentSpammerLogger(), + scenarioCancel: func() {}, + runningChan: runningChan, + } + s.running.Store(true) + + return s +} + +// Deleting a running spammer must not hold the spammer map lock across the +// blocking Pause call; other readers would otherwise freeze for up to 10 seconds. +// The entry is removed while Pause is blocked, so the delete finishes through the +// reacquire recheck without needing a database. +func TestDeleteSpammer_DoesNotBlockReadersWhilePausing(t *testing.T) { + dctx, dcancel := context.WithCancel(context.Background()) + defer dcancel() + d := &Daemon{ctx: dctx, cancel: dcancel, spammerMap: make(map[int64]*Spammer, 1)} + + runningChan := make(chan struct{}) + d.spammerMap[1] = newBlockedPauseSpammer(d, 1, runningChan) + + result := make(chan error, 1) + go func() { + result <- d.DeleteSpammer(1, "") + }() + + // Wait until the delete has entered Pause (it releases the lock before). + time.Sleep(50 * time.Millisecond) + + read := make(chan struct{}) + go func() { + _ = d.GetSpammer(1) + close(read) + }() + + select { + case <-read: + case <-time.After(2 * time.Second): + t.Fatal("GetSpammer was blocked while DeleteSpammer waited in Pause") + } + + // Simulate a concurrent delete of the same id finishing meanwhile. + d.spammerMapMtx.Lock() + delete(d.spammerMap, 1) + d.spammerMapMtx.Unlock() + + close(runningChan) + + select { + case err := <-result: + if err != nil { + t.Fatalf("expected DeleteSpammer to return cleanly for an already removed entry, got: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("DeleteSpammer did not return after Pause was released") + } +} From 83af6879faeaee8b6f749d5cf3d73a55139e0c60 Mon Sep 17 00:00:00 2001 From: pk910 Date: Thu, 10 Sep 2026 12:50:55 +0200 Subject: [PATCH 5/8] Call OnComplete when submission is skipped for a cancelled context OnComplete is documented as always being called, and ReclaimFunds relies on it to release its wait group. The early return for an already cancelled context skipped the callback, leaving the reclaim (and the spammer shutdown around it) hung forever. --- spamoor/txpool.go | 5 +++ spamoor/txpool_test.go | 81 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 spamoor/txpool_test.go diff --git a/spamoor/txpool.go b/spamoor/txpool.go index 7f04bb1..440202c 100644 --- a/spamoor/txpool.go +++ b/spamoor/txpool.go @@ -940,6 +940,11 @@ func (pool *TxPool) calculateAllWalletPoolStats(confirmedTxMap map[common.Hash]* // whether to immediately submit or just set up confirmation tracking. func (pool *TxPool) submitTransaction(ctx context.Context, wallet *Wallet, tx *txtypes.Transaction, options *SendTransactionOptions, submitNow bool) error { if ctx.Err() != nil { + // OnComplete is documented as always being called once processing ends. + // Callers such as WalletPool.ReclaimFunds release a WaitGroup from it and + // would hang forever if a pre-cancelled context skipped the callback. + options.invokeComplete(tx, nil, ctx.Err()) + return ctx.Err() } diff --git a/spamoor/txpool_test.go b/spamoor/txpool_test.go new file mode 100644 index 0000000..84155eb --- /dev/null +++ b/spamoor/txpool_test.go @@ -0,0 +1,81 @@ +package spamoor + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/ethpandaops/spamoor/txtypes" +) + +// OnComplete is documented as always being called once processing ends. The early +// return for an already-cancelled context used to skip it, which left callers that +// release a WaitGroup from the callback (WalletPool.ReclaimFunds) hanging forever. +func TestSendTransactionCallsOnCompleteWhenContextCancelled(t *testing.T) { + pool := &TxPool{} + wallet := &Wallet{} + tx := txtypes.NewTx(&txtypes.LegacyTx{Nonce: 0}) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done := make(chan error, 1) + err := pool.SendTransaction(ctx, wallet, tx, &SendTransactionOptions{ + OnComplete: func(_ *txtypes.Transaction, receipt *txtypes.Receipt, err error) { + if receipt != nil { + t.Errorf("expected no receipt for a cancelled submission") + } + done <- err + }, + }) + if err == nil { + t.Fatal("expected SendTransaction to return the context error") + } + + select { + case cbErr := <-done: + if cbErr == nil { + t.Fatal("expected OnComplete to receive the context error") + } + case <-time.After(2 * time.Second): + t.Fatal("OnComplete was not called for an already-cancelled context") + } +} + +// Mirrors the ReclaimFunds wiring: one send per wallet, wg.Done only from OnComplete. +func TestSendTransactionReleasesWaitGroupWhenContextCancelled(t *testing.T) { + pool := &TxPool{} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + const txCount = 20 + var wg sync.WaitGroup + wg.Add(txCount) + + for i := range txCount { + wallet := &Wallet{} + tx := txtypes.NewTx(&txtypes.LegacyTx{Nonce: uint64(i)}) + + go func() { + _ = pool.SendTransaction(ctx, wallet, tx, &SendTransactionOptions{ + OnComplete: func(_ *txtypes.Transaction, _ *txtypes.Receipt, _ error) { + wg.Done() + }, + }) + }() + } + + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("wait group was never released after the context was cancelled") + } +} From cff98b00140d3d940265f6eba01e703626cbe813 Mon Sep 17 00:00:00 2001 From: pk910 Date: Thu, 10 Sep 2026 12:50:55 +0200 Subject: [PATCH 6/8] Size erc20_bloater batches for Amsterdam state-creation gas Under EIP-8037 every fresh storage slot is charged 64 bytes of state creation gas on top of the SSTORE cost. A transaction at or below the EIP-7825 cap gets no state-gas reservoir, so that cost spills into regular gas and the fixed 370-address batch ran out of gas on every Amsterdam chain. Derive the batch size from the fee model's cost per state byte while keeping the gas limit at the EIP-7825 cap, so the transactions stay valid on chains that have not activated Amsterdam yet. The batch shrinks when a bloat tx still runs out of gas (the on-chain price can exceed the static estimate) and grows back after successful rounds. Nonces of transactions built for a round that is abandoned before sending are released. --- .../statebloat/erc20_bloater/erc20_bloater.go | 105 +++++++++++++++++- .../erc20_bloater/erc20_bloater_test.go | 95 ++++++++++++++++ .../statebloat/erc20_bloater/logger_test.go | 14 +++ 3 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 scenarios/statebloat/erc20_bloater/erc20_bloater_test.go create mode 100644 scenarios/statebloat/erc20_bloater/logger_test.go diff --git a/scenarios/statebloat/erc20_bloater/erc20_bloater.go b/scenarios/statebloat/erc20_bloater/erc20_bloater.go index 4f4e2f4..18439bc 100644 --- a/scenarios/statebloat/erc20_bloater/erc20_bloater.go +++ b/scenarios/statebloat/erc20_bloater/erc20_bloater.go @@ -33,7 +33,8 @@ const ( FixedGasLimitPerTx = 16700000 // Set slightly below max to ensure transaction success // MaxBloatedAddressesPerTx is the maximum number of addresses we can bloat in a single transaction - // while staying under the EIP-7825 gas limit. + // while staying under the EIP-7825 gas limit on a pre-Amsterdam chain. On Amsterdam chains the + // batch size is derived from the state-creation cost instead, see addressesPerBloatTx. // // Gas cost breakdown per address iteration in bloatStorage(): // - SSTORE to balanceOf[targetAddr]: @@ -52,6 +53,23 @@ const ( // 16,700,000 / 44,400 ≈ 376 addresses // We use 370 to leave a safety margin. MaxBloatedAddressesPerTx = 370 + + // PreAmsterdamGasPerAddress is the per-address regular (compute) gas from the breakdown + // above. Amsterdam (EIP-8037/8038) restructures SSTORE to 3,000 cold access + 10,000 + // first-write per fresh slot, so this figure stays a safe upper bound there as well. + PreAmsterdamGasPerAddress = 44400 + + // StateCreationBytesPerSlot is the EIP-8037 STORAGE_CREATION_SIZE: every fresh storage slot + // is charged key + value (64 bytes) of state-creation gas at the chain's cost per state byte. + StateCreationBytesPerSlot = 64 + + // BloatTxOverheadGas covers function dispatch, the nextStorageSlot write, the event and a + // safety margin per bloat transaction. + BloatTxOverheadGas = 300000 + + // batchGrowAfterRounds is the number of consecutive successful rounds after which a + // previously shrunk batch size is grown again by one step. + batchGrowAfterRounds = 10 ) type ScenarioOptions struct { @@ -72,6 +90,14 @@ type Scenario struct { contractAddr common.Address contractInstance *contract.ERC20Bloater + + // addressesPerTx is the current bloat batch size. It starts at the fee-model derived + // estimate (maxAddressesPerTx), shrinks whenever a bloat tx runs out of gas (the cost + // per state byte a chain actually charges can exceed the static estimate) and grows + // back slowly after successful rounds. + addressesPerTx uint64 + maxAddressesPerTx uint64 + roundsSinceShrink int } var ScenarioName = "erc20_bloater" @@ -248,6 +274,12 @@ func (s *Scenario) Run(ctx context.Context) error { s.logger.Infof("target: %.2f GB = %d addresses (%.2f million)", s.options.TargetStorageGB, targetAddresses, float64(targetAddresses)/1000000) + // Size the per-tx batch for the active fee model (GetCostPerStateByte is 0 on the + // legacy model, which yields the pre-Amsterdam constant). + s.maxAddressesPerTx = addressesPerBloatTx(s.walletPool.GetTxPool().GetCostPerStateByte()) + s.addressesPerTx = s.maxAddressesPerTx + s.logger.Infof("bloating up to %d addresses per tx (%d gas each)", s.addressesPerTx, FixedGasLimitPerTx) + // Start bloating with EIP-7825 compliant transaction splitting totalTxCount := uint64(0) errorCount := 0 @@ -303,8 +335,8 @@ func (s *Scenario) Run(ctx context.Context) error { wallet := s.walletPool.GetWallet(spamoor.SelectWalletByIndex, walletIndex) - // Use the maximum number of addresses that fit within EIP-7825 limit - numAddresses := uint64(MaxBloatedAddressesPerTx) + // Use the maximum number of addresses that fit within the fixed gas limit + numAddresses := s.addressesPerTx // Check if we would exceed our target addresses endAddressIndex := nextAddressIndex + numAddresses @@ -317,7 +349,7 @@ func (s *Scenario) Run(ctx context.Context) error { break // No more addresses to process } - s.logger.Debugf("batch %d/%d: processing %d addresses (max per tx) with %dM gas limit", + s.logger.Debugf("batch %d/%d: processing %d addresses with %dM gas limit", i+1, len(txSplits), numAddresses, FixedGasLimitPerTx/1000000) // Build bloating transaction with calculated number of addresses @@ -353,6 +385,12 @@ func (s *Scenario) Run(ctx context.Context) error { } if !roundSuccess { + // The txs built so far hold allocated nonces but will never be sent; + // release them so the wallets do not end up with a permanent nonce gap. + for _, batch := range txBatches { + batch.wallet.MarkSkippedNonce(batch.tx.Nonce()) + } + // Revert to beginning of round on failure nextAddressIndex = roundStartAddressIndex errorCount++ @@ -402,6 +440,9 @@ func (s *Scenario) Run(ctx context.Context) error { if receipt.Status != txtypes.ReceiptStatusSuccessful { s.logger.Errorf("tx failed: %s (gas used: %d, gas limit: %d)", batch.tx.Hash().Hex(), receipt.GasUsed, batch.tx.Gas()) + // bloatStorage has no revert path of its own, so a failed tx ran out + // of gas: the batch is too large for what the chain actually charges. + s.shrinkBatchSize() roundSuccess = false break } @@ -432,6 +473,7 @@ func (s *Scenario) Run(ctx context.Context) error { // Reset error count on successful round errorCount = 0 + s.growBatchSize() // Log progress after successful round // Note: each address = 2 storage slots = 64 bytes @@ -464,6 +506,61 @@ func (s *Scenario) calculateTransactionSplits(totalTargetGas uint64) []uint64 { return splits } +// addressesPerBloatTx returns how many fresh addresses a single bloat transaction can +// process within FixedGasLimitPerTx for the given EIP-8037 cost per state byte +// (0 on the legacy fee model). +// +// The gas limit deliberately stays at the EIP-7825 cap so the transaction is valid on +// chains that have not activated Amsterdam yet (those reject larger limits outright). +// Under Amsterdam a transaction at or below the cap gets no separate state-gas +// reservoir, so the state-creation gas of every fresh slot (64 bytes x cost per state +// byte) spills into regular gas and has to be accounted for in the batch size. +func addressesPerBloatTx(costPerStateByte uint64) uint64 { + if costPerStateByte == 0 { + return MaxBloatedAddressesPerTx + } + + stateGasPerAddress := uint64(SlotsPerBloatCycle) * StateCreationBytesPerSlot * costPerStateByte + gasPerAddress := PreAmsterdamGasPerAddress + stateGasPerAddress + + addresses := uint64(FixedGasLimitPerTx-BloatTxOverheadGas) / gasPerAddress + if addresses == 0 { + return 1 + } + + return min(addresses, MaxBloatedAddressesPerTx) +} + +// shrinkBatchSize reduces the per-tx address count after a bloat tx ran out of gas. The +// static estimate assumes the flat CostPerStateByte, but the price a chain actually +// charges can be higher, so adapt instead of retrying the same failing batch forever. +func (s *Scenario) shrinkBatchSize() { + if s.addressesPerTx <= 1 { + return + } + + s.addressesPerTx = max(1, s.addressesPerTx*3/4) + s.roundsSinceShrink = 0 + s.logger.Warnf("reducing batch size to %d addresses per tx", s.addressesPerTx) +} + +// growBatchSize slowly restores a shrunk batch size after a run of successful rounds so a +// transient cost spike does not throttle the scenario for the rest of the run. +func (s *Scenario) growBatchSize() { + if s.addressesPerTx >= s.maxAddressesPerTx { + return + } + + s.roundsSinceShrink++ + if s.roundsSinceShrink < batchGrowAfterRounds { + return + } + + s.roundsSinceShrink = 0 + s.addressesPerTx = min(s.maxAddressesPerTx, s.addressesPerTx+max(1, s.addressesPerTx/10)) + s.logger.Infof("increasing batch size to %d addresses per tx", s.addressesPerTx) +} + // distributeTokensToWallets distributes tokens from wallet 0 to other wallets for parallel execution func (s *Scenario) distributeTokensToWallets(ctx context.Context, numWallets int) error { if numWallets <= 1 { diff --git a/scenarios/statebloat/erc20_bloater/erc20_bloater_test.go b/scenarios/statebloat/erc20_bloater/erc20_bloater_test.go new file mode 100644 index 0000000..9c383d9 --- /dev/null +++ b/scenarios/statebloat/erc20_bloater/erc20_bloater_test.go @@ -0,0 +1,95 @@ +package erc20bloater + +import ( + "testing" + + "github.com/ethpandaops/spamoor/spamoor" +) + +func TestAddressesPerBloatTx(t *testing.T) { + tests := []struct { + name string + costPerStateByte uint64 + want uint64 + }{ + { + name: "legacy fee model keeps the pre-Amsterdam batch size", + costPerStateByte: 0, + want: MaxBloatedAddressesPerTx, + }, + { + // (16,700,000 - 300,000) / (44,400 + 2 * 64 * 1530) = 68 + name: "amsterdam flat cost per state byte", + costPerStateByte: spamoor.CostPerStateByte, + want: 68, + }, + { + name: "tiny cost per state byte stays close to the pre-Amsterdam batch size", + costPerStateByte: 1, + want: 368, + }, + { + name: "huge cost per state byte still makes progress", + costPerStateByte: 1 << 40, + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := addressesPerBloatTx(tt.costPerStateByte); got != tt.want { + t.Fatalf("addressesPerBloatTx(%d) = %d, want %d", tt.costPerStateByte, got, tt.want) + } + }) + } +} + +// The batch must always fit the fixed gas limit once the state-creation gas of every +// fresh slot is added to the compute estimate. +func TestAddressesPerBloatTxFitsGasLimit(t *testing.T) { + for _, cpsb := range []uint64{100, 1000, spamoor.CostPerStateByte, 5000, 20000} { + addresses := addressesPerBloatTx(cpsb) + stateGas := addresses * SlotsPerBloatCycle * StateCreationBytesPerSlot * cpsb + total := addresses*PreAmsterdamGasPerAddress + stateGas + BloatTxOverheadGas + + if total > FixedGasLimitPerTx { + t.Fatalf("cpsb %d: %d addresses need %d gas, exceeding the %d gas limit", + cpsb, addresses, total, FixedGasLimitPerTx) + } + } +} + +func TestBatchSizeShrinkAndGrow(t *testing.T) { + s := &Scenario{maxAddressesPerTx: 100, addressesPerTx: 100} + s.logger = newTestLogger() + + s.shrinkBatchSize() + if s.addressesPerTx != 75 { + t.Fatalf("expected batch size 75 after shrink, got %d", s.addressesPerTx) + } + + for range batchGrowAfterRounds - 1 { + s.growBatchSize() + } + if s.addressesPerTx != 75 { + t.Fatalf("batch size grew too early: %d", s.addressesPerTx) + } + + s.growBatchSize() + if s.addressesPerTx != 82 { + t.Fatalf("expected batch size 82 after growth step, got %d", s.addressesPerTx) + } + + for range 10 * batchGrowAfterRounds { + s.growBatchSize() + } + if s.addressesPerTx != 100 { + t.Fatalf("expected batch size to settle at the maximum 100, got %d", s.addressesPerTx) + } + + s.addressesPerTx = 1 + s.shrinkBatchSize() + if s.addressesPerTx != 1 { + t.Fatalf("batch size must never drop below 1, got %d", s.addressesPerTx) + } +} diff --git a/scenarios/statebloat/erc20_bloater/logger_test.go b/scenarios/statebloat/erc20_bloater/logger_test.go new file mode 100644 index 0000000..1362886 --- /dev/null +++ b/scenarios/statebloat/erc20_bloater/logger_test.go @@ -0,0 +1,14 @@ +package erc20bloater + +import ( + "io" + + "github.com/sirupsen/logrus" +) + +func newTestLogger() *logrus.Entry { + lg := logrus.New() + lg.SetOutput(io.Discard) + + return lg.WithField("test", ScenarioName) +} From 12cbc658fd0ec2ba8c7de3655fdcd074e819bb52 Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 11 Sep 2026 07:32:56 +0200 Subject: [PATCH 7/8] Use the compare-and-swap helper in SetNonce as well SetNonce raised pendingTxCount with a load-then-store under nonceMutex, which does not exclude the confirmation path's update under txNonceMutex. A confirmation landing between its load and store could be overwritten with the lower on-chain nonce, reopening the clobber the previous commit closed. The root wallet resyncs its nonce every 48s, so this is reachable in normal operation. --- spamoor/wallet.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/spamoor/wallet.go b/spamoor/wallet.go index 100e432..3d1bf6c 100644 --- a/spamoor/wallet.go +++ b/spamoor/wallet.go @@ -303,10 +303,9 @@ func (wallet *Wallet) SetNonce(nonce uint64) { wallet.nonceMutex.Lock() defer wallet.nonceMutex.Unlock() - pendingNonce := wallet.pendingTxCount.Load() - if nonce > pendingNonce { - wallet.pendingTxCount.Store(nonce) - } + // nonceMutex only serializes against GetNextNonce, not against the + // confirmation path, so raise the counter with the same CAS helper. + wallet.advancePendingTxCount(nonce) wallet.confirmedTxCount = nonce } From 7a56f71e153d15bd92cd206e90edd49974559c8d Mon Sep 17 00:00:00 2001 From: pk910 Date: Fri, 11 Sep 2026 07:40:21 +0200 Subject: [PATCH 8/8] Release erc20_bloater nonces of txs that failed at submission A hard error from SendMultiTransactionBatch left the nonces allocated by buildBloatTx permanently skipped when the tx never reached a node, so the next round built that wallet's tx at nonce+1 and could never get it included. A submission failure drops the tx from the wallet's pending tracking while a submitted-but-unconfirmed tx stays there, so use that to release only the nonces that are actually free. Also treat a nil receipt as a failed round instead of dereferencing it. --- scenarios/statebloat/erc20_bloater/erc20_bloater.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/scenarios/statebloat/erc20_bloater/erc20_bloater.go b/scenarios/statebloat/erc20_bloater/erc20_bloater.go index 18439bc..bc18452 100644 --- a/scenarios/statebloat/erc20_bloater/erc20_bloater.go +++ b/scenarios/statebloat/erc20_bloater/erc20_bloater.go @@ -425,12 +425,23 @@ func (s *Scenario) Run(ctx context.Context) error { }) if err != nil { s.logger.Errorf("failed to send transaction batch: %v", err) + + // Release the nonces of txs that never reached a node. A submission + // failure drops the tx from the wallet's pending tracking, whereas a tx + // that was submitted but not confirmed yet stays pending and keeps its + // nonce. Confirmed nonces are ignored by MarkSkippedNonce. + for _, batch := range txBatches { + if batch.wallet.GetPendingTx(batch.tx) == nil { + batch.wallet.MarkSkippedNonce(batch.tx.Nonce()) + } + } + roundSuccess = false } else { // Process receipts for i, batch := range txBatches { walletReceipts := receipts[batch.wallet] - if len(walletReceipts) == 0 { + if len(walletReceipts) == 0 || walletReceipts[0] == nil { s.logger.Errorf("no receipt for batch tx %d/%d", i+1, len(txBatches)) roundSuccess = false break