From ba2f8fd897cedae10185e8e96042add75a6f13f6 Mon Sep 17 00:00:00 2001 From: John Hilliard Date: Thu, 20 Aug 2026 20:22:21 +0000 Subject: [PATCH 1/3] feat(loadtest): fast-forward account nonce on nonce-too-low errors When a send fails with a geth-style "nonce too low: next nonce X, tx nonce Y" error, parse the next nonce from the message and fast-forward the account's local nonce to it instead of grinding through stale nonces one failed transaction at a time. The nonce is never rewound, so stale error messages can't undo progress made by concurrent in-flight transactions, and reusable nonces below the new value are dropped. If the message doesn't match this exact format, behavior is unchanged. Co-Authored-By: Claude Fable 5 --- loadtest/account.go | 37 ++++++++++++ loadtest/account_test.go | 118 +++++++++++++++++++++++++++++++++++++++ loadtest/runner.go | 26 ++++++++- loadtest/runner_test.go | 62 ++++++++++++++++++++ 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 loadtest/account_test.go create mode 100644 loadtest/runner_test.go diff --git a/loadtest/account.go b/loadtest/account.go index 950909bd2..d097481e8 100644 --- a/loadtest/account.go +++ b/loadtest/account.go @@ -419,6 +419,43 @@ func (ap *AccountPool) AddReusableNonce(ctx context.Context, address common.Addr return nil } +// FastForwardNonce sets the nonce of the account with the given address to +// nextNonce when it is higher than the current value, and drops any reusable +// nonces below nextNonce since the network already considers them used. It +// never rewinds the nonce, so a stale error message can't undo progress made +// by concurrent in-flight transactions. It returns whether the nonce was +// updated. +func (ap *AccountPool) FastForwardNonce(ctx context.Context, address common.Address, nextNonce uint64) (bool, error) { + ap.mu.Lock() + defer ap.mu.Unlock() + + accountPos, found := ap.accountsPositions[address] + if !found { + return false, fmt.Errorf("account not found in pool: %s", address.Hex()) + } + + account := ap.accounts[accountPos] + + // reusableNonces is kept sorted ascending, so cut everything below nextNonce + firstValid, _ := slices.BinarySearch(account.reusableNonces, nextNonce) + if firstValid > 0 { + account.reusableNonces = account.reusableNonces[firstValid:] + } + + if nextNonce <= account.nonce { + return false, nil + } + + log.Debug(). + Stringer("address", address). + Uint64("oldNonce", account.nonce). + Uint64("newNonce", nextNonce). + Msg("Fast-forwarding account nonce") + + account.nonce = nextNonce + return true, nil +} + // RefreshNonce refreshes the nonce for the given address. func (ap *AccountPool) RefreshNonce(ctx context.Context, address common.Address) error { ap.mu.Lock() diff --git a/loadtest/account_test.go b/loadtest/account_test.go new file mode 100644 index 000000000..2ac2d030a --- /dev/null +++ b/loadtest/account_test.go @@ -0,0 +1,118 @@ +package loadtest + +import ( + "context" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func newTestAccountPool(accounts ...*Account) *AccountPool { + ap := &AccountPool{ + accounts: accounts, + accountsPositions: make(map[common.Address]int), + cfg: &AccountPoolConfig{}, + } + for i, acc := range accounts { + ap.accountsPositions[acc.address] = i + } + return ap +} + +func TestFastForwardNonce(t *testing.T) { + addr := common.HexToAddress("0x1") + + tests := []struct { + name string + nonce uint64 + reusableNonces []uint64 + nextNonce uint64 + wantUpdated bool + wantNonce uint64 + wantReusableNonces []uint64 + }{ + { + name: "fast forward when next nonce is higher", + nonce: 44, + nextNonce: 78, + wantUpdated: true, + wantNonce: 78, + }, + { + name: "no rewind when next nonce is lower", + nonce: 100, + nextNonce: 78, + wantUpdated: false, + wantNonce: 100, + }, + { + name: "no update when next nonce is equal", + nonce: 78, + nextNonce: 78, + wantUpdated: false, + wantNonce: 78, + }, + { + name: "drops reusable nonces below next nonce", + nonce: 44, + reusableNonces: []uint64{40, 42, 77, 78, 80}, + nextNonce: 78, + wantUpdated: true, + wantNonce: 78, + wantReusableNonces: []uint64{78, 80}, + }, + { + name: "keeps reusable nonces when not rewinding", + nonce: 100, + reusableNonces: []uint64{90, 95}, + nextNonce: 78, + wantUpdated: false, + wantNonce: 100, + wantReusableNonces: []uint64{90, 95}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reusable := make([]uint64, len(tt.reusableNonces)) + copy(reusable, tt.reusableNonces) + acc := &Account{ + address: addr, + nonce: tt.nonce, + reusableNonces: reusable, + } + ap := newTestAccountPool(acc) + + updated, err := ap.FastForwardNonce(context.Background(), addr, tt.nextNonce) + if err != nil { + t.Fatalf("FastForwardNonce returned error: %v", err) + } + if updated != tt.wantUpdated { + t.Errorf("updated = %v, want %v", updated, tt.wantUpdated) + } + if acc.nonce != tt.wantNonce { + t.Errorf("nonce = %d, want %d", acc.nonce, tt.wantNonce) + } + if len(acc.reusableNonces) != len(tt.wantReusableNonces) { + t.Fatalf("reusableNonces = %v, want %v", acc.reusableNonces, tt.wantReusableNonces) + } + for i, n := range tt.wantReusableNonces { + if acc.reusableNonces[i] != n { + t.Fatalf("reusableNonces = %v, want %v", acc.reusableNonces, tt.wantReusableNonces) + } + } + }) + } +} + +func TestFastForwardNonceUnknownAccount(t *testing.T) { + ap := newTestAccountPool() + + updated, err := ap.FastForwardNonce(context.Background(), common.HexToAddress("0x2"), 10) + if err == nil { + t.Fatal("expected error for unknown account, got nil") + } + if updated { + t.Error("updated = true, want false for unknown account") + } +} diff --git a/loadtest/runner.go b/loadtest/runner.go index 81731b8d6..7901bdc31 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "os/signal" + "regexp" "strconv" "strings" "sync" @@ -974,12 +975,35 @@ func (r *Runner) isCurrentBaseFeeGreaterThanMax(ctx context.Context, maxBaseFee return false, nil, nil } +// nonceTooLowRegexp matches geth-style nonce errors like +// "nonce too low: next nonce 78, tx nonce 44" and captures the next nonce +// expected by the network. +var nonceTooLowRegexp = regexp.MustCompile(`nonce too low: next nonce (\d+), tx nonce \d+`) + func (r *Runner) handleNonceReuse(ctx context.Context, tops *bind.TransactOpts, tErr error) { + errMsg := tErr.Error() + // Start with assumption that we can reuse the nonce - reuseNonce := !strings.Contains(tErr.Error(), "replacement transaction underpriced") && !strings.Contains(tErr.Error(), "transaction underpriced") && !strings.Contains(tErr.Error(), "nonce too low") && !strings.Contains(tErr.Error(), "already known") && !strings.Contains(tErr.Error(), "could not replace existing") + reuseNonce := !strings.Contains(errMsg, "replacement transaction underpriced") && !strings.Contains(errMsg, "transaction underpriced") && !strings.Contains(errMsg, "nonce too low") && !strings.Contains(errMsg, "already known") && !strings.Contains(errMsg, "could not replace existing") // If it is an error that consumes the nonce, we can't retry it + // If the node told us the nonce it expects next, fast-forward the account + // to it instead of grinding through nonces one failed tx at a time. If the + // message doesn't match, keep the default increment behavior. + if match := nonceTooLowRegexp.FindStringSubmatch(errMsg); match != nil { + nextNonce, parseErr := strconv.ParseUint(match[1], 10, 64) + if parseErr == nil { + if _, ffErr := r.accountPool.FastForwardNonce(ctx, tops.From, nextNonce); ffErr != nil { + log.Error(). + Str("address", tops.From.String()). + Uint64("nextNonce", nextNonce). + Err(ffErr). + Msg("Unable to fast-forward account nonce") + } + } + } + // If we can reuse the nonce, add it back to the account pool if reuseNonce { err := r.accountPool.AddReusableNonce(ctx, tops.From, tops.Nonce.Uint64()) diff --git a/loadtest/runner_test.go b/loadtest/runner_test.go new file mode 100644 index 000000000..7e82e56a1 --- /dev/null +++ b/loadtest/runner_test.go @@ -0,0 +1,62 @@ +package loadtest + +import ( + "strconv" + "testing" +) + +func TestNonceTooLowRegexp(t *testing.T) { + tests := []struct { + name string + errMsg string + wantMatch bool + wantNextNonce uint64 + }{ + { + name: "geth format", + errMsg: "nonce too low: next nonce 78, tx nonce 44", + wantMatch: true, + wantNextNonce: 78, + }, + { + name: "geth format wrapped with context", + errMsg: "failed to send transaction: nonce too low: next nonce 78, tx nonce 44", + wantMatch: true, + wantNextNonce: 78, + }, + { + name: "nonce too low without details", + errMsg: "nonce too low", + wantMatch: false, + }, + { + name: "unrelated error", + errMsg: "insufficient funds for gas * price + value", + wantMatch: false, + }, + { + name: "different client format", + errMsg: "nonce too low: address 0xabc, tx: 44 state: 78", + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := nonceTooLowRegexp.FindStringSubmatch(tt.errMsg) + if (match != nil) != tt.wantMatch { + t.Fatalf("match = %v, wantMatch = %v", match, tt.wantMatch) + } + if match == nil { + return + } + nextNonce, err := strconv.ParseUint(match[1], 10, 64) + if err != nil { + t.Fatalf("failed to parse next nonce: %v", err) + } + if nextNonce != tt.wantNextNonce { + t.Errorf("nextNonce = %d, want %d", nextNonce, tt.wantNextNonce) + } + }) + } +} From d18c641df3fa39448fb7be3192ca9f6314f49a0a Mon Sep 17 00:00:00 2001 From: John Hilliard Date: Thu, 20 Aug 2026 20:36:20 +0000 Subject: [PATCH 2/3] feat(loadtest): add --reverse-nonce-order to stress queued vs pending txpool dynamics Each sending account computes its planned nonce range from the test parameters (concurrency * requests / number of accounts) and sends transactions in descending nonce order, from the top of the range down to its current nonce. All txs except the last per account land in the queued pool, and the final tx at the base nonce promotes the whole chain to pending at once. Constraints enforced at startup: - total requests must divide evenly across sending accounts - requires --fire-and-forget (waiting for receipts of queued txs would deadlock) - incompatible with --wait-for-receipt, --eth-call-only, --duplicate-nonce-rate, and --adaptive-rate-limit (its pending-tx probe assumes ascending nonces) The nonce-too-low fast-forward is skipped in reverse mode since jumping the counter forward would corrupt the descending sequence. Failed nonces are re-sent as-is via the existing reusable-nonce queue without moving the descending counter. Co-Authored-By: Claude Fable 5 --- cmd/loadtest/cmd.go | 1 + doc/polycli_loadtest.md | 1 + doc/polycli_loadtest_uniswapv3.md | 1 + loadtest/account.go | 66 +++++++++++++++ loadtest/account_test.go | 133 ++++++++++++++++++++++++++++++ loadtest/config/config.go | 22 +++++ loadtest/config/config_test.go | 85 +++++++++++++++++++ loadtest/runner.go | 68 ++++++++++++++- loadtest/runner_test.go | 71 ++++++++++++++++ 9 files changed, 446 insertions(+), 2 deletions(-) diff --git a/cmd/loadtest/cmd.go b/cmd/loadtest/cmd.go index 721ad99ec..44a67bd9c 100644 --- a/cmd/loadtest/cmd.go +++ b/cmd/loadtest/cmd.go @@ -130,6 +130,7 @@ func initPersistentFlags() { pf.Var(&flag.GasValue{Val: &cfg.ForceGasPrice}, "gas-price", "gas price with unit support (e.g., \"100gwei\", \"1000000000\")") pf.Uint64Var(&cfg.StartNonce, "nonce", 0, "use this flag to manually set the starting nonce") pf.Float64Var(&cfg.DuplicateNonceRate, "duplicate-nonce-rate", 0, "ratio of duplicate-nonce txs to fresh txs (0 disables; 1 = 50% duplicates, 4 = 80%); requires --fire-and-forget") + pf.BoolVar(&cfg.ReverseNonceOrder, "reverse-nonce-order", false, "send each account's txs in descending nonce order, from highest planned nonce down to the current one, to stress queued vs pending txpool dynamics; total requests must divide evenly across accounts; requires --fire-and-forget") pf.Var(&flag.GasValue{Val: &cfg.ForcePriorityGasPrice}, "priority-gas-price", "gas tip for EIP-1559 with unit support (e.g., \"2gwei\")") pf.BoolVar(&cfg.ShouldProduceSummary, "summarize", false, "produce execution summary after load test (can take a long time for large tests)") pf.Uint64Var(&cfg.BatchSize, "batch-size", 999, "batch size for receipt fetching (default: 999)") diff --git a/doc/polycli_loadtest.md b/doc/polycli_loadtest.md index fda9531eb..24cdc53f3 100644 --- a/doc/polycli_loadtest.md +++ b/doc/polycli_loadtest.md @@ -193,6 +193,7 @@ The codebase has a contract that used for load testing. It's written in Solidity --receipt-retry-max uint maximum polling attempts for transaction receipt with --wait-for-receipt (default 30) --refund-remaining-funds refund remaining balance to funding account after completion -n, --requests int number of requests to perform for the benchmarking session (default of 1 leads to non-representative results) (default 1) + --reverse-nonce-order send each account's txs in descending nonce order, from highest planned nonce down to the current one, to stress queued vs pending txpool dynamics; total requests must divide evenly across accounts; requires --fire-and-forget --rpc-headers string custom HTTP headers for RPC requests (format: "key1:value1,key2:value2") -r, --rpc-url string the RPC endpoint URL (default "http://localhost:8545") --seed int a seed for generating random values and addresses (default 123456) diff --git a/doc/polycli_loadtest_uniswapv3.md b/doc/polycli_loadtest_uniswapv3.md index e43bf10ce..df8494124 100644 --- a/doc/polycli_loadtest_uniswapv3.md +++ b/doc/polycli_loadtest_uniswapv3.md @@ -116,6 +116,7 @@ The command also inherits flags from parent commands. --rate-limit float requests per second limit (use negative value to remove limit) (default 4) --rate-limit-ramp-duration duration linearly ramp rate limit from max(1% of --rate-limit, 1 TPS) to full --rate-limit over this duration (e.g. 3m; 0 disables ramp) -n, --requests int number of requests to perform for the benchmarking session (default of 1 leads to non-representative results) (default 1) + --reverse-nonce-order send each account's txs in descending nonce order, from highest planned nonce down to the current one, to stress queued vs pending txpool dynamics; total requests must divide evenly across accounts; requires --fire-and-forget --rpc-headers string custom HTTP headers for RPC requests (format: "key1:value1,key2:value2") -r, --rpc-url string the RPC endpoint URL (default "http://localhost:8545") --seed int a seed for generating random values and addresses (default 123456) diff --git a/loadtest/account.go b/loadtest/account.go index d097481e8..d165f53a6 100644 --- a/loadtest/account.go +++ b/loadtest/account.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "errors" "fmt" + "math" "math/big" "math/rand" "slices" @@ -41,6 +42,12 @@ type AccountPoolConfig struct { GasPriceMultiplier *big.Float ChainSupportBaseFee bool + // ReverseNonceOrder makes Next() hand out each account's nonces in + // descending order, from a precomputed highest nonce down to the account's + // starting nonce (see PrepareReverseNonces). Used to stress queued vs + // pending txpool dynamics. Requires fire-and-forget. + ReverseNonceOrder bool + // DuplicateNonceRate controls how often Next() returns the same nonce twice // in a row for the same account. The probability of duplication is // rate / (rate + 1): 0 = disabled, 1 = 50%, 4 = 80%. Used to induce nonce @@ -419,6 +426,48 @@ func (ap *AccountPool) AddReusableNonce(ctx context.Context, address common.Addr return nil } +// PrepareReverseNonces prepares all accounts for reverse nonce order sending. +// For each account, the current nonce becomes the floor (startNonce) and the +// nonce counter is moved to the top of the account's planned range +// (startNonce + txsPerAccount - 1). Next() then walks the range downward. +// Must be called after all account nonces have been fetched and after any +// setup transactions (contract deployments) have been accounted for, and +// before the first call to Next(). +func (ap *AccountPool) PrepareReverseNonces(txsPerAccount uint64) error { + if txsPerAccount == 0 { + return fmt.Errorf("txsPerAccount must be greater than zero") + } + + ap.mu.Lock() + defer ap.mu.Unlock() + + for _, account := range ap.accounts { + if !account.ready { + return fmt.Errorf("account %s nonce is not ready", account.address.Hex()) + } + if account.nonce > math.MaxUint64-(txsPerAccount-1) { + return fmt.Errorf("account %s nonce %d + %d txs per account overflows uint64", account.address.Hex(), account.nonce, txsPerAccount) + } + account.startNonce = account.nonce + account.nonce += txsPerAccount - 1 + + log.Debug(). + Stringer("address", account.address). + Uint64("floorNonce", account.startNonce). + Uint64("topNonce", account.nonce). + Msg("Prepared account for reverse nonce order") + } + + return nil +} + +// AccountCount returns the total number of accounts in the pool. +func (ap *AccountPool) AccountCount() int { + ap.mu.Lock() + defer ap.mu.Unlock() + return len(ap.accounts) +} + // FastForwardNonce sets the nonce of the account with the given address to // nextNonce when it is higher than the current value, and drops any reusable // nonces below nextNonce since the network already considers them used. It @@ -1205,6 +1254,23 @@ func (ap *AccountPool) Next(ctx context.Context) (Account, error) { accCopy := *account + if ap.cfg.ReverseNonceOrder { + // Failed nonces are re-sent as-is; they don't move the descending + // counter since their slot in the range was already consumed. + if len(account.reusableNonces) > 0 { + accCopy.nonce = account.reusableNonces[0] + account.reusableNonces = account.reusableNonces[1:] + } else if account.nonce > account.startNonce { + account.nonce-- + } else { + // The floor nonce is being handed out now; the account's range + // is exhausted, so stop it to guard against extra requests + // re-sending nonces below the floor (uint64 underflow). + account.stopped = true + } + return accCopy, nil + } + // Check if the account has a reusable nonce if len(account.reusableNonces) > 0 { account.nonce = account.reusableNonces[0] diff --git a/loadtest/account_test.go b/loadtest/account_test.go index 2ac2d030a..6b427f75d 100644 --- a/loadtest/account_test.go +++ b/loadtest/account_test.go @@ -2,6 +2,7 @@ package loadtest import ( "context" + "math" "testing" "github.com/ethereum/go-ethereum/common" @@ -105,6 +106,138 @@ func TestFastForwardNonce(t *testing.T) { } } +func TestPrepareReverseNonces(t *testing.T) { + addr1 := common.HexToAddress("0x1") + addr2 := common.HexToAddress("0x2") + + acc1 := &Account{address: addr1, nonce: 0, ready: true} + acc2 := &Account{address: addr2, nonce: 100, ready: true} + ap := newTestAccountPool(acc1, acc2) + + if err := ap.PrepareReverseNonces(5); err != nil { + t.Fatalf("PrepareReverseNonces returned error: %v", err) + } + + if acc1.startNonce != 0 || acc1.nonce != 4 { + t.Errorf("acc1 = {startNonce: %d, nonce: %d}, want {0, 4}", acc1.startNonce, acc1.nonce) + } + if acc2.startNonce != 100 || acc2.nonce != 104 { + t.Errorf("acc2 = {startNonce: %d, nonce: %d}, want {100, 104}", acc2.startNonce, acc2.nonce) + } +} + +func TestPrepareReverseNoncesErrors(t *testing.T) { + addr := common.HexToAddress("0x1") + + t.Run("zero txs per account", func(t *testing.T) { + ap := newTestAccountPool(&Account{address: addr, ready: true}) + if err := ap.PrepareReverseNonces(0); err == nil { + t.Error("expected error for zero txsPerAccount, got nil") + } + }) + + t.Run("account not ready", func(t *testing.T) { + ap := newTestAccountPool(&Account{address: addr, ready: false}) + if err := ap.PrepareReverseNonces(5); err == nil { + t.Error("expected error for not-ready account, got nil") + } + }) + + t.Run("nonce overflow", func(t *testing.T) { + ap := newTestAccountPool(&Account{address: addr, nonce: math.MaxUint64 - 1, ready: true}) + if err := ap.PrepareReverseNonces(5); err == nil { + t.Error("expected error for nonce overflow, got nil") + } + }) +} + +func TestNextReverseNonceOrder(t *testing.T) { + addr1 := common.HexToAddress("0x1") + addr2 := common.HexToAddress("0x2") + + acc1 := &Account{address: addr1, nonce: 0, ready: true} + acc2 := &Account{address: addr2, nonce: 100, ready: true} + ap := newTestAccountPool(acc1, acc2) + ap.cfg.ReverseNonceOrder = true + + const txsPerAccount = 3 + if err := ap.PrepareReverseNonces(txsPerAccount); err != nil { + t.Fatalf("PrepareReverseNonces returned error: %v", err) + } + + // Round-robin across 2 accounts, each walking its own range downward. + want := []struct { + addr common.Address + nonce uint64 + }{ + {addr1, 2}, {addr2, 102}, + {addr1, 1}, {addr2, 101}, + {addr1, 0}, {addr2, 100}, + } + + ctx := context.Background() + for i, w := range want { + acc, err := ap.Next(ctx) + if err != nil { + t.Fatalf("Next() call %d returned error: %v", i, err) + } + if acc.Address() != w.addr || acc.Nonce() != w.nonce { + t.Errorf("Next() call %d = {%s, %d}, want {%s, %d}", i, acc.Address(), acc.Nonce(), w.addr, w.nonce) + } + } + + // All ranges are exhausted; further calls must fail rather than underflow + // below the floor nonce. + if _, err := ap.Next(ctx); err == nil { + t.Error("expected error after all account ranges exhausted, got nil") + } +} + +func TestNextReverseNonceOrderReusableNonce(t *testing.T) { + addr := common.HexToAddress("0x1") + + acc := &Account{address: addr, nonce: 10, ready: true} + ap := newTestAccountPool(acc) + ap.cfg.ReverseNonceOrder = true + + if err := ap.PrepareReverseNonces(5); err != nil { + t.Fatalf("PrepareReverseNonces returned error: %v", err) + } + + ctx := context.Background() + + // First call hands the top of the range (14) and moves the counter down. + first, err := ap.Next(ctx) + if err != nil { + t.Fatalf("Next() returned error: %v", err) + } + if first.Nonce() != 14 { + t.Fatalf("first nonce = %d, want 14", first.Nonce()) + } + + // Simulate a failed send of nonce 14: it goes back as reusable and must be + // handed out next without moving the descending counter. + if err := ap.AddReusableNonce(ctx, addr, 14); err != nil { + t.Fatalf("AddReusableNonce returned error: %v", err) + } + + retry, err := ap.Next(ctx) + if err != nil { + t.Fatalf("Next() returned error: %v", err) + } + if retry.Nonce() != 14 { + t.Errorf("retry nonce = %d, want reused 14", retry.Nonce()) + } + + next, err := ap.Next(ctx) + if err != nil { + t.Fatalf("Next() returned error: %v", err) + } + if next.Nonce() != 13 { + t.Errorf("nonce after retry = %d, want 13", next.Nonce()) + } +} + func TestFastForwardNonceUnknownAccount(t *testing.T) { ap := newTestAccountPool() diff --git a/loadtest/config/config.go b/loadtest/config/config.go index 3d9ac8b97..7dc54416a 100644 --- a/loadtest/config/config.go +++ b/loadtest/config/config.go @@ -71,6 +71,7 @@ type Config struct { StartNonceSet bool `json:"-"` GasPriceMultiplier float64 DuplicateNonceRate float64 + ReverseNonceOrder bool // Gas options ForceGasLimit uint64 @@ -255,6 +256,27 @@ func (c *Config) Validate() error { return errors.New("--duplicate-nonce-rate requires --fire-and-forget (duplicate-nonce txs have no receipt to wait for)") } + if c.ReverseNonceOrder { + if !c.FireAndForget { + return errors.New("--reverse-nonce-order requires --fire-and-forget (queued txs can't be mined until the lowest nonce is sent, so waiting for receipts would deadlock)") + } + if c.WaitForReceipt { + return errors.New("--reverse-nonce-order is incompatible with --wait-for-receipt") + } + if c.EthCallOnly { + return errors.New("--reverse-nonce-order doesn't make sense with --eth-call-only (no transactions are sent)") + } + if c.DuplicateNonceRate > 0 { + return errors.New("--reverse-nonce-order is incompatible with --duplicate-nonce-rate") + } + if c.AdaptiveRateLimit { + return errors.New("--reverse-nonce-order is incompatible with --adaptive-rate-limit (pending tx tracking assumes ascending nonces)") + } + if c.Requests <= 0 || c.Concurrency <= 0 { + return errors.New("--reverse-nonce-order requires positive --requests and --concurrency") + } + } + return nil } diff --git a/loadtest/config/config_test.go b/loadtest/config/config_test.go index 7b7f5471d..d7451dcb3 100644 --- a/loadtest/config/config_test.go +++ b/loadtest/config/config_test.go @@ -144,3 +144,88 @@ func TestValidateSendRPCURL(t *testing.T) { }) } } + +func TestValidateReverseNonceOrder(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + wantErr string + }{ + { + name: "valid with fire-and-forget", + mutate: func(c *Config) { + c.FireAndForget = true + }, + }, + { + name: "requires fire-and-forget", + mutate: func(c *Config) {}, + wantErr: "--reverse-nonce-order requires --fire-and-forget", + }, + { + name: "incompatible with wait-for-receipt", + mutate: func(c *Config) { + c.FireAndForget = true + c.WaitForReceipt = true + c.ReceiptRetryMax = 5 + }, + wantErr: "--reverse-nonce-order is incompatible with --wait-for-receipt", + }, + { + name: "incompatible with eth-call-only", + mutate: func(c *Config) { + c.FireAndForget = true + c.EthCallOnly = true + }, + wantErr: "--reverse-nonce-order doesn't make sense with --eth-call-only", + }, + { + name: "incompatible with duplicate-nonce-rate", + mutate: func(c *Config) { + c.FireAndForget = true + c.DuplicateNonceRate = 1 + }, + wantErr: "--reverse-nonce-order is incompatible with --duplicate-nonce-rate", + }, + { + name: "incompatible with adaptive-rate-limit", + mutate: func(c *Config) { + c.FireAndForget = true + c.AdaptiveRateLimit = true + }, + wantErr: "--reverse-nonce-order is incompatible with --adaptive-rate-limit", + }, + { + name: "requires positive requests", + mutate: func(c *Config) { + c.FireAndForget = true + c.Requests = 0 + }, + wantErr: "--reverse-nonce-order requires positive --requests and --concurrency", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validConfig() + cfg.ReverseNonceOrder = true + cfg.Requests = 10 + cfg.Concurrency = 2 + tt.mutate(cfg) + + err := cfg.Validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("Validate() expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Validate() error %q does not contain %q", err.Error(), tt.wantErr) + } + }) + } +} diff --git a/loadtest/runner.go b/loadtest/runner.go index 7901bdc31..a51773e38 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "errors" "fmt" + "math" "math/big" "math/rand" "net/http" @@ -48,6 +49,11 @@ type Runner struct { startBlockNumber uint64 finalBlockNumber uint64 + // reverseTxsPerAccount is the size of each account's descending nonce + // range when --reverse-nonce-order is enabled. Computed and validated in + // initAccountPool, applied via PrepareReverseNonces in mainLoop. + reverseTxsPerAccount uint64 + // Mode execution modes []mode.Runner waitBaseFeeToDrop atomic.Bool @@ -300,6 +306,7 @@ func (r *Runner) initAccountPool(ctx context.Context) error { GasPriceMultiplier: r.cfg.BigGasPriceMultiplier, ChainSupportBaseFee: r.cfg.ChainSupportBaseFee, DuplicateNonceRate: r.cfg.DuplicateNonceRate, + ReverseNonceOrder: r.cfg.ReverseNonceOrder, Seed: r.cfg.Seed, } @@ -348,6 +355,14 @@ func (r *Runner) initAccountPool(ctx context.Context) error { return errors.New("unable to set account pool: " + err.Error()) } + // Validate reverse nonce order parameters before funding accounts or + // sending anything, so misconfigured runs are blocked right away. + if r.cfg.ReverseNonceOrder { + if err := r.computeReverseTxsPerAccount(); err != nil { + return err + } + } + // Dump private keys to file if configured if r.cfg.DumpSendingAccountsFile != "" { if err := r.dumpPrivateKeys(); err != nil { @@ -399,6 +414,43 @@ func (r *Runner) initAccountPool(ctx context.Context) error { return nil } +// computeReverseTxsPerAccount computes the size of each account's descending +// nonce range for --reverse-nonce-order and stores it in the runner. The total +// number of requests (concurrency * requests) must divide evenly across the +// accounts, otherwise the per-account nonce ranges wouldn't line up and some +// queued transactions could never become pending. +func (r *Runner) computeReverseTxsPerAccount() error { + concurrency := r.cfg.Concurrency + requests := r.cfg.Requests + + if requests > math.MaxInt64/concurrency { + return fmt.Errorf("--reverse-nonce-order: total requests overflow (concurrency %d * requests %d)", concurrency, requests) + } + totalRequests := concurrency * requests + + accountCount := int64(r.accountPool.AccountCount()) + if accountCount == 0 { + return errors.New("--reverse-nonce-order: no sending accounts in pool") + } + if totalRequests%accountCount != 0 { + return fmt.Errorf("--reverse-nonce-order: total requests (concurrency %d * requests %d = %d) must divide evenly across %d sending accounts", concurrency, requests, totalRequests, accountCount) + } + + r.reverseTxsPerAccount = uint64(totalRequests / accountCount) + + if r.cfg.TimeLimit > 0 { + log.Warn().Msg("--time-limit with --reverse-nonce-order: stopping early strands queued transactions that will never become pending") + } + + log.Info(). + Int64("totalRequests", totalRequests). + Int64("accounts", accountCount). + Uint64("txsPerAccount", r.reverseTxsPerAccount). + Msg("Reverse nonce order enabled") + + return nil +} + // Run executes the load test. func (r *Runner) Run(ctx context.Context) error { log.Info().Msg("Starting Load Test") @@ -599,6 +651,16 @@ func (r *Runner) mainLoop(ctx context.Context) error { return err } + // Move each account's nonce to the top of its planned range so the send + // loop walks nonces downward. Done as late as possible so setup + // transactions (contract deployments, nonce refresh) are reflected in the + // per-account floor nonces. + if cfg.ReverseNonceOrder { + if err = r.accountPool.PrepareReverseNonces(r.reverseTxsPerAccount); err != nil { + return fmt.Errorf("failed to prepare reverse nonce order: %w", err) + } + } + // Setup max base fee monitoring mustCheckMaxBaseFee, maxBaseFeeCtxCancel := r.setupBaseFeeMonitoring(ctx) defer maxBaseFeeCtxCancel() @@ -990,8 +1052,10 @@ func (r *Runner) handleNonceReuse(ctx context.Context, tops *bind.TransactOpts, // If the node told us the nonce it expects next, fast-forward the account // to it instead of grinding through nonces one failed tx at a time. If the - // message doesn't match, keep the default increment behavior. - if match := nonceTooLowRegexp.FindStringSubmatch(errMsg); match != nil { + // message doesn't match, keep the default increment behavior. Skipped in + // reverse nonce order mode, where jumping the counter forward would + // corrupt the descending sequence. + if match := nonceTooLowRegexp.FindStringSubmatch(errMsg); match != nil && !r.cfg.ReverseNonceOrder { nextNonce, parseErr := strconv.ParseUint(match[1], 10, 64) if parseErr == nil { if _, ffErr := r.accountPool.FastForwardNonce(ctx, tops.From, nextNonce); ffErr != nil { diff --git a/loadtest/runner_test.go b/loadtest/runner_test.go index 7e82e56a1..a9eb69c09 100644 --- a/loadtest/runner_test.go +++ b/loadtest/runner_test.go @@ -3,8 +3,79 @@ package loadtest import ( "strconv" "testing" + + "github.com/0xPolygon/polygon-cli/loadtest/config" + "github.com/ethereum/go-ethereum/common" ) +func TestComputeReverseTxsPerAccount(t *testing.T) { + newPool := func(n int) *AccountPool { + accounts := make([]*Account, n) + for i := range accounts { + accounts[i] = &Account{address: common.BytesToAddress([]byte{byte(i + 1), byte(i >> 8)}), ready: true} + } + return newTestAccountPool(accounts...) + } + + tests := []struct { + name string + concurrency int64 + requests int64 + accounts int + wantErr bool + wantTxsPerAccount uint64 + }{ + { + name: "even split", + concurrency: 250, + requests: 10, + accounts: 500, + wantTxsPerAccount: 5, + }, + { + name: "single account", + concurrency: 2, + requests: 10, + accounts: 1, + wantTxsPerAccount: 20, + }, + { + name: "uneven split", + concurrency: 3, + requests: 7, + accounts: 2, + wantErr: true, + }, + { + name: "more accounts than requests", + concurrency: 1, + requests: 250, + accounts: 500, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Runner{ + cfg: &config.Config{ + Concurrency: tt.concurrency, + Requests: tt.requests, + }, + accountPool: newPool(tt.accounts), + } + + err := r.computeReverseTxsPerAccount() + if (err != nil) != tt.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr) + } + if err == nil && r.reverseTxsPerAccount != tt.wantTxsPerAccount { + t.Errorf("reverseTxsPerAccount = %d, want %d", r.reverseTxsPerAccount, tt.wantTxsPerAccount) + } + }) + } +} + func TestNonceTooLowRegexp(t *testing.T) { tests := []struct { name string From dadedb7d9d7afc9f00bff3dadb2114993fedbb6a Mon Sep 17 00:00:00 2001 From: John Hilliard Date: Mon, 24 Aug 2026 13:17:47 +0000 Subject: [PATCH 3/3] fix(loadtest): resolve variable shadowing in account pool test Co-Authored-By: Claude Fable 5 --- loadtest/account_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loadtest/account_test.go b/loadtest/account_test.go index 6b427f75d..b05c68f6b 100644 --- a/loadtest/account_test.go +++ b/loadtest/account_test.go @@ -217,7 +217,7 @@ func TestNextReverseNonceOrderReusableNonce(t *testing.T) { // Simulate a failed send of nonce 14: it goes back as reusable and must be // handed out next without moving the descending counter. - if err := ap.AddReusableNonce(ctx, addr, 14); err != nil { + if err = ap.AddReusableNonce(ctx, addr, 14); err != nil { t.Fatalf("AddReusableNonce returned error: %v", err) }