Skip to content

fix(tempo): derive expiring-nonce uniqueness from valid_after instead of max_fee_per_gas - #219

Open
mattsse wants to merge 1 commit into
mainfrom
fix/expiring-nonce-uniqueness-outside-fees
Open

mattsse wants to merge 1 commit into
mainfrom
fix/expiring-nonce-uniqueness-outside-fees

Conversation

@mattsse

@mattsse mattsse commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Problem

Expiring-nonce Tempo transactions (TIP-1009, nonce_key == U256::MAX) have no sequential nonce, so
their replay protection is hash-based: two otherwise identical generated transactions must not
produce the same signed payload. txgen guaranteed that by adding a monotonic counter-derived bump to
max_fee_per_gas.

That bump leaks straight into the node's transaction-pool priority. The Tempo pool ranks by
effective tip per gas, the standard EIP-1559 min(max_priority_fee_per_gas, max_fee_per_gas - base_fee) (crates/transaction-pool/src/ordering.rs, EvictionOrderKey in
crates/transaction-pool/src/tt_2d_pool.rs; highest priority first, oldest first only on exact
ties). The bench presets set max_fee_per_gas == max_priority_fee_per_gas == 100 gwei and under
load the T7 base fee sits at its 12 gwei cap, so the effective tip was 88 gwei + bump and grew
strictly with generation order. The pool therefore selected newest-first.

In the multi-region bench run 35329155258 the oldest 20-30k of the 50k-entry pool starved until
their 25 s valid_for_secs expiry. That produced eviction waves every 26 s, ~81k expired
transactions (7% of included volume), and visibly smaller blocks right after each wave.

Fix

Route the same encoded_uniqueness value into valid_after instead of max_fee_per_gas. The
counter derivation is unchanged, only the destination field is different.

valid_after is safe for this:

  • It is part of the signed payload (RLP-encoded in TempoTransaction), so the transaction hash
    still changes.
  • It is invisible to pool ordering and to block packing. Fees/gas_price feed the effective tip,
    gas_limit drives how builders pack, and calldata length is validated strictly by some Tempo
    precompiles - none of those can carry the value.
  • The node only ever bounds valid_after from above: the pool validator checks
    valid_after <= now + aa_valid_after_max_secs
    (crates/transaction-pool/src/validator.rs::ensure_pool_time_bounds, backed by
    ensure_valid_after in crates/primitives/src/transaction/tempo_transaction.rs), pool-side EVM
    validation skips the check entirely (crates/evm/src/pool.rs), and execution only requires
    block_timestamp >= valid_after (validate_time_window in crates/revm/src/handler.rs). Small
    positive values such as 1 + encoded_uniqueness are always in the past and always satisfiable.
  • The field is Option<NonZeroU64>, hence values start at 1. The protocol also requires
    valid_before > valid_after; that invariant is now checked explicitly and produces a clear error
    instead of a transaction the node would reject.

Determinism is preserved: scenario-assigned identities still encode to odd values (2 * hint + 1)
and ordinary generation counters to even ones (2 * counter), so the two domains stay disjoint and
retrying a scenario step reproduces the same identity. Since valid_after = 1 + encoded_uniqueness,
scenario identities land on even valid_after values and generation counters on odd ones.

Templates with an explicit valid_after

The template value wins - a workload that pins valid_after is assumed to be exercising the time
window on purpose, and silently overwriting it would change the behaviour the spec asked for. Those
templates fall back to the previous max_fee_per_gas bump for uniqueness and print a one-time
warning on stderr explaining that the fallback makes the pool prefer newly generated transactions.
The trade-off is documented in the function's doc comment and in the README.

Deferred (--defer-signing) relative expiry is unaffected: the late signer clones the request and
only sets valid_before, so the uniqueness value carries through to the final signature.

Test plan

Commands run locally (macOS, rustc 1.99.0-nightly):

Command Result
cargo fmt --all / cargo fmt --all --check clean
cargo build --workspace ok
cargo clippy --workspace --all-targets -- -D warnings clean (matches .github/workflows/lint.yml)
cargo test -p txgen-tempo 66 passed, 0 failed, 1 ignored
cargo test --workspace --no-fail-fast 1 pre-existing failure, see below
./tests/decode-tx.sh txgen-tempo tests/specs/tempo-all-types.yaml 50/50 passed
./tests/decode-tx.sh txgen-ethereum tests/specs/ethereum-all-types.yaml 50/50 passed

Updated tests:

  • test_expiring_nonce_max_fee_bumps_leave_zero_priority_fee_unchanged ->
    test_expiring_nonce_uniqueness_leaves_fees_unchanged: both fee fields stay exactly as
    configured, valid_after walks 1, 3, 5, ....
  • test_sponsored_expiring_nonce_uniqueness_happens_before_fee_payer_signing: asserts identical
    fees and differing valid_after, and still asserts the fee-payer signature differs.
  • scenario_expiring_nonce.rs: the probe's "scenario identity" detector now keys off an even
    valid_after instead of an even max_fee_per_gas delta, and the concurrency test asserts
    identical fees with differing valid_after.

New tests:

  • test_expiring_nonce_uniqueness_does_not_change_pool_priority: two generated transactions from
    the same template have identical max_fee_per_gas, max_priority_fee_per_gas, gas_limit and
    calldata, differing valid_after, and differing signed payloads/hashes.
  • test_expiring_nonce_uniqueness_keeps_explicit_valid_after: a template with an explicit
    valid_after keeps it and falls back to the fee bump.
  • test_expiring_nonce_uniqueness_rejects_unusable_valid_before: a valid_before at or below the
    uniqueness value is rejected with a clear error.
  • test_expiring_nonce_scenario_identities_stay_disjoint_from_counters: generation counters land on
    odd values, scenario identities on even ones, and a retried scenario step reproduces its identity.

End-to-end spot check with cast decode-tx on tests/specs/tempo-all-types.yaml, showing constant
fees, unique valid_after and unique hashes:

maxFee=0x3b9aca00 prio=0x3b9aca00 validAfter=0x1 validBefore=0x2540be3ff hash=0x70f962eb1c
maxFee=0x3b9aca00 prio=0x3b9aca00 validAfter=0x3 validBefore=0x2540be3ff hash=0xae3600465a
maxFee=0x3b9aca00 prio=0x3b9aca00 validAfter=0x5 validBefore=0x2540be3ff hash=0x3ea9c9cf5a

Pre-existing failure

bench-cli call_replay::replays_a_mixed_corpus_identically_twice fails with
assertion left == right failed, left: 4, right: 5. It fails identically on a clean origin/main
worktree (verified at 41bddc3), so it is unrelated to this change. scenario_two_chain passes
locally here.

🤖 Generated with Claude Code

… of max_fee_per_gas

Expiring-nonce transactions (TIP-1009) need distinct signed payloads even
when two generated transactions are otherwise identical. txgen achieved
that by bumping `max_fee_per_gas` with a monotonic counter, which leaks
straight into the node's pool priority: the pool ranks by effective tip
per gas, so every newly generated transaction outranked the ones already
queued and the pool selected newest-first. Under load the oldest entries
starved until their `valid_for_secs` expiry, producing periodic eviction
waves.

Route the same `encoded_uniqueness` value into `valid_after` instead.
It is signed (so the hash still changes), but it is invisible to pool
ordering and to block packing, and the node only bounds it from above,
so small positive values are always satisfiable.

Templates that set `valid_after` explicitly keep their value and fall
back to the previous fee bump, with a one-time warning.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant