Add nonce-aware transaction pool#971
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d spacing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ex read; add pool unit tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re PR Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a nonce-aware transaction mempool mode, local nonce reading from indexed state, queueing and batching logic with TTL and spacing controls, startup wiring, metrics, and tests for the new flow. ChangesNonce-Aware Transaction Mempool
Sequence Diagram(s)sequenceDiagram
participant Client
participant TxMemPool
participant LocalNonceProvider
participant eoaQueue
participant CrossSporkClient
Client->>TxMemPool: Add(tx)
TxMemPool->>LocalNonceProvider: GetNextNonce(sender)
LocalNonceProvider-->>TxMemPool: nonce
alt immediate submission
TxMemPool->>CrossSporkClient: submitBatch(tx)
else enqueue
TxMemPool->>eoaQueue: store tx and timers
end
loop background tick
TxMemPool->>eoaQueue: collectDueBatches()
eoaQueue-->>TxMemPool: flushWork
TxMemPool->>CrossSporkClient: send batch
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related issues
Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ 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 |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 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/nonce_aware_tx_pool.go`:
- Around line 183-197: The pending-tx event is being published too early by
t.txPublisher.Publish(tx); update Add so it only publishes after the transaction
is successfully accepted into the pool/queue (i.e., after the fast-path
acceptance or after enqueue returns success), moving the
t.txPublisher.Publish(tx) call from its current position to immediately after
the success branch that uses models.DeriveTxSender/tx.MarshalBinary and the
queue acceptance logic; apply the same fix for the duplicate publish site around
the later block (the one referenced at 243-251) so subscribers see only
transactions that were actually enqueued/submitted.
- Around line 230-235: The code sets q.lastSubmittedAt before calling
t.submitBatch (e.g., the q.lastSubmittedAt assignment around submitBatch and
related blocks), which causes spacingElapsed/TxSubmissionSpacing to rate-limit
EOAs even when submitBatch fails; move the q.lastSubmittedAt assignment to
immediately after a successful return from t.submitBatch (i.e., only set
q.lastSubmittedAt and q.lastSentNonce and q.hasInFlight = true after submitBatch
returns nil), and ensure failure paths do not touch lastSubmittedAt (also update
the other occurrences referenced in the 397–420 region to follow the same
pattern), keeping submitBatch error handling as-is and adding any necessary
tests to cover transient submit failures.
- Around line 412-427: selectExpired can return arbitrarily many expired txs but
the current code appends them as a single flushWork (flushWork{from: from, txs:
expired}), bypassing the configured batch size and risking an unbounded Flow
transaction; change the logic to split the expired slice into bounded chunks of
at most t.config.TxMaxBatchSize (or the equivalent TxMaxBatchSize config) and
append one flushWork per chunk (each flushWork{from: from, txs: chunk}) instead
of a single large flushWork; keep the existing deletions and q.lastSubmittedAt
update but ensure the work queue receives multiple bounded batches so build/send
failures only affect a single chunk rather than the entire expired set.
- Around line 353-358: Empty queues removed by pruneStaleTxs never age out
because the deletion only checks q.lastSubmittedAt and that stays zero for EOAs
that never submitted; to fix, track a last-activity timestamp that pruneStaleTxs
updates (e.g., q.lastPrunedAt or a generic q.lastActivityAt) whenever it removes
txs and then change the idle deletion condition in the empty-queue branch (the
block that checks q.txs, q.hasInFlight, q.lastSubmittedAt and
idleQueueRetention) to consider either lastSubmittedAt OR the new last-activity
timestamp when deciding to delete stale empty queues; make the same change in
the other identical block (around lines 437-453) so empty queues pruned by
pruneStaleTxs can be removed after idleQueueRetention.
- Around line 254-260: Detect whether the nonce already existed and whether the
queue was empty before overwriting q.txs: capture wasEmpty := len(q.txs) == 0
and existed := (q.txs[tx.Nonce()] present) before assigning q.txs[tx.Nonce()] =
userTx; then only set q.windowDeadline and q.flushDeadline when wasEmpty is true
(i.e., on the first enqueue), so replacements of an existing nonce do not reset
the original deadlines; keep using t.config.TxCollectionWindow and
t.config.TxSubmissionSpacing for the deadline values.
🪄 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: 136b3b65-6469-4c8a-8a81-6d6ab310954a
📒 Files selected for processing (8)
bootstrap/bootstrap.gocmd/run/cmd.goconfig/config.gomodels/errors/errors.goservices/requester/nonce_aware_tx_pool.goservices/requester/nonce_aware_tx_pool_test.goservices/requester/nonce_provider.gotests/nonce_aware_tx_pool_test.go
| enqueuedAt: now, | ||
| } | ||
|
|
||
| // Read the index nonce at most once per Add — each read builds a full |
There was a problem hiding this comment.
That's a good remark. Maybe it would be worthwhile to introduce a dedicated DB index nonce, just like we have for the other models, such as blocks/transactions/receipts etc. It would be a lot more lightweight, compared to using the entire EVM state.
| } | ||
|
|
||
| // Reject an exact duplicate of a transaction already in the queue. | ||
| if existing, ok := q.txs[tx.Nonce()]; ok && existing.txHash == tx.Hash() { |
There was a problem hiding this comment.
The previous condition of if q.hasInFlight || (len(q.txs) == 0 && t.spacingElapsed(q, now)) doesn't always guarantee that the q.txs is empty, so it would be more efficient to do the check for duplicates much earlier, and return early whenever possible.
|
|
||
| // Reject a nonce that has been submitted and is still in flight: it | ||
| // would burn Flow fees on a guaranteed nonce-mismatch failure. | ||
| if q.hasInFlight && tx.Nonce() <= q.lastSentNonce { |
There was a problem hiding this comment.
Regardless of whether q.hasInFlight is true or false, whenever tx.Nonce() <= q.lastSentNonce, doesn't this imply that tx.Nonce() is inherently inflight? In which case, we wouldn't want to re-submit a transaction with tx.Nonce(). Also, I think that this check could be done before the condition of if q.hasInFlight || (len(q.txs) == 0 && t.spacingElapsed(q, now)) . What do you think?
| } | ||
|
|
||
| // Enqueue. A same-nonce, different-payload resubmission replaces the | ||
| // queued transaction (last write wins), matching mempool semantics. |
There was a problem hiding this comment.
That's correct, this should be possible, as long as both transactions are still in the pool, and tx.Nonce() > q.lastSentNonce.
| } | ||
| } | ||
| if len(stale) > 0 { | ||
| t.collector.TransactionsDropped(len(stale)) |
There was a problem hiding this comment.
We shouldn't be emitting that metric here, as it is meant to track the error rate during the build/submission of the Cadence transaction to the Flow network. We use it to track service/network errors, it wouldn't make sense to use it for state EVM txs that were pruned.
| // Read the index nonce at most once per Add — each read builds a full | ||
| // block view — and only when it is actually needed: to refresh a stale | ||
| // in-flight marker, or to evaluate the fast path. | ||
| if q.hasInFlight || (len(q.txs) == 0 && t.spacingElapsed(q, now)) { |
There was a problem hiding this comment.
We seem to use quite frequently the len(q.txs) outcome, maybe we could add some helper methods on q, to view the txs state, whether it is Empty or has a single tx etc.
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>
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>
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>
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>
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>
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>
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>
Refactor & harden the nonce-aware tx mempool (stacked on #971)
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
services/requester/tx_mempool_test.go (1)
123-128: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winLet the fake simulate
GetBlockView()failures too.
GetBlockView()always returnsnil, so the updated nonce-provider/view path can only exerciseNonceView.GetNonce()failures. That leaves the separate view-acquisition error branch untestable from this suite.♻️ Suggested test-double split
type fakeNonceProvider struct { - nonce uint64 - err error + nonce uint64 + viewErr error + nonceErr error } func (f *fakeNonceProvider) GetNextNonce(_ gethCommon.Address) (uint64, error) { - return f.nonce, f.err + return f.nonce, f.nonceErr } func (f *fakeNonceProvider) GetNonce(_ gethCommon.Address) (uint64, error) { - return f.nonce, f.err + return f.nonce, f.nonceErr } func (f *fakeNonceProvider) GetBlockView() (NonceView, error) { + if f.viewErr != nil { + return nil, f.viewErr + } return f, nil }Then add a small
pool.Add(...)regression that injectsviewErrand asserts the error is returned directly.🤖 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 123 - 128, The fake nonce provider only simulates GetNonce failures, so the GetBlockView error branch in the nonce-provider path is still untested. Update fakeNonceProvider.GetBlockView in tx_mempool_test.go to return a configurable view error (for example via a new field like viewErr), then add a regression test around pool.Add that injects that error and asserts the error is returned directly from the view-acquisition path.
🤖 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/nonce_provider.go`:
- Around line 80-90: The cache check in LocalNonceProvider.GetBlockView is using
a stale height snapshot taken before mu is acquired, so it can return cachedView
for an outdated block height under contention. Re-read or validate the current
height after locking in GetBlockView, and only return cachedView when the locked
height still matches the cachedHeight; otherwise rebuild or loop until the built
NonceView matches the latest EVM height. Keep the fix localized to GetBlockView
and the cachedHeight/cachedView path so Add and flush see a consistent nonce
state.
---
Nitpick comments:
In `@services/requester/tx_mempool_test.go`:
- Around line 123-128: The fake nonce provider only simulates GetNonce failures,
so the GetBlockView error branch in the nonce-provider path is still untested.
Update fakeNonceProvider.GetBlockView in tx_mempool_test.go to return a
configurable view error (for example via a new field like viewErr), then add a
regression test around pool.Add that injects that error and asserts the error is
returned directly from the view-acquisition path.
🪄 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: ccaadba1-89a8-4a96-8090-748f55f7a698
📒 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
✅ Files skipped from review due to trivial changes (1)
- models/errors/errors.go
🚧 Files skipped from review as they are similar to previous changes (3)
- cmd/run/cmd.go
- services/requester/tx_mempool.go
- config/config.go
| return p.cachedView, nil | ||
| } | ||
|
|
||
| viewProvider := query.NewViewProvider( |
There was a problem hiding this comment.
We don't have to lock the nonce provider when querying the view. We only need to lock when reading and updating the cached view.
There was a problem hiding this comment.
k done - I also added a few additional metrics to track the EOA based cache (hits vs misses etc.) to help debug on testnet and mainnet.
… locking
Adds two Prometheus metrics for observing the mempool on testnet:
- evm_gateway_txpool_submissions_total{reason} — batches submitted by flush
reason (fast-path / consecutive-prefix / ttl-expiry), incremented in
logSubmission.
- evm_gateway_txpool_nonce_view_cache_total{result} — block-view cache
hit/miss, so the EOA nonce-view caching is directly observable rather than
inferred from CPU/latency. Threads the metrics.Collector into
LocalNonceProvider (bootstrap wiring updated).
Also, per @zhangchiqing review: GetBlockView no longer holds the provider
mutex across the expensive view build. The lock now guards only the
cached-view slot (read + update); the build runs outside it. A concurrent
miss may build the view more than once for a height (wasteful but correct;
does not occur under the mempool queueMux-serialized access).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Testnet testing summaryThis change was exercised end-to-end on a live Flow EVM testnet gateway (chain id 545). A build off this branch was deployed with the mempool enabled ( Testing was driven by a Go harness that signs and fires Round 1 — single-EOA functional and sustained load
Round 2 — multi-EOA concurrency
ConclusionAll mempool behaviors (fast path, burst batching, submission spacing, out-of-order hold-and-fill, duplicate rejection, TTL submit-anyway, and per-EOA queueing) behaved as designed on live testnet — confirmed via both on-chain receipts and gateway logs/metrics — with no silent drops in any scenario. |
|
hey @zhangchiqing @m-Peter - thanks for the review on this PR! I have this another PR that port this change over to the soft-finality branch #977. I tested that one as well on testnet. (I will create an issue to merge the soft-finality branch into main behind a feature flag after all these changes) |

Summary
Implements the Nonce-Aware Transaction Pool — the third
TxPoolstrategy designed in the #965 discussion (revised plan) and follow-up. It replaces the blanket batch-window delay with nonce-aware behavior so the fix can serve all production traffic, not just the dedicated DFNS gateway. See also the animated scenario walkthroughs.Behavior
--tx-collection-window, default 300ms, reset on each arrival). On flush, the longest consecutive nonce prefix is submitted as oneEVM.batchRun, capped at--tx-max-batch-size(default 5).--tx-submission-spacingapart (default 1200ms ≈ 1.5x block production rate, per @m-Peter's suggestion) so they land in different blocks and cannot be reordered by Collection Nodes. The spacing also serves as the flush deadline anchored at first enqueue — there is deliberately no separate hard-cap knob (see fix(batch): eliminate silent tx drops from first-tx nonce race in BatchTxPool #965 discussion).--tx-pool-ttl(default 30s) expires — on expiry they are submitted anyway so the failure is observable on-chain rather than a silent drop (@janezpodhostnik's concern).transaction with the same nonce already submitted), since letting it through burns Flow fees on a guaranteed nonce-mismatch failure (@m-Peter's cost concern). A same-nonce resubmission of a queued (not yet sent) tx replaces it, matching mempool semantics.Configuration
Validated at startup: window ≤ spacing; requires
--tx-state-validation=local-index; mutually exclusive with--tx-batch-mode.SingleTxPoolandBatchTxPoolare untouched.Known tradeoff
The fast path submits while holding the pool-wide mutex, so one EOA's fast-path submission briefly serializes other EOAs'
Addcalls behind one Flow round-trip. This is documented in the type comment; per-EOA locking is the known upgrade path if contention shows up under multi-EOA load.Testing
services/requester): 13 tests covering prefix/expiry selection, fast-path gating, in-flight rejection, and a regression test for a failed-flush reconcile path (a failed submission previously could have permanently wedged an EOA's queue;submitWork/rollbackInFlightfix that and the test exercises the real path).tests/, emulator): out-of-order 10-tx burst lands exactly 10 executions (the DFNS regression scenario), fast-path single tx submits via theEVM.runpath, gap hold-and-fill, TTL eviction (duplicate-check lifecycle proves it), and in-flight same-nonce rejection. Existing batch-mode e2e tests still pass.Deployment notes
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Bug Fixes
Tests