feat: parallel nonce lanes for concurrent sends#114
Conversation
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.
| /** | ||
| * 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. | ||
| */ |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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:
| * @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. |
There was a problem hiding this comment.
The fact that we are using abstraction kit is an implementation detail:
| * @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. |
There was a problem hiding this comment.
This field should also accept numbers:
| * @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`. |
There was a problem hiding this comment.
This should be:
| * @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`. |
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 onmain.parallel: true— each send is placed in a fresh, independent lane (a random 192-bit key at sequence 0). Fire them withPromise.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).nonceKey>parallel> default (key 0). Default sends are unchanged.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 ateth_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-gaslessPR.Implementation
_resolveNonce(config)computes the lane nonce (label → keccak-derived uint192 key;parallel→ random key; elseundefined) 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)
SAME_SENDER_MEMPOOL_COUNT = 4) — firing more than 4 lanes concurrently may be rejected until earlier ops mine; bundler-configurable.parallel: truemints a new EntryPoint nonce slot per send (one-time gas per lane). For repeated parallel workloads, reuse a fixed set of lanes vianonceKeylabels.signTransactionstill signs at the default nonce (lanes apply tosendTransaction/transfer) — possible follow-up.Type of change
Verification
standardlint clean.nonceKeyprecedence overparallel,transfer()through a named lane). Existing cache/staleness tests still green..d.tshand-edited to match (notsc).