Skip to content

Refactor & harden the nonce-aware tx mempool (stacked on #971)#974

Merged
vishalchangrani merged 31 commits into
vishal/nonce-aware-mini-poolfrom
vishal/tx-mempool-redesign
Jun 30, 2026
Merged

Refactor & harden the nonce-aware tx mempool (stacked on #971)#974
vishalchangrani merged 31 commits into
vishal/nonce-aware-mini-poolfrom
vishal/tx-mempool-redesign

Conversation

@vishalchangrani

@vishalchangrani vishalchangrani commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What

Refactors and hardens the nonce-aware transaction mempool introduced in #971, driven by a design review of services/requester/tx_mempool.go. This PR is stacked on #971 — its base is vishal/nonce-aware-mini-pool, so the diff is only the follow-up work on top of that pool. Draft for review.

No change to the pool's external behavior except one liveness fix (fast-path submit timeout); everything else is internal refactoring, observability, documentation, and tests.

Highlights

Structure / state machine

  • Replaced ad-hoc in-flight tracking with an explicit per-EOA nonceTracker state machine (classify / markSubmitting / markSubmitted / rollbackSubmitting) and a nonceWrapper ("unset = −∞") type that removes scattered nil/zero checks.
  • Decomposed the ~145-line collectDueBatches into focused helpers (collectQueue / collectPrefix / collectExpired / reportSize), surfacing the prefix-vs-TTL asymmetry.
  • Deferred heldTx allocation past the cheap rejections; cached the block view by indexed height; assorted Go-idiom cleanups (helpers, prealloc, %w wrapping).

Correctness / safety

  • Reject out-of-range nonces up front (ErrNonceTooLow / ErrNonceTooHigh, new --tx-max-nonce-gap).
  • Liveness fix: bound the fast-path submit with a 10s ceiling so a hung Access-node call can't pin the pool-wide lock for up to the 120s request timeout.

Observability — no silent drops

  • Every submission outcome is logged: WARN on drop (submit failure / stale prune / TTL submit-anyway) with full batch context, DEBUG on success. Invariant: any accepted tx id is either on-chain or in a WARN log — documented, including the one shutdown caveat (no graceful drain).

Documentation

  • A top-of-file behavior spec enumerating all 12 cases + cross-cutting invariants, each with a concrete nonce example, usable as a test checklist.

Tests

  • Dedicated nonceTracker unit tests; an injectable clock enabling timing tests through the real Add → tick → flush path (collection window, spacing gate, flush deadline, TTL, idle eviction); plus duplicate/in-flight/rejection coverage.

Validation

go build ./..., go vet, go test ./services/requester/ -race, and the Test_TxMemPool e2e all pass.

Notes for reviewers

  • Commits are organized one-per-change and are meant to be read in order (the 7-step redesign, then a comment audit, then audit-driven fixes).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a new setting to control how far ahead queued transactions can be from the current nonce.
    • Improved nonce handling so transactions are classified more consistently before submission.
  • Bug Fixes

    • Rejected transactions that are clearly too low or too high for the account’s nonce.
    • Prevented duplicate queued transactions from being accepted.
    • Improved cleanup after failed submissions and stale queue entries.
  • Tests

    • Expanded coverage for nonce limits, submission behavior, timing, and queue eviction.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1f988d4e-4853-45bf-9bbe-cfecaadd4f12

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch vishal/tx-mempool-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

vishalchangrani and others added 17 commits June 26, 2026 00:57
PR #971 review (m-Peter): expose the EVM block view so a caller reading many
EOAs' nonces at once (a mempool flush tick) can build the expensive view once
and reuse it, instead of rebuilding it per address.

- Add NonceView interface (GetNonce at a fixed state) and GetBlockView() to
  NonceProvider; LocalNonceProvider.GetNonce now builds the view via
  GetBlockView (DRY). NonceView is an interface so tests can fake it.
- Test fake returns itself as the NonceView.

The per-tick shared-view usage in collectDueBatches lands with the
collectDueBatches rewrite in the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR #971 call w/ zhangchiqing: remove the hard-to-reason-about hasInFlight bool
and track submission state as explicit nonce facts, so Add/collectDueBatches
call well-named methods instead of poking raw fields and writing compound
conditions.

Introduce nonceTracker (nested in eoaQueue), holding three facts:
- localIndexedNonce: cached on-chain frontier (refreshed from a fresh read)
- submitting: highest nonce sent but not yet ack'd
- lastConsecutivelySubmitted: highest nonce successfully, consecutively ack'd

The latter two are optionalNonce (value + set flag): nonce 0 is a valid nonce,
so a bare uint64 cannot represent "absent". optionalNonce owns the comparison
logic (atLeast/is/max, treating unset as -∞) so callers carry no set checks.

nonceTracker exposes classify(nonce) -> {nextExpected, inFlight, queue},
expectedNonce(), and the transitions markSubmitting/markSubmitted/
rollbackSubmitting/refreshIndexed. Add's reject+fast-path chain collapses onto
classify; the fragile `lastSubmittedNonce == batchMax` rollback guard is gone.

Explicit success update (per the call): on a successful detached submission
reconcileSubmission advances submitted under the lock, rather than waiting for
the index to confirm — so `submitting` strictly means "network call
outstanding" (documented on markSubmitted). On failure it clears submitting,
preventing the permanent-wedge.

collectDueBatches now builds the block view once per tick (via the new
GetBlockView) and reads every due EOA's nonce from it. Added dedicated
nonceTracker unit tests (classify table, expectedNonce, transitions). Unit +
e2e suites pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR #971 call w/ zhangchiqing: reject far-future and already-used nonces up
front in Add, so a client gets immediate feedback instead of the mempool
holding an unexecutable tx until TTL (which then burns Flow fees on a
guaranteed nonce-mismatch).

- New config TxMaxNonceGap (flag --tx-max-nonce-gap, default 500; 0 disables).
  Stored on nonceTracker as maxNonceGap, seeded from config at queue creation.
- classify now returns nonceTooLow (nonce < indexed frontier) and nonceTooHigh
  (nonce > indexed frontier + maxNonceGap), gated on maxNonceGap > 0. The
  in-flight check takes precedence. A behind/stale index can only make this
  over-strict, which is acceptable (the gateway is catching up).
- New errors ErrNonceTooLow / ErrNonceTooHigh.
- Add restructured around a single classify switch: reject duplicates (no
  read), then read the on-chain frontier unless the nonce is already one we've
  sent (in-flight retries stay read-free), then act on the verdict.

Bound is anchored on the local index (Leo: "maxGap + local index"), which is
anti-runaway (the index advances only on execution, not on our own sends) and
robust when the chain has moved past our sends. Default gap is off in tests, so
existing behavior is preserved; new unit tests cover the range verdicts and the
Add rejections. Unit + e2e suites pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add (via classify) read the EOA nonce with np.GetNonce per transaction, which
built a fresh EVM block view every time — so a burst of N txs paid N view
builds even though they were all at the same indexed height. Only
collectDueBatches reused a view (once per tick, via a local closure).

Cache the built view on LocalNonceProvider keyed by the indexed height: reuse
it while the height is unchanged, rebuild when a new block is indexed. Reuse is
correct because an EOA's on-chain nonce cannot change without a new block. This
makes Add bursts within one block build the view once, and lets
collectDueBatches drop its per-tick closure and just call GetNonce (the cache
now spans the whole pass, and across ticks at the same height).

The cached view is shared across reads; callers serialize via the pool's
queueMux (the only caller). Guarded by a mutex on the provider for safety.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per review preference: optionalNonce -> nonceWrapper and its constructor
knownNonce -> toNonceWrapper. Pure rename, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verify and document that a freshly created eoaQueue is safe to leave with
its non-nonce fields at their zero values. Traced every read:

- collectionWindowEndsAt / flushDeadline are read only once the queue is
  non-empty (collectDueBatches skips empty queues) and are set on the
  first enqueue before that, so their zero value is never observed;
- lastSubmittedAt may be read while still zero on the fast path, but
  spacingElapsed treats zero as "never submitted" => spacing satisfied;
- lastActivity is set unconditionally right after creation.

The prior comment claimed the timing fields were "only ever read after
being set," which is inaccurate for lastSubmittedAt. Tighten the comment
to state each field's actual safety argument. Comment-only; no behavior
change (the nonceWrapper work in step 2 already removed the ambiguous
zero-nonce case Leo flagged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Leo and Ardit flagged that on a Flow submit failure the batch's EVM
transactions were dropped with only a low-context Error log buried in
submitTxBatch. Establish Leo's invariant: for any tx id you can either
find it on-chain (sent) OR find a WARN log (dropped) — never nothing.

Decision (Vishal): keep drop-and-rely-on-client-resubmit; do NOT
re-enqueue failed txs for retry. This change is therefore observability
only, no behavior change to the submission state machine.

- Add logSubmission, called from both submission paths (the Add fast path
  and submitWork). On failure it WARNs with everything needed to debug a
  "lost transaction" report: eoa, tx hashes, nonce range, indexed
  frontier, batch size, flush reason, error. On success it emits a
  lighter DEBUG line (eoa + nonce range + reason).
- Enrich flushWork with reason (fast-path / consecutive-prefix /
  ttl-expiry) and the indexed frontier, set in collectDueBatches.
- Make submitTxBatch a pure submission primitive: keep the
  TransactionsDropped metric (reserved for Cadence build/send errors),
  drop its now-redundant logging — logSubmission at the call site has the
  richer context. Remove the unused logTxsDropped helper.
- Add local-indexed-nonce (and expected-nonce) to the existing stale-prune
  and TTL-expiry WARN logs for consistent debuggability.
- Remove the file-top "log discarded txs" TODO, now addressed.

Tests (TDD): a failed flush emits an observable WARN with the dropped tx
hashes and debug fields; a successful flush emits a DEBUG trace line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tep 6)

Per Leo (round-3 #1): build the heldTx only AFTER the cheap rejections
(duplicate + classify), so a transaction we are about to reject never
costs a heldTx allocation. Previously userTx was built before the
duplicate check, allocating even for duplicates and in-flight retries
(the common DFNS-resubmit case).

Split the verdict switch: the reject verdicts (in-flight / too-low /
too-high) now return up front, then userTx is built once, then the fast
path is a flat guard on nonceNextExpected. No behavior change — existing
unit + e2e tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the two file-top TODOs with a thorough behavior spec enumerating
every case the mempool handles, numbered to read as a test checklist:
fast path, burst batching, consecutive-prefix flush, submission spacing,
queue-on-gap, stale pruning, TTL submit-anyway, idle-queue retention, and
the four synchronous rejections (duplicate, in-flight, too-low,
too-high), plus the cross-cutting invariants (no silent drops, failure
handling, concurrency). Each applicable case carries a concrete nonce
example (e.g. queued 1,2,3,5,6,7 -> flush sends 1,2,3, holds 5,6,7).

Trim the TxMemPool struct doc to its unique content (the dual role of
TxSubmissionSpacing and the locking tradeoff) and point at the spec
instead of duplicating the prose. Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment-only pass over tx_mempool.go and its test for accuracy, concision
and first-reader clarity:

- Fix two stale/inaccurate comments: the TTL-expiry block referenced the
  removed hasInFlight/lastSubmittedNonce identifiers (now the nonceTracker
  submitting marker), and the far-ahead-nonce tradeoff still described
  rejection as an unresolved open question when TxMaxNonceGap/classify
  already enforce a too-high bound at Add time.
- Remove personal-name and insider references (Leo, zhangchiqing, PR #965
  discussion, Fix 1/2/3/4 test prefixes), restating the rationale plainly.
- Trim verbosity across the nonceTracker/classify/markSubmitted/
  reconcileSubmission docs while preserving every invariant and rationale.
- Call out load-bearing assumptions explicitly (queueMux discipline +
  single-goroutine access, nonce-ascending inputs, unset nonceWrapper as
  -inf).

No behavior change; build/vet/test/gofmt all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eal path

Audit finding H1: the pool called time.Now() directly, so the core
time-based behavior (collection-window reset, flush deadline, submission
spacing, TTL expiry, idle-queue retention) could only be tested by
hand-building deadline timestamps, never through the real Add->tick->flush
path.

Add an injectable now func() time.Time field (defaulting to time.Now,
mirroring the existing submitBatch field-injection pattern) and route the
three direct time.Now() calls through it. Add a controllable fakeClock and
five clock-driven tests exercising: window reset on each arrival, the
spacing gate deferring then releasing a flush, the flush deadline forcing
a flush while the window is still open, TTL submit-anyway, and idle-queue
eviction.

No behavior change; race-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…path

Two accuracy fixes from the audit:

- The Case 2 (burst batching) example in the behavior spec was misleading:
  it showed nonces 5,6,7 being collected together, but if 5 is the next
  expected nonce on an empty queue it fast-paths (Case 1) rather than
  queueing. Reword the example so the lead nonce is sent and the following
  nonces queue behind the submission-spacing gap, and note the fast-path
  caveat.
- Add Test_TxMemPool_DuplicateQueuedTxRejected. The existing
  InFlightDuplicateRejected test is named "duplicate" but actually
  exercises the in-flight rejection (the first tx is submitted and removed
  from the queue before the second Add), leaving the genuine
  duplicate-of-a-held-tx path (Case 9) untested. The new test holds a tx
  in the queue, asserts the identical tx is rejected with
  ErrDuplicateTransaction, and that a same-nonce different-hash tx replaces
  it (last write wins).

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit liveness finding: the fast path holds the pool-wide queueMux across
the entire Flow submission, and forwarded the caller (JSON-RPC request)
context straight into it. With the default rpc-request-timeout of 120s, a
hung Access-node call could pin the global lock for up to two minutes,
blocking every other EOA Add and the background flush loop.

Wrap the fast-path submit context with a 10s fastPathSubmitTimeout ceiling.
This is a liveness safety net, not a latency SLA: normal submits (~sub-
second, well under the ~1.2s spacing) return and release the lock at once;
only a genuinely stalled call is cut off, and its tx is dropped-and-logged
for the client to resubmit. The background submit path does not hold the
lock and is left unchanged.

Adds a test asserting the fast-path submit context carries a bounded
deadline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…, naming)

Low-risk readability cleanups from the audit, behavior-preserving:

- Extract deleteByNonce and txHashHexes helpers, replacing four near-
  identical loops across collectDueBatches, pruneStaleTxs and logSubmission.
- Preallocate slices with known caps: selectConsecutivePrefix (maxBatch),
  selectExpired (len txs), collectDueBatches work (len queues).
- Wrap pass-through errors with %w (classify index read, submitTxBatch
  build/send) for triage context; leave the errs.Err* sentinels bare so
  errors.Is still matches.
- Rename the nonceWrapper receiver o -> w (leftover from the old
  optionalNonce name) and the Add local userTx -> held.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit finding M1: collectDueBatches was ~145 lines spanning five
responsibilities (idle eviction, due/spacing gating, frontier refresh +
stale prune, consecutive-prefix collection, TTL-expiry collection, metrics).

Split it into:
  - collectDueBatches: lock + iterate + reportSize
  - collectQueue: the per-EOA gating + at-most-one-batch decision
  - collectPrefix: the consecutive-prefix path
  - collectExpired: the TTL submit-anyway path
  - reportSize: the memory-footprint metric

The prefix-vs-TTL asymmetry (prefix marks submitting + re-arms windows;
TTL does neither) is now visible as two sibling functions instead of being
implied by a continue 80 lines down. Pure behavior-preserving refactor;
unit + timing tests pass unchanged, race-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit finding M3: the three WARN-level per-batch logs (submission failure
in logSubmission, stale prune, TTL submit-anyway) had drifted to different,
overlapping field sets, so the no-silent-drops fields were inconsistent
and the invariant did not really live in one place.

Add a batchFields helper that attaches the common fields (eoa, tx-hashes,
low/high nonce, batch-size, local-indexed-nonce) to a caller-supplied
zerolog event, and route all three WARN sites through it. Each site then
only adds its specifics (reason+err / expected-nonce / none). The nonce
range is computed as the true min/max so it is correct even for the
stale-prune batch, which is collected in unordered map iteration. The
lighter success DEBUG line is intentionally left as-is.

No behavior change beyond the prune/TTL logs gaining the shared fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit concurrency finding (Low): on ctx cancellation processQueues
returns immediately, so transactions still held in a queue are discarded
without a WARN — the one gap in the no-silent-drops invariant, which holds
in steady state but not across shutdown/restart. Document this at the
ctx.Done() return and note the exception in the top-of-file invariant, so
the limitation is discoverable and a future drain has an obvious home.
Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vishalchangrani vishalchangrani force-pushed the vishal/tx-mempool-redesign branch from 624e26d to 8cbdfce Compare June 26, 2026 04:57
Clarify that the helper is a logging concern, not part of the core
mempool logic. Rename only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"github.com/onflow/flow-evm-gateway/storage/pebble"
)

// NonceView reads EOA nonces at a single, fixed EVM state (one built block

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are changes to incorporate @m-Peter comment in the earlier PR about reusing the block view to find the last nonce for an EOA. Caching the view for a given block height ensures we are not performing the expensive operation of reading the full block view each time when figuring out the latest on-chain nonce for an EOA.

Comment thread cmd/run/cmd.go
Cmd.Flags().DurationVar(&cfg.TxSubmissionSpacing, "tx-submission-spacing", 1200*time.Millisecond, "Minimum gap between consecutive Cadence submissions for the same EOA in the transaction mempool; also serves as the flush deadline for a continuously-fed collection window. Recommended ~1.5x the block production rate.")
Cmd.Flags().DurationVar(&cfg.TxPoolTTL, "tx-pool-ttl", 30*time.Second, "How long the transaction mempool holds an out-of-order transaction waiting for its nonce gap to fill, before submitting it anyway.")
Cmd.Flags().IntVar(&cfg.TxMaxBatchSize, "tx-max-batch-size", 5, "Maximum number of EVM transactions per EVM.batchRun Cadence transaction in the transaction mempool.")
Cmd.Flags().Uint64Var(&cfg.TxMaxNonceGap, "tx-max-nonce-gap", 500, "How far ahead of an EOA's on-chain nonce the transaction mempool accepts a nonce; nonces beyond indexedNonce+gap are rejected as nonce-too-high. 0 means no upper bound. A nonce below the indexed nonce is always rejected as nonce-too-low regardless of this setting.")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this change is to address @zhangchiqing suggestion that we should reject nonce that are ridiculously higher than the current nonce. e.g. 1M nonce in the future.

"github.com/onflow/flow-evm-gateway/services/requester/keystore"
)

// This file implements TxMemPool, a nonce-aware transaction mempool that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicitly mentioning the expected behaviour as a spec for both humans and AI. @zhangchiqing mentioned that we were missing this and I agree. Hopefully, this makes it clear to whoever works on this code later about what the code does and the different use cases.

// much slack, which is acceptable relative to the collection window.
const txMemPoolTickInterval = 50 * time.Millisecond

// fastPathSubmitTimeout bounds how long a single fast-path submission may take.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude pointed out that in the fastpath where we submit the transaction right away instead of queing it up, the context has no deadline which means that a slow or unresponsive Access node could delay all subsequent transactions. This changes adds timed context for the fast path with this timeout value.

// An unset nonceWrapper behaves as -∞ in the comparisons below (atLeast/is/max):
// below every real nonce and never "at or above" one, so callers need no set
// checks.
type nonceWrapper struct {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this also makes nonce comparision easy to read e.g. nonceWrapper.atLeast(5) instead of nonce != null && nonce >= 4

q, ok := t.queues[from]
if !ok {
q = &eoaQueue{txs: make(map[uint64]heldTx)}
// A fresh queue's other fields are intentionally left at their zero

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a note on why not all the fields of the eoa queue need to be initialized here


// Reject out-of-range / in-flight nonces up front, before allocating any
// held state: a transaction we are about to reject must not cost a heldTx.
switch verdict {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the nonce tracker helps us replace those .. && .. || conditions with switch and hopefully more readable statements

for {
select {
case <-ctx.Done():
// Shutdown is NOT a graceful drain: any transactions still held in a

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one thing claude flagged was that the shutdown was not gracious and inflight transactions will be lost. For now, there is just this comment about that issue.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
services/requester/tx_mempool_test.go (1)

250-256: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Tighten the fast-path timeout assertion.

The extra + time.Second slack can hide regressions above fastPathSubmitTimeout. Capture after := time.Now() after Add and assert the deadline is no later than after.Add(fastPathSubmitTimeout).

Proposed test tightening
 before := time.Now()
 require.NoError(t, pool.Add(context.Background(), signedTestTx(t, key, 0, 1)))
+after := time.Now()

 require.True(t, hasDeadline, "fast-path submit context must carry a deadline")
 assert.Greater(t, deadline, before)
-assert.LessOrEqual(t, deadline.Sub(before), fastPathSubmitTimeout+time.Second,
+assert.LessOrEqual(t, deadline, after.Add(fastPathSubmitTimeout),
 	"deadline must be bounded by fastPathSubmitTimeout")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@services/requester/tx_mempool_test.go` around lines 250 - 256, Tighten the
fast-path deadline check in the mempool test by removing the extra slack that
can mask regressions. In tx_mempool_test.go, update the assertion around
pool.Add and the deadline capture so it records an after := time.Now()
immediately after the Add call, then verify the context deadline is no later
than after.Add(fastPathSubmitTimeout). Keep the existing hasDeadline check and
the lower-bound assertion, but use the captured after time together with
fastPathSubmitTimeout to make the bound precise.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@services/requester/tx_mempool.go`:
- Around line 892-901: The submission spacing timestamp is being set too early
in collectDueBatches, which can let later EOA batches bypass
TxSubmissionSpacing. Move the lastSubmittedAt update out of collection-time
logic and stamp it in the post-submit path, preferably in reconcileSubmission or
equivalent submit-completion handling using t.now(), so the spacing reflects the
actual Flow submission attempt. Apply the same fix to the TTL batch path and any
related detached submission flow so all batch types share the same timing
source.
- Around line 332-336: The max nonce-gap validation in tx_mempool.go can
overflow because the check in the nonce handling path compares against
n.localIndexedNonce+n.maxNonceGap directly. Update the logic in the nonce
validation block that returns nonceTooHigh so it uses the already-established
nonce >= n.localIndexedNonce relationship and compares the distance between
nonce and n.localIndexedNonce instead of adding the two values, keeping the
behavior in sync with the surrounding nonce-gap handling in the mempool gate.

---

Nitpick comments:
In `@services/requester/tx_mempool_test.go`:
- Around line 250-256: Tighten the fast-path deadline check in the mempool test
by removing the extra slack that can mask regressions. In tx_mempool_test.go,
update the assertion around pool.Add and the deadline capture so it records an
after := time.Now() immediately after the Add call, then verify the context
deadline is no later than after.Add(fastPathSubmitTimeout). Keep the existing
hasDeadline check and the lower-bound assertion, but use the captured after time
together with fastPathSubmitTimeout to make the bound precise.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6ca7e1c4-4951-4de1-9c9c-c1f7b176bf74

📥 Commits

Reviewing files that changed from the base of the PR and between 8f50460 and 46cf137.

📒 Files selected for processing (6)
  • cmd/run/cmd.go
  • config/config.go
  • models/errors/errors.go
  • services/requester/nonce_provider.go
  • services/requester/tx_mempool.go
  • services/requester/tx_mempool_test.go

Comment thread services/requester/tx_mempool.go Outdated
Comment thread services/requester/tx_mempool.go
@vishalchangrani

vishalchangrani commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@m-Peter , @zhangchiqing , @janezpodhostnik -

So, I have a new version of code now. However, I changed a lot of things so to make it easier to review, the changes are a PR on my earlier PR.

This new PR that build on the old PR #971.

What changed -

  1. I have now some additional constructs to make the code more readable and easy to maintain -
  • nonceTracker - all the nonce comparision is abstracted out to this struct. Add method simply calls nonce tracker to decide what to do with the incoming transaction.
  • nonceWrapper - a simple wrapper for a nonce that help distinguish between uninitialized nonce and initialized but zero value nonce. Plus it has simple comparision methods for nonce.
  • Split collectDueBatches into four separate functions.
  • removed the isInFlight boolean flag and introduced a caching mechanism for the blockview for a given height.
  1. Behaviour spec - a spec defintion at the top which calls out what the code does.

  2. Claude suggested some additional changes

  • Use an injectable now function to add more unit test for time based functions.

When you get a chance can you please review this PR ?

thanks a lot

vishalchangrani and others added 2 commits June 26, 2026 11:39
Address CodeRabbit review on PR #974:

- classify: compare the nonce distance (nonce - localIndexedNonce) against
  maxNonceGap instead of localIndexedNonce + maxNonceGap, which could
  overflow near math.MaxUint64. The preceding too-low check guarantees
  nonce >= localIndexedNonce, so the subtraction never underflows. Boundary
  semantics unchanged (verified by Test_NonceTracker_Classify).
- Tighten the fast-path timeout test: capture after := time.Now() and
  assert the deadline is <= after.Add(fastPathSubmitTimeout), removing the
  +1s slack that could mask a regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CodeRabbit PR #974 flagged that lastSubmittedAt is stamped at collection
time, not actual submit completion, so a later EOA in a tick can have
slightly short effective spacing. Document the bounded skew on
spacingElapsed and why the collection-time stamp is load-bearing (it gates
a follow-up batch from being collected while one is in flight). Accepted
for the current few-EOA deployment; the precise fix (re-stamp on submit
completion in reconcileSubmission) is noted for if higher EOA fan-out
makes the skew material. Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vishalchangrani

Copy link
Copy Markdown
Contributor Author

@coderabbitai re the tx_mempool_test.go nitpick (tighten the fast-path timeout assertion): fixed in ec791ad5. Now captures after := time.Now() immediately after Add and asserts deadline <= after.Add(fastPathSubmitTimeout), dropping the +time.Second slack that could have masked a regression.

Summary of this review pass:

  • Overflow in nonce-gap check (tx_mempool.go) — fixed, ec791ad5 (distance comparison).
  • Fast-path timeout test slack — fixed, ec791ad5.
  • Spacing stamped at collection time — concern valid; CodeRabbit's literal fix would regress (the collection-time stamp gates against collecting a follow-up batch while one is in flight). Documented as a bounded, accepted skew for the current few-EOA deployment (f0011415); see the thread reply for the precise fix if fan-out grows.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@vishalchangrani Thanks — that addresses the tx_mempool_test.go timeout nit, and the tighter after := time.Now() bound is exactly what I was looking for.

Also appreciate the write-up on the spacing stamp behavior. The rationale makes sense: moving the stamp to submission time would interfere with collecting a follow-up batch while one is already in flight, so documenting the current collection-time stamp as a bounded/intentional skew is a reasonable choice for the current deployment.

(•ᴗ•)🐇


✏️ Learnings added
Learnt from: vishalchangrani
URL: https://github.com/onflow/flow-evm-gateway/pull/974

Timestamp: 2026-06-26T16:11:10.786Z
Learning: In `services/requester/tx_mempool.go`, the spacing timestamp being recorded at collection time is an intentional tradeoff, not an oversight: moving it to submission time would gate collection of a follow-up batch while another batch is in flight. For the current few-EOA deployment this skew is accepted and documented, with a different fix reserved for future higher fan-out scenarios.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

@vishalchangrani vishalchangrani self-assigned this Jun 26, 2026
) (nonceVerdict, error) {
// At or below the highest nonce we've already sent: in flight or submitted.
if n.highestSent().atLeast(nonce) {
return nonceInFlight, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wonder if it's useful to include the highestSent nonce in the error message for debugging purpose, or would it become a risk that is leaking the internal state data?

@vishalchangrani vishalchangrani Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lemme investigate. see what other chains do

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Short answer: the account nonce is public (eth_getTransactionCount), so it isn't a state-leak — and geth/Erigon/Reth all include both the tx nonce and the next/expected nonce directly in the client-facing error. So this is worth doing, but it's broader than just the in-flight case (it applies to ErrNonceTooLow / ErrNonceTooHigh too, which are also bare sentinels today).

Rather than scope it into this PR, I've opened #975 to improve nonce-rejection error reporting across the board (include tx nonce + next/pending nonce in the error and logs, following the geth/Erigon/Reth precedent). Let's handle this comment there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you actually pointed out to a broader issue. Other clients return the tx nonce and the on-chain nonce as part of the error. I am creating a new issue to improve error reporting and will handle that as a separate PR.

here is what other clients do:

Every major execution client includes the nonce values in the client-facing error. Verified from source:

  • geth — nonce too low: next nonce 6, tx nonce 5 (core/txpool/validation.go); state layer adds address … tx: … state: …. The %w-wrapped string reaches the RPC client.
  • Erigon — same format (nonce too low: address 0x… tx: 150 state: 161).
  • Reth — transaction nonce is not consistent: next nonce 6, tx nonce 5.
  • Nethermind — the lone outlier: bare "nonce too low", no numbers, in both the error and the log.

Comment thread services/requester/tx_mempool.go Outdated
Comment thread services/requester/tx_mempool.go Outdated
n.refreshIndexed(indexNonce)

// Below the on-chain frontier: already used, can never execute.
if nonce < n.localIndexedNonce {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if nonce < n.localIndexedNonce {
if nonce <= n.localIndexedNonce {

I think nonce has to be strictly above local indexed nonce, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localIndexedNonce is the account's next expected nonce (what GetNonce returns), not the last-used one so nonce == localIndexedNonce is the valid next nonce and must be accepted (nonceNextExpected).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can rename localIndexedNonce to nextIndexedNonce for clarity if you think localIndexedNonce is not clear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread services/requester/tx_mempool.go Outdated
// Compare the distance rather than localIndexedNonce+maxNonceGap, which could
// overflow near math.MaxUint64; the too-low check above guarantees
// nonce >= localIndexedNonce, so the subtraction never underflows.
if n.maxNonceGap > 0 && nonce-n.localIndexedNonce > n.maxNonceGap {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does maxNonceGap ever change? Can we check maxNonceGap in the constructor of n or even the mempool?

Suggested change
if n.maxNonceGap > 0 && nonce-n.localIndexedNonce > n.maxNonceGap {
if nonce-n.localIndexedNonce > n.maxNonceGap {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

n.maxNonceGap never changes. lemme see if it can be moved out from here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok found a cleaner way to not have n.maxNonceGap > 0 in this condition all together.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this commit sets maxNonceGap to max value is user specified a 0 for it. That way we avoid checking for n.maxNonceGap > 0 all together.

q.lastSubmittedAt = t.now()
return nil
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to be specific that the only case that could reach here is nonce Queued or nonceNextExpected.

if ! (verdict == nonceQueued || verdict == nonceNextExpected) {
  return fmt.Errorf("unknown verdict: %v", verdict)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is an if verdict == nonceNextExpected && condition for the if block this statement is part of which ensures this will never be executed if verdict was anything other than nonceNextExpected. However, I am refactoring the code to make easy to read by introducing a switch statement for each of the verdicts. For unhandled verdict, I decided to just panic 🤷

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread services/requester/tx_mempool.go Outdated
cancel()
t.logSubmission(from, batch, flushReasonFastPath, q.nonces.localIndexedNonce, submitErr)
if submitErr != nil {
return submitErr

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we don't update lastSubmittedAt when submit failed.

IMO, I think we could update regardless submission succeed or not, so that we won't keep sending the tx over and over if it keeps failing, a failed submission would still have to wait for spacingElapsed for next attempt.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A failed fast-path submit is usually a Flow-side issue (e.g. Access node), not the tx's fault, and the error is returned to the client synchronously. Stamping lastSubmittedAt on failure would make the EOA's next (possibly valid) tx wait out the spacing for a failure it didn't cause. Retries are the client's call here (we don't retry internally), so I'd rather not rate-limit the EOA on a failed attempt.

(remember this is the fast path where transactions is sent out right away)

Comment on lines +805 to +809
if submitErr != nil {
q.nonces.rollbackSubmitting(highNonce)
return
}
q.nonces.markSubmitted(highNonce)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ the mutex was released and locked again, so we need to check the existing state variable before setting these state variables. Otherwise, it's overwriting updated state value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added calls to re-read before attempting to set submitting

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread services/requester/tx_mempool.go Outdated
Comment on lines +639 to +640
// reconcileSubmission). TTL-expiry batches are not marked in flight and must
// never touch the tracker.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we have localIndexedNonce, can inFlight be derived from localIndexedNonce + 1 == txs[0].nonce?

For TTL-expiry batch, inFlight will be false, and for consecutive batch, it will be true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but localIndexNonce can be well behind the current highestSent + 1 or might have advanced by Add separately.
I have renamed this inFlight field in flusWork to needsReconcile since thats what it actually is for - to tell reconcileSubmission() whether or not to reconcile or ignore.

Co-authored-by: Leo Zhang <zhangchiqing@gmail.com>
vishalchangrani and others added 3 commits June 27, 2026 00:07
The doc said "current nonce", which read ambiguously (last-used vs next-to-
use) in PR #974 review. Reword to "account nonce ... the next nonce the EOA
should use (matches eth_getTransactionCount)" so the semantics behind the
too-low check (nonce < frontier) are unmistakable. Comment-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…0 guard

Per @zhangchiqing (#974): maxNonceGap is set once, so re-checking
"maxNonceGap > 0" on every classify call is noise. Normalize the config
value at the mempool boundary (queue creation) via normalizeNonceGap: the
config "0 = no upper bound" becomes math.MaxUint64, and classify uses a
single distance comparison "nonce - localIndexedNonce > maxNonceGap"
(still overflow-safe; distance is at most MaxUint64 so the unbounded case
never trips).

Normalization lives at the consumer, not in parseConfigFromFlags, so the
config field keeps its honest "0 = unbounded" contract regardless of how a
Config is built. The nonceTracker now stores the normalized value
(MaxUint64 = unbounded), documented on the field. classify table test
normalizes the same way; adds Test_NormalizeNonceGap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per @zhangchiqing (#974): be explicit that only nonceNextExpected and
nonceQueue may reach the enqueue path. Fold the keep verdicts into the
existing reject switch as an explicit case, and panic in default so a
newly added, unhandled verdict is a loud programming error rather than
silently falling through to enqueue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vishalchangrani vishalchangrani force-pushed the vishal/tx-mempool-redesign branch from 1db8c8f to 3865949 Compare June 27, 2026 05:24
Follow-up to the exhaustive-switch change: move the fast-path and enqueue
logic into the nonceNextExpected / nonceQueue cases instead of re-checking
the verdict after the switch. Each case returns, default still panics, so
the switch is the function tail (no trailing return). Extract the shared
enqueue logic into a small enqueue() helper; build the heldTx in place in
each keep case (no helper for a plain struct literal). Reads top-to-bottom
as one decision per verdict. No behavior change; unit + e2e green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vishalchangrani vishalchangrani force-pushed the vishal/tx-mempool-redesign branch from 3865949 to 7123a4e Compare June 27, 2026 05:27
vishalchangrani and others added 3 commits June 27, 2026 01:52
Per @zhangchiqing (#974): reconcileSubmission re-acquires queueMux after
the detached submit, and the failure path (rollbackSubmitting) already
guards against a stale ack clobbering newer state, but the success path
(markSubmitted) overwrote unconditionally. Safe today (submitWork is
sequential in the single processQueues goroutine, and concurrent Add never
writes submitting while a batch is in flight), but it becomes a real
double-submit hazard the moment submissions are parallelized.

Make markSubmitted symmetric with rollbackSubmitting: advance
lastConsecutivelySubmitted via max (never regress) and clear submitting
only if it still refers to this batch. Backward-compatible in sequential
operation (highNonce is always the current submitting). Adds a
nonceTracker test for the stale/out-of-order ack case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The guarded in-flight clear (clear submitting only if it still refers to
highNonce) was duplicated in markSubmitted and rollbackSubmitting. Extract
it into clearSubmittingIf: both acks fire when the network call returns and
retract the marker, while only markSubmitted also advances the submitted
nonce. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per @zhangchiqing (#974): the flag tells reconcileSubmission whether to
update the tracker, but "inFlight" described the cause (the batch was
marked submitting) and collided with nonceTracker.inFlight() (which means
"a submission is outstanding"). Rename to needsReconcile, which states the
flags purpose, and lead its doc with that. Pure rename; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@zhangchiqing zhangchiqing left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice 👍 Looks good to me now. Just left a few suggestion to comments and variable renaming.

Comment thread services/requester/tx_mempool.go
Comment thread services/requester/nonce_provider.go Outdated
type NonceProvider interface {
// GetNonce returns the current nonce of the given EOA address.
// GetNonce returns the account nonce of the given EOA — its transaction count,
// i.e. the next nonce the EOA should use (matches eth_getTransactionCount).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been confusing.

if GetNonce is returning next nonce. Then the following is wrong, because refreshIndexed should be refreshNextNonce. The word Indexed usually mean the nonce has been seen and indexed (used) on chain. If it's used as next expected, it would cause confusion:

	indexNonce, err := np.GetNonce(from)
	if err != nil {
		return 0, fmt.Errorf("reading indexed nonce for %s: %w", from.Hex(), err)
	}
	n.refreshIndexed(indexNonce)
  • Maybe just rename it to GetNextNonce to be explicit.
  • And also rename refreshIndexed to refreshNextNonce

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renamed to "nextNonce" - 60b0bb6

Comment thread services/requester/tx_mempool.go Outdated
vishalchangrani and others added 3 commits June 29, 2026 17:44
Co-authored-by: Leo Zhang <zhangchiqing@gmail.com>
Co-authored-by: Leo Zhang <zhangchiqing@gmail.com>
)

Per @zhangchiqing: "Indexed" implies a nonce already used/seen on chain,
but these hold the EOA next expected nonce (what GetNonce returns), so the
naming was misleading. Rename for consistency:

- NonceProvider.GetNonce -> GetNextNonce (interface, LocalNonceProvider
  impl, both mempool call sites, test fake).
- nonceTracker.localIndexedNonce -> localNextNonce; refreshIndexed ->
  refreshNextNonce; flushWork.localIndexedNonce -> localNextNonce; local
  indexNonce vars -> nextNonce; the "reading indexed nonce" error and the
  "local-indexed-nonce" log key -> next-nonce; stray "indexed frontier"
  comments -> "on-chain frontier".

NonceView.GetNonce is intentionally NOT renamed: the interface is satisfied
directly by flow-go query.View (whose method is GetNonce), so renaming it
would break compilation; documented inline. The test fake therefore carries
both GetNextNonce (NonceProvider) and GetNonce (NonceView).

Kept "indexed height" (block indexing, not the nonce). Pure rename; no
behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vishalchangrani vishalchangrani merged commit 7e9f185 into vishal/nonce-aware-mini-pool Jun 30, 2026
1 of 2 checks passed
@vishalchangrani vishalchangrani deleted the vishal/tx-mempool-redesign branch June 30, 2026 17:42
@github-project-automation github-project-automation Bot moved this from 👀 In Review to ✅ Done in 🌊 Flow 4D Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

2 participants