Skip to content

fix: unique default nonce keys to prevent same-chain userOp nonce collision - #209

Merged
fichiokaku merged 1 commit into
developfrom
fix/nonce-key-collision
Sep 1, 2026
Merged

fichiokaku merged 1 commit into
developfrom
fix/nonce-key-collision

Conversation

@fichiokaku

@fichiokaku fichiokaku commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #208

Problem

getSessionQuote({ mode: "PREPARE" }) (and getQuote generally) can return a quote whose userOps contain a duplicate nonce for the same account on the same chain. The second userOp then fails at execution as a nonce reuse. The duplicate is present in the quote as returned, so it is assigned at quote construction. Client-reported and intermittent.

Root cause

Each userOp gets a nonce key; the sequence is read from chain per key. When no key is passed (the normal path), getDefaultNonceKey derived it from the wall clock:

const key = BigInt(Date.now())
await new Promise((resolve) => setTimeout(resolve, 1)) // "ensure next call is in the next millisecond"

All userOps in a bundle fetch their nonces concurrently (Promise.all in prepareUserOps). setTimeout(1) does not guarantee Date.now() advances between the lock-serialized calls, so two same-chain userOps can receive the same key, hence the same nonce (both read sequence 0). A session install/enable PREPARE bundle has multiple same-chain userOps, which is where it surfaces.

Fix

Make the default key strictly increasing per (account, chainId) so uniqueness no longer depends on the wall clock advancing. The lock (for serialization) is kept; the fragile setTimeout(1) spacer is removed.

Test

Adds a regression test that freezes Date.now() (the exact condition the old code failed under) and asserts 50 concurrent getDefaultNonceKey calls return distinct keys, plus a strictly-increasing check. Verified it fails on the pre-fix code and passes after.


PR-Codex overview

This PR focuses on improving the getDefaultNonceKey function to ensure unique and strictly increasing nonce keys for concurrent calls, addressing a nonce collision issue caused by previous time-based logic.

Detailed summary

  • Added unit tests for getDefaultNonceKey to ensure unique keys for concurrent calls and strictly increasing keys for sequential calls.
  • Revised the getDefaultNonceKey implementation to avoid reliance on Date.now() with a timeout, ensuring distinct keys regardless of timer resolution.
  • Introduced a lastIssuedKey map to track the last key issued for each (account, chainId).

✨ Ask PR-Codex anything about this PR by commenting with /codex {your question}

@fichiokaku
fichiokaku requested a review from vr16x July 23, 2026 14:10
@github-actions

github-actions Bot commented Jul 23, 2026 •

Copy link
Copy Markdown

size-limit report 📦

Path Size
core (esm) 72.26 KB (+0.12% 🔺)
core (cjs) 87.33 KB (+0.09% 🔺)
bundler (tree-shaking) 7.2 KB (0%)
paymaster (tree-shaking) 543 B (0%)

…lision

getDefaultNonceKey derived the key from Date.now() with a setTimeout(1) spacer
to keep concurrently-built same-chain userOps distinct. setTimeout(1) does not
guarantee Date.now() advances between the lock-serialized calls, so two userOps
in one bundle (e.g. a session install via getSessionQuote PREPARE) could receive
the same key and therefore the same nonce, failing the second at execution as a
nonce reuse.

Make the key strictly increasing per (account, chainId) so uniqueness no longer
depends on the wall clock advancing. Adds a regression test that freezes the
clock and asserts concurrent calls still return distinct keys.

Fixes #208
@fichiokaku
fichiokaku force-pushed the fix/nonce-key-collision branch from 40aa905 to 6331803 Compare September 1, 2026 13:55
@fichiokaku
fichiokaku merged commit 9b8428f into develop Sep 1, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Nonce collision: duplicate nonce assigned to same-chain userOps at quote construction (getSessionQuote/getQuote)

2 participants