Refactor & harden the nonce-aware tx mempool (stacked on #971)#974
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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>
624e26d to
8cbdfce
Compare
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 |
There was a problem hiding this comment.
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.
| 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.") |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
services/requester/tx_mempool_test.go (1)
250-256: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winTighten the fast-path timeout assertion.
The extra
+ time.Secondslack can hide regressions abovefastPathSubmitTimeout. Captureafter := time.Now()afterAddand assert the deadline is no later thanafter.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
📒 Files selected for processing (6)
cmd/run/cmd.goconfig/config.gomodels/errors/errors.goservices/requester/nonce_provider.goservices/requester/tx_mempool.goservices/requester/tx_mempool_test.go
|
@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 -
When you get a chance can you please review this PR ? thanks a lot |
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>
|
@coderabbitai re the Summary of this review pass:
|
|
Tip For best results, initiate chat on the files or code changes.
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
If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
| ) (nonceVerdict, error) { | ||
| // At or below the highest nonce we've already sent: in flight or submitted. | ||
| if n.highestSent().atLeast(nonce) { | ||
| return nonceInFlight, nil |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
lemme investigate. see what other chains do
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| n.refreshIndexed(indexNonce) | ||
|
|
||
| // Below the on-chain frontier: already used, can never execute. | ||
| if nonce < n.localIndexedNonce { |
There was a problem hiding this comment.
| if nonce < n.localIndexedNonce { | |
| if nonce <= n.localIndexedNonce { |
I think nonce has to be strictly above local indexed nonce, right?
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
I can rename localIndexedNonce to nextIndexedNonce for clarity if you think localIndexedNonce is not clear.
There was a problem hiding this comment.
added this additional line to explicitly call that out - https://github.com/onflow/flow-evm-gateway/pull/974/changes#diff-8b52571e515d91ade90131a27686cfb9df5fa5bb38aed986c082256511847043R28
| // 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 { |
There was a problem hiding this comment.
Does maxNonceGap ever change? Can we check maxNonceGap in the constructor of n or even the mempool?
| if n.maxNonceGap > 0 && nonce-n.localIndexedNonce > n.maxNonceGap { | |
| if nonce-n.localIndexedNonce > n.maxNonceGap { |
There was a problem hiding this comment.
n.maxNonceGap never changes. lemme see if it can be moved out from here.
There was a problem hiding this comment.
ok found a cleaner way to not have n.maxNonceGap > 0 in this condition all together.
There was a problem hiding this comment.
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 | ||
| } | ||
|
|
There was a problem hiding this comment.
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)
}
There was a problem hiding this comment.
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 🤷
There was a problem hiding this comment.
added this switch statement to keep all cases separate and easy to read - https://github.com/onflow/flow-evm-gateway/pull/974/changes#diff-eed503b56e9f1201642b8320513667c59e990d2853507fd4a96d1fa5538d215fR582-R623
| cancel() | ||
| t.logSubmission(from, batch, flushReasonFastPath, q.nonces.localIndexedNonce, submitErr) | ||
| if submitErr != nil { | ||
| return submitErr |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
| if submitErr != nil { | ||
| q.nonces.rollbackSubmitting(highNonce) | ||
| return | ||
| } | ||
| q.nonces.markSubmitted(highNonce) |
There was a problem hiding this comment.
There was a problem hiding this comment.
added calls to re-read before attempting to set submitting
There was a problem hiding this comment.
| // reconcileSubmission). TTL-expiry batches are not marked in flight and must | ||
| // never touch the tracker. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
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>
1db8c8f to
3865949
Compare
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>
3865949 to
7123a4e
Compare
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
left a comment
There was a problem hiding this comment.
Nice 👍 Looks good to me now. Just left a few suggestion to comments and variable renaming.
| 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). |
There was a problem hiding this comment.
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
GetNextNonceto be explicit. - And also rename
refreshIndexedtorefreshNextNonce
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>
7e9f185
into
vishal/nonce-aware-mini-pool
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 isvishal/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
nonceTrackerstate machine (classify/markSubmitting/markSubmitted/rollbackSubmitting) and anonceWrapper("unset = −∞") type that removes scattered nil/zero checks.collectDueBatchesinto focused helpers (collectQueue/collectPrefix/collectExpired/reportSize), surfacing the prefix-vs-TTL asymmetry.heldTxallocation past the cheap rejections; cached the block view by indexed height; assorted Go-idiom cleanups (helpers, prealloc,%wwrapping).Correctness / safety
ErrNonceTooLow/ErrNonceTooHigh, new--tx-max-nonce-gap).Observability — no silent drops
Documentation
Tests
nonceTrackerunit tests; an injectable clock enabling timing tests through the realAdd → tick → flushpath (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 theTest_TxMemPoole2e all pass.Notes for reviewers
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Tests