Add nonce endpoint - #47
Merged
Merged
Conversation
Every account starts at 0, validation accepts only the expected nonce, each included user op (business failures included) advances its sender's nonce by exactly one, nothing else changes a nonce, and an op at u32::MAX is never included. This was the wallet's behavior; the sequencer now relies on it to derive GET /nonce from persisted user ops instead of asking the engine, so the rule is stated in the contract, the trait docs, the C header, and AGENTS.md.
GET /nonce?sender= returns the nonce a sender signs next: one past its latest user op in a valid batch, or 0. It reads the rows the lane already writes in the FULL commit that authorizes each POST /tx ack, so a read after a 200 sees the op, and recovery lowers the value through the valid-batch filter. No new table or writer: a (sender, nonce) index on user_ops keeps the lookup off a full-history scan. POST /tx keeps no nonce or fee pre-filter; the api.rs module doc records why. GET /domain serves the EIP-712 domain /tx verifies against, keyed as eth_signTypedData_v4 expects, for clients to assert against the domain they pin. /fee and /nonce responses carry Cache-Control: no-store. The Rust SDK gains get_nonce and get_domain; GetFeeError becomes QueryError, shared by the three read routes. The README documents the one-op-in-flight client model and the known gap: a sender idle since a rebuilt baseline reads 0 until its first op. Closing that gap is a Track 6 follow-up. Schema: rewrites baseline migration 0001 (no deployed databases), so existing data directories need a fresh setup to get the index.
The harness wallet gains served_next_nonce. Restart, stale-batch recovery, Tip cascade, and cold-replica recovery now assert that GET /nonce agrees with the independent replay wallet, including the drop when recovery invalidates soft-confirmed ops. The rebuild round trip pins the documented gap (a sender idle since the rebuild reads 0) and exactness after its first op in the new era.
A dated record keeps the 2026-09-25 measurement: the sender index raises the mean 64-op chunk commit from 0.27 to 1.0 ms and p99 from 1.1 to 9 ms on the measured machine, and a lane-written per-sender table compares favorably only while the sender population fits in a few pages. The register gains a "Known optimizations" section for headroom with a known mechanism: the sender index and its alternatives, checkpoints running inside the lane's commit, and per-request read connections. Each names its evidence and revisit trigger; the index's schema comment points there.
u32::MAX has no successor under the nonce rule. The wallet accepted an op carrying it when the sender's expected nonce was u32::MAX, then panicked in apply, taking down the lane (or the canonical machine). validate_and_execute_user_op now rejects such an op with InvalidReason::NonceExhausted before app validation, next to the max-fee guard, so the lane and the canonical scheduler agree. Reaching it takes 2^32-1 paid ops from one sender; the rejection is a courtesy, not a live threat. The C header appends APPLICATION_ENGINE_NONCE_EXHAUSTED, carried like the max-fee reason so the vocabulary stays whole; engines never report it, and the host treats it as unsupported if one does.
Adds the read side to the sender-index record: a lookup walks the sender's invalidated index entries above its current nonce (about 0.11 us each, 16 ms at 100,000), bounded by that sender's own rolled-back volume, while opening a read connection per request costs about 0.2 ms, a hundred times the query. The register entries now carry these numbers and the covering-index option. States that the rebuilt-baseline gap also covers a sender whose post-rebuild ops a later recovery invalidated, and why /domain keeps chainId a JSON number: it is exact for every chain browser wallets accept, and a larger ID fails closed at POST /tx.
GCdePaula
marked this pull request as ready for review
September 25, 2026 13:47
stephenctw
previously approved these changes
Sep 26, 2026
The exhausted-nonce guard ran before app validation, so any op carrying u32::MAX was rejected as "nonce 4294967295 has no successor", even when the sender expected a lower nonce. The guard now runs after the app accepts the op: an op at the wrong nonce gets the app's "bad nonce: expected N, got 4294967295", and NonceExhausted means exactly that the sender's expected nonce is u32::MAX. Both paths reject, and the canonical scheduler ignores rejection reasons, so consensus is unchanged. The threat-model row now states what GET /nonce exposes: a live per-address count of soft-confirmed ops, published before their batches reach L1. It reveals activity timing, not op contents, and moves only after the op is sequenced.
stephenctw
self-requested a review
September 28, 2026 14:29
stephenctw
approved these changes
Sep 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds two public ingress reads so a wallet can prepare a signature without guessing:
GET /nonce?sender=(the nonce to sign next) andGET /domain(the EIP-712 domain/txverifies against). It also makes the user-nonce rule the nonce endpoint relies on an explicit application contract, and rejects the unreachable-in-practiceu32::MAXnonce instead of panicking.What changed
GET /nonce?sender=0x…→{"sender": "0xAbC…", "next_nonce": 7}next_nonceis one past the sender's latest user op in a valid batch, or 0. It is namednext_noncebecausenoncein/txresponses and the WS feed is the nonce an op consumed.user_opsrows the inclusion lane already writes in the FULL commit that authorizes eachPOST /txack. There is no new table and no new writer; a(sender, nonce)index keeps the lookup off a full-history scan.200sees the op, because the commit precedes the ack.valid_batchesfilter excludes invalidated rows, which are kept and whose nonces are reused on resubmit.200with 0. A lookup that finds no row is an answer, not a storage fault.GET /domain→{"name", "version", "chainId", "verifyingContract"}/txverifies against, keyed aseth_signTypedData_v4expects.eth_chainId.chainIdstays a JSON number; the README explains why.Also
/feeand/noncesendCache-Control: no-store./feeis otherwise unchanged.POST /tx. A stale op costs the lane an in-memory check and no transaction; a pre-filter would add a SQLite read to every honest submit and is easy to bypass. Theapi.rsmodule doc records the reasoning and when to revisit it.validate_and_execute_user_oprejects nonceu32::MAXwithInvalidReason::NonceExhausted, which the lane and the canonical scheduler both apply. The wallet used to panic in apply there. Reaching it takes 2³²−1 paid ops from one sender.get_nonceandget_domain.GetFeeErroris renamed toQueryError, shared by the three read routes.docs/review/register.md.Why this design
Asking the app for a nonce would mean querying the inclusion lane, which puts reads into the one bounded queue and onto the single app thread. Reading SQLite does not contend with the lane: WAL readers never block the writer. The lane already persists every included op's
(sender, nonce), so the nonce is derived from facts that already exist.We considered and rejected two replicated tables:
Known limitation
After an operator rebuild (
setup --recovery), a sender with no surviving op since the rebuild reads 0 even when its nonce in the rebuilt baseline is higher. A422bad-nonce rejection still names the expected nonce. This is documented in the README and the contract, and the rebuild-round-trip e2e test asserts it. Cockroach recovery is being redesigned as a separate tool; seeding baseline nonces belongs there (Track 6 note).Performance
Measured on an M5 Max running macOS, with WAL +
synchronous=FULL, 64-op chunks, 192k ops. Full method and limits are indocs/review/2026-09-25-sender-index-commit-cost.md.Risk and compatibility
0001to add the index. There are no deployed databases, but existing data directories need a freshsetupto get it.NonceExhaustedin the shared boundary, which changes canonical behavior only for an op atu32::MAX, which used to crash the machine.APPLICATION_ENGINE_NONCE_EXHAUSTED = 3, carried like the max-fee reason. Engines never report it, and the host treats it as unsupported if one does. @edubart: this is additive only, but it touches the shared header.GetFeeErroris renamed toQueryError.POST /txand the/feebody are unchanged.