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 950909bd2..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,85 @@ 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 +// 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() @@ -1168,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 new file mode 100644 index 000000000..b05c68f6b --- /dev/null +++ b/loadtest/account_test.go @@ -0,0 +1,251 @@ +package loadtest + +import ( + "context" + "math" + "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 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() + + 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/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 81731b8d6..a51773e38 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -5,12 +5,14 @@ import ( "crypto/ecdsa" "errors" "fmt" + "math" "math/big" "math/rand" "net/http" "net/url" "os" "os/signal" + "regexp" "strconv" "strings" "sync" @@ -47,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 @@ -299,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, } @@ -347,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 { @@ -398,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") @@ -598,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() @@ -974,12 +1037,37 @@ 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. 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 { + 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..a9eb69c09 --- /dev/null +++ b/loadtest/runner_test.go @@ -0,0 +1,133 @@ +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 + 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) + } + }) + } +}