Skip to content

feat: parallel nonce lanes for concurrent sends#114

Open
AlonzoRicardo wants to merge 1 commit into
mainfrom
feat/parallel-nonce-lanes
Open

feat: parallel nonce lanes for concurrent sends#114
AlonzoRicardo wants to merge 1 commit into
mainfrom
feat/parallel-nonce-lanes

Conversation

@AlonzoRicardo

Copy link
Copy Markdown
Contributor

What this PR does

Adds parallel nonce lanes so independent operations from one Safe account can be sent concurrently (or back-to-back) without colliding on the nonce — via ERC-4337 two-dimensional nonces (a 192-bit key + sequence). This supersedes the sequential local nonce tracker currently on main.

  • parallel: true — each send is placed in a fresh, independent lane (a random 192-bit key at sequence 0). Fire them with Promise.all(...); they don't collide.
  • nonceKey: bigint | string — send in an explicit lane. A string is hashed to a deterministic, reusable named lane (same label resumes the same lane across sessions); a bigint is used as the raw uint192 key (validated to the uint192 range, throwing otherwise).
  • Both are settable at construction or per call. Precedence: nonceKey > parallel > default (key 0). Default sends are unchanged.
const [a, b] = await Promise.all([
  account.sendTransaction(tx1, { parallel: true }),
  account.sendTransaction(tx2, { parallel: true })
])
await account.sendTransaction(tx, { nonceKey: 'payments' }) // reusable named lane

Why (and why the sequential tracker is removed)

The sequential tracker (_allocateNonce/_reservedNonces/_nonceLock) delivered no real concurrency: a future-nonce UserOp (N, then N+1) is rejected with AA25 at eth_estimateUserOperationGas, because estimation simulates against current mined state. Verified live on Sepolia against both Candide and Pimlico — the second concurrent op is rejected at estimation regardless of bundler (Pimlico's "queue up to +10" is a mempool property the estimate-then-send flow never reaches; and its sponsored/token paymaster step re-estimates anyway).

Lane isolation sidesteps it: a fresh key at sequence 0 is valid at current state, so it passes estimation on any spec-compliant bundler. 2D nonces are ERC-4337-standard and portable (Pimlico, Candide, ZeroDev, Biconomy all support them). This mirrors the sibling wdk-wallet-evm-7702-gasless PR.

Implementation

_resolveNonce(config) computes the lane nonce (label → keccak-derived uint192 key; parallel → random key; else undefined) and threads it into _buildUserOperation's overrides (EvmErc4337BuildOverrides). The default path builds at the current nonce with the quote cache preserved; lane sends bypass the cache and build at the lane nonce. Removed: _allocateNonce, _reservedNonces, _nonceLock, _releaseNonce, _maybeReleaseNonceOnRejection, _isPreAcceptanceError, withTimeout, and the release-on-rejection paths.

Trade-offs (documented in the README)

  • Deploy the Safe first. A Safe is deployed by its first UserOperation (which carries the init code); concurrent sends from an undeployed account can't each carry init code, so let one send land before firing parallel lanes.
  • Per-sender mempool cap (≈4) per ERC-7562 (SAME_SENDER_MEMPOOL_COUNT = 4) — firing more than 4 lanes concurrently may be rejected until earlier ops mine; bundler-configurable.
  • parallel: true mints a new EntryPoint nonce slot per send (one-time gas per lane). For repeated parallel workloads, reuse a fixed set of lanes via nonceKey labels.
  • A single lane is still sequential — concurrency comes from using different keys. Dependent/ordered ops should be batched into one UserOperation.
  • signTransaction still signs at the default nonce (lanes apply to sendTransaction/transfer) — possible follow-up.

Type of change

  • Feature (non-breaking for default sends; opt-in). Removes the internal sequential tracker, which had no public API.

Verification

  • standard lint clean.
  • Unit suite 89/89 (7 new nonce-lane tests: default no-override, parallel fresh lane, distinct lanes, raw bigint key, deterministic string label, over-range + negative bigint throw). The sequential-nonce test was removed and default-send assertions updated to omit the previously-injected nonce.
  • Integration suite 33/33 on hardhat v3 + alto (4 new lane tests: concurrent parallel sends leave key-0 untouched, named lane advances by 2, nonceKey precedence over parallel, transfer() through a named lane). Existing cache/staleness tests still green.
  • .d.ts hand-edited to match (no tsc).

Replace the sequential local nonce tracker with ERC-4337 two-dimensional
nonce lane isolation, so independent operations from one account can be
sent concurrently (or back-to-back) without colliding on the nonce.

- `parallel: true` — each send is placed in a fresh, independent lane
  (a random 192-bit key at sequence 0). Fire them with Promise.all.
- `nonceKey: bigint | string` — send in an explicit lane. A string is
  hashed to a deterministic, reusable named lane; a bigint is used as the
  raw uint192 key (validated to the uint192 range, throwing otherwise).
- Both settable at construction or per call. Precedence:
  nonceKey > parallel > default (key 0). Default sends are unchanged.

The removed sequential tracker delivered no real concurrency: a future
nonce (N+1) is rejected with AA25 at eth_estimateUserOperationGas on real
bundlers (verified on Sepolia against both Candide and Pimlico), because
estimation simulates against current mined state. Lane isolation sidesteps
this — a fresh key at sequence 0 is valid at current state.

The default path now builds at the current nonce with the quote cache
preserved; lane sends bypass the cache and build at the lane nonce.

Hand-edited the .d.ts to match. Adds unit + integration lane coverage.
Comment on lines +101 to +114
/**
* Build-time UserOperationV7 overrides passed to `_buildUserOperation`: the gas overrides plus an
* optional explicit `nonce` used to place the operation in a specific two-dimensional nonce lane.
* The `nonce` is derived internally from the account's `parallel`/`nonceKey` configuration, never
* from user-supplied transaction fields.
*
* @typedef {Object} EvmErc4337BuildOverrides
* @property {bigint} [callGasLimit] - Override for the UserOperation's call gas limit.
* @property {bigint} [verificationGasLimit] - Override for the UserOperation's verification gas limit.
* @property {bigint} [preVerificationGas] - Override for the UserOperation's pre-verification gas.
* @property {bigint} [maxFeePerGas] - Override for the UserOperation's max fee per gas (EIP-1559 cap).
* @property {bigint} [maxPriorityFeePerGas] - Override for the UserOperation's max priority fee per gas.
* @property {bigint} [nonce] - Full 256-bit UserOperation nonce (`key << 64 | sequence`) placing the op in a specific lane. Omitted for default (key-0) sends, in which case AbstractionKit fetches the current on-chain nonce.
*/

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.

Instead of duplicating all the 'EvmErc4337GasOverrides' type's fields, you can just create a new type definition that only includes the new 'nonce' property and then use it with: EvmErc4337GasOverrides & Nonce.

/**
 * @typedef {Object} Nonce
 * @property {number | bigint} [nonce] - Full 256-bit UserOperation nonce (`key << 64 | sequence`) placing the op in a specific lane. Omitted for default (key-0) sends, in which case the account fetches the current on-chain nonce.
 */

* @property {bigint} [preVerificationGas] - Override for the UserOperation's pre-verification gas.
* @property {bigint} [maxFeePerGas] - Override for the UserOperation's max fee per gas (EIP-1559 cap).
* @property {bigint} [maxPriorityFeePerGas] - Override for the UserOperation's max priority fee per gas.
* @property {bigint} [nonce] - Full 256-bit UserOperation nonce (`key << 64 | sequence`) placing the op in a specific lane. Omitted for default (key-0) sends, in which case AbstractionKit fetches the current on-chain nonce.

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.

All numeric inputs should also accept number other than big integers. This also applies to all the other fields of the 'EvmErc4337GasOverrides' type. Since it's a small change, feel free to address it here without opening a separate PR:

Suggested change
* @property {bigint} [nonce] - Full 256-bit UserOperation nonce (`key << 64 | sequence`) placing the op in a specific lane. Omitted for default (key-0) sends, in which case AbstractionKit fetches the current on-chain nonce.
* @property {number | bigint} [nonce] - Full 256-bit UserOperation nonce (`key << 64 | sequence`) placing the op in a specific lane. Omitted for default (key-0) sends, in which case AbstractionKit fetches the current on-chain nonce.

* @property {bigint} [preVerificationGas] - Override for the UserOperation's pre-verification gas.
* @property {bigint} [maxFeePerGas] - Override for the UserOperation's max fee per gas (EIP-1559 cap).
* @property {bigint} [maxPriorityFeePerGas] - Override for the UserOperation's max priority fee per gas.
* @property {bigint} [nonce] - Full 256-bit UserOperation nonce (`key << 64 | sequence`) placing the op in a specific lane. Omitted for default (key-0) sends, in which case AbstractionKit fetches the current on-chain nonce.

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.

The fact that we are using abstraction kit is an implementation detail:

Suggested change
* @property {bigint} [nonce] - Full 256-bit UserOperation nonce (`key << 64 | sequence`) placing the op in a specific lane. Omitted for default (key-0) sends, in which case AbstractionKit fetches the current on-chain nonce.
* @property {bigint} [nonce] - Full 256-bit UserOperation nonce (`key << 64 | sequence`) placing the op in a specific lane. Omitted for default (key-0) sends, in which case the account fetches the current on-chain nonce.

* @property {string} safeModulesVersion - Version of the Safe 4337 module set to deploy with the account (e.g. "0.3.0"). Determines the module addresses used in init code.
* @property {OnChainIdentifier | string} [onChainIdentifier] - Optional on-chain identifier. Appends a 50-byte project marker to every UserOperation callData. Pass a string to reuse it as the project name, or a full object for more control.
* @property {boolean} [parallel] - When true, each send is placed in a fresh, independent nonce lane (a random 192-bit key at sequence 0) so concurrent or back-to-back sends don't collide on the nonce. Ordering between such sends is not guaranteed and each consumes a new EntryPoint nonce slot. Ignored when `nonceKey` is set. Overridable per call.
* @property {bigint | string} [nonceKey] - Send in an explicit nonce lane. A string is hashed to a deterministic key — a reusable named lane that resumes the same sequence across sessions; a bigint is used as the raw uint192 key and must be within the uint192 range (0 to 2^192 - 1), otherwise the send throws. Sends sharing a key are ordered sequentially; different keys run in parallel. Overridable per call.

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.

This field should also accept numbers:

Suggested change
* @property {bigint | string} [nonceKey] - Send in an explicit nonce lane. A string is hashed to a deterministic key a reusable named lane that resumes the same sequence across sessions; a bigint is used as the raw uint192 key and must be within the uint192 range (0 to 2^192 - 1), otherwise the send throws. Sends sharing a key are ordered sequentially; different keys run in parallel. Overridable per call.
* @property {number | bigint | string} [nonceKey] - Send in an explicit nonce lane. A string is hashed to a deterministic key a reusable named lane that resumes the same sequence across sessions; a bigint is used as the raw uint192 key and must be within the uint192 range (0 to 2^192 - 1), otherwise the send throws. Sends sharing a key are ordered sequentially; different keys run in parallel. Overridable per call.

* @param {MetaTransaction[]} calls - The meta-transactions to include in the UserOperation.
* @param {Omit<EvmErc4337WalletConfig, 'transferMaxFee' | 'transactionMaxFee'>} config - The wallet configuration.
* @param {EvmErc4337GasOverrides} [txOverrides] - Optional UserOperationV7 gas overrides extracted from the input transaction(s).
* @param {EvmErc4337BuildOverrides} [txOverrides] - Optional UserOperationV7 gas overrides extracted from the input transaction(s), plus an optional explicit lane `nonce`.

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.

This should be:

Suggested change
* @param {EvmErc4337BuildOverrides} [txOverrides] - Optional UserOperationV7 gas overrides extracted from the input transaction(s), plus an optional explicit lane `nonce`.
* @param {EvmErc4337GasOverrides & Nonce} [txOverrides] - Optional UserOperationV7 gas overrides extracted from the input transaction(s), plus an optional explicit lane `nonce`.

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.

2 participants