Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/loadtest/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
1 change: 1 addition & 0 deletions doc/polycli_loadtest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions doc/polycli_loadtest_uniswapv3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
103 changes: 103 additions & 0 deletions loadtest/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/ecdsa"
"errors"
"fmt"
"math"
"math/big"
"math/rand"
"slices"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]
Expand Down
Loading