From e120e86caf5c22be1620253cb436483d8ab8cb48 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 10 Sep 2026 14:38:02 +0200 Subject: [PATCH 1/9] docs(evm): add parallel nonce submission tutorial for HFT traders Add a High-Frequency Trading group to the EVM tab with a guide to sei-protocol/sei-parallel-nonce-hft: how EIP-7702 delegation, ERC-4337 nonce lanes, a private mempool, and gas-only relayers let one funded address submit many independent operations without a sequential nonce queue. - Explain the problem (sequential nonces, no pending state on Sei, strict nonce admission under Giga) and each mechanism with its own diagram - Add a "moat" section comparing the design with single wallets, wallet fleets, and public ERC-4337 bundlers - Step-by-step tutorial for a local Prague fork and Atlantic-2, an annotated example report, tuning notes, a configuration reference, venue-integration guidance, and troubleshooting - Six theme-aware SVG diagrams in snippets/parallel-nonce-diagrams.jsx, following the existing Giga diagram convention - Link the page from the EVM home page Use Cases Co-authored-by: Cursor --- docs.json | 6 + evm/hft/parallel-nonce-submission.mdx | 599 ++++++++++++++++++++++++++ evm/index.mdx | 3 + snippets/parallel-nonce-diagrams.jsx | 372 ++++++++++++++++ 4 files changed, 980 insertions(+) create mode 100644 evm/hft/parallel-nonce-submission.mdx create mode 100644 snippets/parallel-nonce-diagrams.jsx diff --git a/docs.json b/docs.json index 09d2c58..97b6247 100644 --- a/docs.json +++ b/docs.json @@ -203,6 +203,12 @@ } ] }, + { + "group": "High-Frequency Trading", + "pages": [ + "evm/hft/parallel-nonce-submission" + ] + }, { "group": "sei-js Library", "pages": [ diff --git a/evm/hft/parallel-nonce-submission.mdx b/evm/hft/parallel-nonce-submission.mdx new file mode 100644 index 0000000..55493b2 --- /dev/null +++ b/evm/hft/parallel-nonce-submission.mdx @@ -0,0 +1,599 @@ +--- +title: 'Parallel Nonce Submission for High-Frequency Trading' +sidebarTitle: 'Parallel Nonce Submission' +description: 'Submit many independent orders from one funded Sei address without a sequential nonce queue. This guide explains how EIP-7702 delegation, ERC-4337 nonce lanes, a private mempool, and gas-only relayers fit together, and walks you through running the reference implementation.' +keywords: ['sei', 'hft', 'high-frequency trading', 'nonce', 'parallel nonce', 'nonce lanes', 'eip-7702', 'erc-4337', 'entrypoint', 'userop', 'relayer', 'bundler', 'trading infrastructure', 'market making'] +--- + +import { PnNonceQueue, PnLaneNonce, PnDelegation, PnPipeline, PnBundleLifecycle, PnFailureIsolation } from '/snippets/parallel-nonce-diagrams.jsx'; + +One funded account on Sei EVM has one sequential nonce. That is the right model for a wallet and the wrong model for an order flow: a single transaction that never lands freezes every transaction signed after it. The usual fix is a fleet of hot wallets, which multiplies balances, approvals, and keys. + +[sei-parallel-nonce-hft](https://github.com/sei-protocol/sei-parallel-nonce-hft) is a reference implementation that removes the queue instead. One funded externally owned account (EOA) keeps its address, balance, and approvals, and submits many mutually independent operations at the same time. This page explains how the pieces fit together, why the resulting design is hard to match with simpler setups, and how to run it against a local fork and Atlantic-2. + + + The repository is a runnable engineering demonstration, not a production trading service. It uses a mock venue, plaintext development keys in `.env`, an in-process queue, and console output. Read [Before you adapt this for real trading](#before-you-adapt-this-for-real-trading) before pointing it at inventory. + + +## Why one account is one queue + +An EVM account's transaction nonces are strictly sequential. If nonce `n` has not executed, nonce `n + 1` cannot execute first. Two failures that look similar behave very differently: + +| Event | Blocks later nonces? | +| --- | --- | +| The transaction lands and its call reverts | No. The transaction consumed its nonce. | +| The transaction never lands | Yes. Every later nonce waits until the gap is filled or replaced. | + +The second case is the submission bottleneck. It covers transactions that are dropped, underpriced, rejected at admission, lost before broadcast, or stranded after a process crash. + + + +Two properties of Sei make this sharper than on Ethereum: + +- **No pending state.** `eth_getTransactionCount(address, "pending")` returns the same value as `"latest"`, so you cannot reconstruct an in-flight nonce queue from the RPC. See [Finality and block tags](/evm/evm-parity/finality#pending-state). +- **Strict nonce admission.** Under Giga, the producer mempool admits EVM transactions in per-sender nonce order and rejects a gap with a `bad nonce` error instead of holding it. See [Giga mode behavior](/node/technical-reference#giga-mode-behavior-and-per-block-limits). The repository's `npm run baseline` command probes the behavior of whatever RPC path you configure rather than assuming every endpoint behaves identically. + +Splitting inventory across hot wallets raises throughput, but every wallet is another balance to rebalance, another set of approvals to maintain, and another key that can move funds. + +## How it works + +The design combines four ingredients. Each one solves a specific part of the problem, and none of them is sufficient alone. + +| Ingredient | What it contributes | +| --- | --- | +| ERC-4337 v0.8 two-dimensional nonces | Independent nonce lanes for one account | +| EIP-7702 delegation | The existing funded address keeps its balance and approvals | +| A private in-process mempool | No per-sender pending cap and no third-party bundler | +| Gas-only relayers with a write-ahead journal | Someone still has to send sequential transactions, but they hold no inventory | + +### ERC-4337 nonce lanes + +EntryPoint v0.8 stores a UserOperation nonce as a 192-bit key plus a 64-bit sequence: + +```text +nonce = (uint192 key << 64) | uint64 sequence +``` + +The EntryPoint keeps one `sequence` counter for each `key`. The repository calls a key a **lane**. + + + +Four rules follow from that layout: + +- Operations on different lanes have no ordering relationship. +- Operations on the same lane stay strictly sequential, so the implementation allows at most one in-flight operation per lane. +- An operation that executes and reverts still consumes its lane sequence. +- An operation that never reaches a successful `handleOps` transaction consumes nothing. + +`LaneAccount` rejects lane `0`. Most SDKs pick key `0` when you do not pass one, and a book that lands entirely on key `0` is a single queue again. Rejecting it turns a silent fallback into a loud validation failure. `ADMIN_LANE` (the maximum `uint192`) is reserved for integrations that need one explicitly ordered lane for administrative calls. + +### EIP-7702 keeps the funded address + +An EIP-7702 authorization writes a delegation designator into the EOA's code slot: + +```text +0xef0100 || +``` + +The address does not change. Its native balance, token balances, venue state, and approvals stay attached to the same account. When the EntryPoint calls the account, the EVM runs `LaneAccount`'s code in the EOA's context, so `LaneAccount.execute` reaches the venue with the trading EOA as `msg.sender`. + + + +`LaneAccount` inherits the audited `Simple7702Account` from the [account-abstraction](https://github.com/eth-infinitism/account-abstraction) repository and adds a single policy check: + +```solidity +contract LaneAccount is Simple7702Account { + uint192 public constant ADMIN_LANE = type(uint192).max; + + error LaneZeroReserved(); + + function _validateNonce(uint256 nonce) internal pure override { + if (nonce >> 64 == 0) revert LaneZeroReserved(); + } +} +``` + +The trader spends one ordinary EVM nonce to install the delegation, using a type-4 transaction. Sei requires a non-empty authorization list on type-4 transactions; see [Transaction types](/evm/evm-parity/transaction-types#set-code-eip-7702-auth-list-requirement). After that, the trading path signs UserOperations only and the trader's EVM nonce stops moving. + + + EIP-7702 alone does not create parallel nonces. It preserves the account. ERC-4337 supplies the independent nonce model. You need both. + + +### Gas-only relayers carry what is left of the queue + +UserOperations are not transactions. Something still has to wrap them in `EntryPoint.handleOps` transactions and pay for them. The repository uses a pool of gas-only relayers fed by a private in-process mempool. + + + +The sequential constraint has not disappeared; it has moved. Each relayer still has one sequential EVM nonce and keeps one outer transaction in flight at a time. What changes is the custody boundary. Relayers hold native SEI for gas and nothing else. A compromised relayer key can lose its own gas balance or rebroadcast operations the trader already signed. It cannot create a new operation, because every UserOperation carries the trader's EIP-712 signature over the EntryPoint's `PackedUserOperation` digest. + +The private mempool matters for a different reason. The canonical ERC-4337 mempool enforces the [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562) validation rules, including `SAME_SENDER_MEMPOOL_COUNT = 4` for an unstaked sender. Four pending operations is a wallet number, not a trading number. Those rules exist so competing bundlers can safely pack strangers' operations together. Here every operation comes from one account you control, so there are no strangers to defend against. The EntryPoint still enforces everything that protects funds: the signature, per-lane nonce uniqueness, and prefund solvency. + +### One bundle, start to finish + +Each relayer runs one asynchronous worker. The worker takes a bundle of up to `MAX_OPS_PER_BUNDLE` operations (never two from the same lane), and then works through a fixed sequence. + + + +Two details carry most of the safety: + +- **Write-ahead ordering.** The signed outer transaction is written to the journal before it is broadcast. A crash between signing and sending cannot lose or duplicate work; on restart, the exact raw bytes are rebroadcast first. +- **Same-nonce replacement.** If no receipt arrives within `BUNDLE_RECEIPT_TIMEOUT_MS`, the worker checks earlier attempts for a receipt, bumps fees by `REPLACEMENT_FEE_BUMP_PERCENT`, and signs a replacement at the same relayer nonce. It never sends nonce `n + 1` while a transaction at `n` might still land, which is exactly the gap this whole design exists to avoid. + +Before any new work is created, a restarted process reconciles every incomplete journal entry against the EntryPoint. If the chain sequence is ahead of the journal, the operation was consumed. If they match, the lane is reserved and the operation is recovered or requeued. If the chain is behind the journal, the state is inconsistent and the process stops rather than guessing. + +### What fails alone and what fails together + +The EntryPoint treats an account execution failure as a per-operation result: it emits a failed `UserOperationEvent`, charges gas, advances that lane, and continues with the next operation. A validation failure (bad signature, stale sequence, insufficient prefund) is different: it reverts the whole `handleOps` transaction and nothing in it is consumed. + + + +| Situation | Lane sequence | Other lanes in the bundle | Recovery | +| --- | --- | --- | --- | +| Operation executes successfully | Consumed | Continue | None | +| Operation execution reverts | Consumed | Continue | Retry the intent on the lane's next sequence if you want | +| Operation validation fails | Not consumed | Entire `handleOps` reverts | Fix the cause and resubmit | +| Signed operation never reached a mined bundle | Not consumed | Independent lanes stay valid | The journal requeues it | +| Outer transaction times out or is evicted | Unknown until reconciled | Bundle stays intact | Rebroadcast and fee-bump at the same relayer nonce | +| Outer transaction mines and reverts | Not consumed | Nothing in that bundle executes | Relayer nonce is consumed; operations can be requeued | +| Process exits after journaling | Determined at restart | No new work starts first | Reconcile receipts, lane sequences, and the relayer nonce | + +Every bundle is simulated with `eth_estimateGas` before broadcast, so validation failures are normally caught before any gas is spent. `MAX_OPS_PER_BUNDLE` sets the size of the shared validation domain; keep it small when isolation matters more than amortized cost. + +## The moat: why this design is hard to beat + +Faster hardware and better RPC routing improve every submission strategy equally. The advantage here is structural: it changes what a single funded address is allowed to do, and it does so with the same custody surface as a single wallet. + +| | One hot wallet | Fleet of hot wallets | Public ERC-4337 bundler | Parallel nonce lanes | +| --- | --- | --- | --- | --- | +| In-flight operations from your capital | 1 | N (one per wallet) | At most 4 per unstaked sender | `LANE_POOL_SIZE` (up to 4096 in this implementation) | +| Balances and approvals | One address | Split N ways, approved N times | One address | One address | +| Keys that can move inventory | 1 | N | 1 | 1 (relayers hold gas only) | +| A dropped submission strands the account | Yes | Yes, per wallet | No, but inclusion depends on a third party | No: lanes are independent, and relayers replace at the same nonce | +| Depends on pending-nonce visibility | Yes | Yes | No | No: sequences are read once at startup, then tracked locally | +| Failure blast radius | Everything behind the gap | Everything behind the gap, per wallet | One operation | One lane for execution, one bundle for validation | + +The pillars behind that table: + +1. **One balance, many lanes.** Capital, approvals, and venue state stay on one address. Width comes from lanes, not from splitting inventory. A new lane costs nothing to open and is valid at sequence `0` immediately. +2. **A custody boundary you can reason about.** The only key that can create a valid operation is the trader's. Relayer keys can be rotated, replaced, or lost with a bounded cost measured in gas. +3. **A frozen trading nonce.** After the one-time delegation, the trading EOA's EVM nonce never moves on the hot path. Nothing an RPC drops or a producer rejects can strand the account. +4. **No third-party bundler and no per-sender cap.** The private mempool removes the ERC-7562 `SAME_SENDER_MEMPOOL_COUNT` limit and the dependency on someone else's inclusion policy, while keeping every EntryPoint check that protects funds. +5. **Crash-safe by construction.** Signed operations and signed outer transactions are journaled before broadcast. Replacement reuses the relayer nonce. Restart reconciliation is deterministic and refuses to create duplicate work when the state is ambiguous. +6. **Built for how Sei actually behaves.** The hot signing path performs no nonce reads, so the absence of a pending view costs nothing. Sei's roughly 400 ms blocks and instant finality mean each relayer's receipt wait is short, so `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` is a meaningful per-block submission width rather than a theoretical one. + + + Submission parallelism is not execution parallelism. Independent lanes remove ordering between submissions. They do not make conflicting storage writes execute in parallel. A venue that funnels every order through one hot storage slot still serializes on that slot. See [Optimizing for parallelization](/evm/best-practices/optimizing-for-parallelization) and the [parallelization engine](/learn/parallelization-engine). + + +## Tutorial: run the reference implementation + +The walkthrough below deploys the demo contracts, delegates a throwaway trading account, funds a relayer pool, and submits 24 operations across 32 lanes. One order is deliberately given an unfillable limit price so you can watch a revert land without disturbing its neighbours. + +### Prerequisites + +- Git with submodule support +- [Foundry](https://getfoundry.sh/) with `forge`, `anvil`, and `cast` +- Node.js 26 and npm (the repository pins `>=26 <27`) +- For the Atlantic-2 path: a fresh throwaway key funded from the [Sei faucet](/learn/faucet) + + + + Clone with submodules so the pinned `account-abstraction` and OpenZeppelin dependencies come along: + + ```bash + git clone --recurse-submodules https://github.com/sei-protocol/sei-parallel-nonce-hft.git + cd sei-parallel-nonce-hft + ``` + + For an existing clone, run `git submodule update --init --recursive`. + + Install the Node dependencies and run every local check: + + ```bash + cd app + npm ci + npm run check + cd .. + + forge fmt --check + forge test -vv + ``` + + The Foundry suite runs against the real EntryPoint v0.8 bytecode from the pinned dependency, placed at the canonical address inside the test VM. It proves that different lanes can land in any order, that an execution revert affects only its own lane, that an operation that is never submitted blocks nothing, that a gap on one shared lane reproduces sequential blocking, that one validation failure reverts the whole bundle, that lane `0` is rejected, and that a 50-lane bundle fits in one outer transaction. + + + + Start with a local Prague fork. It carries the deployed EntryPoint bytecode from Atlantic-2 but spends only Anvil funds. + + + + EIP-7702 requires a Prague-capable node. Keep this terminal running: + + ```bash + anvil \ + --fork-url https://evm-rpc-testnet.sei-apis.com \ + --chain-id 1328 \ + --hardfork prague + ``` + + The repository compiles its contracts for Cancun because they use no Prague-only opcodes, but the local node must run Prague to accept the type-4 delegation transaction. + + Create the configuration and point it at the fork: + + ```bash + cp .env.example .env + ``` + + ```dotenv title=".env" + SEI_CHAIN_ID=1328 + SEI_RPC_URL=http://127.0.0.1:8545 + + RELAYER_COUNT=4 + RELAYER_START_INDEX=1 + ``` + + Set `TRADER_PRIVATE_KEY` to account `0`'s private key from the Anvil startup output, and `RELAYER_MNEMONIC` to the mnemonic printed by that same Anvil process. Starting relayers at index `1` keeps the trader and relayer identities distinct; the application rejects overlapping identities. + + Deploy the demo contracts from the repository root using Anvil's unlocked account: + + ```bash + forge script script/Deploy.s.sol:Deploy \ + --rpc-url http://127.0.0.1:8545 \ + --broadcast \ + --unlocked \ + --sender 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + ``` + + + Generate fresh credentials. Never reuse a key or mnemonic that has appeared in a tutorial, a test framework, or a shared document: + + ```bash + cast wallet new # trader key + cast wallet new-mnemonic # relayer mnemonic + ``` + + Fund the trader address from the [Sei faucet](/learn/faucet), then configure: + + ```bash + cp .env.example .env + ``` + + ```dotenv title=".env" + SEI_CHAIN_ID=1328 + SEI_RPC_URL=https://evm-rpc-testnet.sei-apis.com + + TRADER_PRIVATE_KEY=0x... + RELAYER_MNEMONIC="word word word ..." + RELAYER_COUNT=4 + RELAYER_START_INDEX=0 + ``` + + Deploy with a funded key. The deployer can be the throwaway trader, but it does not have to be: + + ```bash + export DEPLOYER_PRIVATE_KEY=0x... + + forge script script/Deploy.s.sol:Deploy \ + --rpc-url https://evm-rpc-testnet.sei-apis.com \ + --broadcast \ + --private-key "$DEPLOYER_PRIVATE_KEY" + + unset DEPLOYER_PRIVATE_KEY + ``` + + + Never use Anvil, Hardhat, tutorial, or shared test mnemonics on Atlantic-2 or Pacific-1. Their addresses and keys are public, and some are already delegated to sweeper code, so a successful funding transaction can still leave a zero balance. + + + + + Copy the two printed addresses into `.env`: + + ```dotenv title=".env" + LANE_ACCOUNT_IMPL=0x... + VENUE=0x... + ``` + + + Mutating commands refuse to write to a remote Pacific-1 RPC unless `ALLOW_MAINNET=1` is set explicitly. That guard prevents an accidental `SEI_CHAIN_ID=1329` run. It does not guard the separate Forge deployment command, and it does not make the demo production-ready. + + + + + `status` is read-only. Run it before anything that writes: + + ```bash + cd app + npm run status + ``` + + It prints the chain ID, whether code exists at the EntryPoint address `0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108`, the trader's current delegation, balances, the trader's EntryPoint deposit, each relayer's confirmed nonce and gas balance, the lane sequences, and the venue state. + + + + ```bash + npm run delegate + ``` + + This sends one type-4 transaction from the trader with an authorization for `LANE_ACCOUNT_IMPL`. It spends the trader's EVM nonce once. The command is idempotent: if the account already delegates to the configured implementation, it does nothing. If the account delegates to something else, it tells you before replacing it. + + + + ```bash + npm run fund + npm run status + ``` + + `fund` uses ordinary trader transactions to top each relayer up to `RELAYER_FUNDING` SEI and to bring the trader's `EntryPoint.depositTo` balance up to `ENTRYPOINT_DEPOSIT` SEI. The deposit is what the EntryPoint draws prefund from when it validates each operation. On a public network you can instead send SEI to relayer `0` and run `npm run dispense`, which waits for the balance and splits it across the pool. Use one bootstrapping path or the other, not both. + + + + ```bash + npm run spray + ``` + + One `spray` process performs the complete run and exits. It runs the preflight, estimates the delegated call's gas, reads each lane's sequence once, signs 24 operations concurrently with no nonce RPCs, journals them, bundles them, drains the bundles through the relayer pool, and prints a report. By default, order `2` receives a limit price below the mark, so its operation reverts during execution while the neighbouring lanes continue. + + + +### Read the report + +The output below is illustrative; your addresses, blocks, and timings will differ. + +```text +=== preflight === +chain Sei Testnet (1328) +entryPoint 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 (… bytes) +trader 0xf39F…2266 +delegated to 0x5FbD…0aa3 +trader nonce 3 <- watch this, it must not move +journal 0 pending op(s) +relayer 0x7099…79C8 0.5 SEI +… + +=== build === +lane pool 32 lanes, 32 idle +signed 24 new ops in 52ms, without nonce RPCs +hash check local digest matches EntryPoint.getUserOpHash + +=== submit === +24 ops -> bundles of <=4 -> 4 relayers + + mined lanes [32,31,30,29] block 187213 gas 612345 + mined lanes [28,27,26,25] block 187213 gas 598120 + … + +=== per-order outcome === + # lane seq exec filled land# block note + 0 32 0 ok yes 1 187213 + 1 31 0 ok yes 2 187213 + 2 30 0 reverted no 0 187213 sabotaged (limit under mark) + 3 29 0 ok yes 3 187213 + … + +=== summary === +ops submitted 24 +ops landed 24 + executed ok 23 + reverted on chain 1 (each consumed only its own lane) +bundles 6 across 2 block(s) + pending recovery 0 + failed safely 0 +distinct lanes 24 +relayers used 4 +throughput 13.0 landed ops/sec + +trader EVM nonce 3 -> 3 UNCHANGED +``` + +What to look for: + +- **`trader nonce` before and `trader EVM nonce` after** must be identical. The trading key never entered a queue. +- **`exec`** is read from the `UserOperationEvent` in each receipt. `reverted` means the operation landed, consumed its lane sequence, and failed inside the venue call. `not mined` would mean the outer transaction never landed and nothing was consumed. +- **`land#`** is the venue's global landing counter. It shows the order in which operations actually executed, which has nothing to do with lane number. Lane acquisition is last-in, first-out, so a fresh 32-lane pool starts at lane `32`; lane numbers carry no priority. +- **`hash check`** confirms that the locally computed EIP-712 digest matches `EntryPoint.getUserOpHash` for the first operation, so client-side hashing matches consensus. + +If a bundle is reported as `PENDING` or `FAILED`, the command exits non-zero and leaves the journal intact. Run `npm run spray` again once the RPC can answer receipt and nonce queries; the process recovers or replaces at the same relayer nonce before it creates new work. Do not delete the journal and do not send the relayer's next nonce by hand. + +### Optional: real swaps on Atlantic-2 + +The repository includes a real-venue path that routes tiny native SEI and native USDC swaps through the documented DragonSwap V1 deployment on Atlantic-2. It is hard-blocked on every other chain. Get testnet USDC for the trader from the [Circle faucet](https://faucet.circle.com/), then: + +```bash +cd app +npm run swap:setup +npm run swap:spray +``` + +`swap:setup` uses ordinary trader transactions to approve a limited amount of USDC and to create and seed the WSEI/USDC pair if the factory has no live pair. `swap:spray` alternates SEI to USDC and USDC to SEI swaps through independent lanes and reports outcomes from EntryPoint events rather than one RPC read per swap. The same knobs apply: + +```bash +ORDERS=3000 \ +LANE_POOL_SIZE=3000 \ +MAX_OPS_PER_BUNDLE=4 \ +SABOTAGE_INDEX=2 \ +npm run swap:spray +``` + +The trader must hold enough of both assets for every input-side swap to execute regardless of landing order. Set `SABOTAGE_INDEX=-1` when you want to measure maximum throughput. + +### Optional: see the baseline you are escaping + +```bash +npm run baseline +``` + +Using a gas-only relayer key so nothing of value is at risk, `baseline` sends nonce `n + 1` while deliberately skipping `n`, reports whether the RPC path rejected it or stranded it, then fills the gap. Compare that with a `spray` run where 24 operations from one account are mutually independent. + +## Tuning + +Three knobs shape a run. They interact, so change one at a time and measure. + + + + One lane holds at most one in-flight operation, so `LANE_POOL_SIZE` is the hard ceiling on unresolved UserOperations in a process. Larger pools permit more concurrently unresolved intents, add startup `getNonce` reads (batched with bounded concurrency so a public RPC does not rate-limit you), and increase the recovery state you must understand after a failure. `ORDERS` must not exceed `LANE_POOL_SIZE`; the application rejects that configuration instead of silently submitting fewer orders. + + + `MAX_OPS_PER_BUNDLE` trades amortized outer-transaction overhead for the size of the shared validation failure domain. A width of `1` gives maximum isolation and the highest overhead. Execution reverts stay per-operation at any width. The relayer caps signed transaction gas below the live block gas limit and rejects a bundle whose estimate cannot fit. + + As a reference point, on Atlantic-2 on September 3, 2026, the real-swap path sustained 77 operations per bundle; 78 hit the 12,500,000 block-gas ceiling and failed safely during simulation. Width 76 produced the best observed submission rate for that call shape, 47.7 landed swaps per second. Treat these as measurements for one call shape and one network state, not as protocol limits. + + + Each relayer has one sequential outer transaction stream. Under favorable admission and inclusion conditions, the immediate submission width is roughly `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` per block. That is a planning heuristic, not a throughput guarantee: RPC latency, block limits, state contention, gas, and producer policy still apply. Public RPC endpoints have [rate limits](/evm/networks); use a [dedicated provider](/learn/rpc-providers) or your own node for anything beyond a demo. + + + +### Configuration reference + +The application always loads `.env` from the repository root. The variables you are most likely to change: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `SEI_CHAIN_ID` | `1328` | `1328` (Atlantic-2) or `1329` (Pacific-1) | +| `SEI_RPC_URL` | viem chain default | HTTP endpoint; its reported chain ID must match before any write | +| `ALLOW_MAINNET` | `0` | Must be `1` for writes to a remote Pacific-1 RPC | +| `TRADER_PRIVATE_KEY` | required | The account that signs every UserOperation | +| `RELAYER_MNEMONIC` | required | Fresh BIP-39 mnemonic used only for gas-paying relayers | +| `RELAYER_COUNT` | `4` | Relayer workers, `1..256` | +| `RELAYER_START_INDEX` | `0` | First derivation index; offset it if the mnemonic also holds the trader | +| `RELAYER_FUNDING` | `0.5` | Target SEI balance per relayer for `fund` | +| `ENTRYPOINT_DEPOSIT` | `1` | Target trader deposit in the EntryPoint for `fund` | +| `LANE_ACCOUNT_IMPL` | unset | Deployed `LaneAccount`; required by `delegate` and `spray` | +| `VENUE` | unset | Deployed venue target; required by `spray` | +| `ORDERS` | `24` | Operations per run; must not exceed `LANE_POOL_SIZE` | +| `LANE_POOL_SIZE` | `32` | `1..4096` lanes and the in-flight ceiling | +| `MAX_OPS_PER_BUNDLE` | `4` | `1..LANE_POOL_SIZE` operations sharing one validation domain | +| `SABOTAGE_INDEX` | `2` | Order that receives an unfillable limit price; `-1` disables | +| `CALL_GAS_LIMIT` | `500000` | Execution-gas floor; the live estimate plus headroom can raise it | +| `BUNDLE_RECEIPT_TIMEOUT_MS` | `12000` | Receipt wait before same-nonce replacement | +| `BUNDLE_MAX_ATTEMPTS` | `3` | Same-nonce attempts per process invocation | +| `REPLACEMENT_FEE_BUMP_PERCENT` | `25` | Fee increase per replacement, `10..1000` | +| `OPERATION_JOURNAL_PATH` | `app/.state/pending-ops.json` | Durable signed-operation journal | + +The [repository README](https://github.com/sei-protocol/sei-parallel-nonce-hft#configuration) documents the full set, including the real-swap variables. + +## Adapting it to your venue + +The demo's `MockPerpVenue` is a stand-in that reverts on slippage so a failure is observable. Swapping it for a real venue is a matter of encoding a different call. `LaneAccount.execute(target, value, data)` forwards any call, and the venue sees the trading EOA as `msg.sender`: + +```ts +import { encodeFunctionData } from 'viem'; +import { buildOp, signUserOp, userOpHash } from './userop.js'; + +// Any call your venue accepts from the trading EOA. +const data = encodeFunctionData({ + abi: venueAbi, + functionName: 'placeOrder', + args: [market, side, size, limitPrice], +}); + +// One lane per in-flight intent. The pool tracks sequences locally. +const slot = lanePool.acquire(); +if (!slot) throw new Error('lane pool exhausted; raise LANE_POOL_SIZE'); + +const unsigned = buildOp({ + sender: trader.address, // the delegated EOA + lane: slot.lane, + seq: slot.seq, + target: VENUE, + value: 0n, // native SEI to forward, if the call needs it + data, + verificationGasLimit, + callGasLimit, + preVerificationGas, + maxFeePerGas, + maxPriorityFeePerGas, +}); + +const op = await signUserOp(trader, unsigned, chain.id, ENTRY_POINT); +mempool.add({ + op, + hash: userOpHash(op, chain.id, ENTRY_POINT), + lane: slot.lane, + seq: slot.seq, + orderId, + label: '', +}); +``` + +The real-swap path in `app/src/swap-spray.ts` is a complete example of this against a live router, including forwarding native SEI as `value`. + +Check these before you trust a new target: + +- **`msg.sender` and `tx.origin`.** At the venue, `msg.sender` is the trading EOA and `tx.origin` is the gas-paying relayer. Contracts that require `tx.origin == msg.sender` are incompatible. Audit each router, approval path, callback, reentrancy assumption, and authorization rule. +- **Gas on Sei.** Storage writes cost materially more than on Ethereum. The application estimates the delegated call live and raises `CALL_GAS_LIMIT` when the estimate plus headroom is higher, so do not copy Ethereum-sized static limits. See [Gas and fees](/evm/evm-parity/gas-and-fees). +- **Storage contention at the venue.** Lanes remove submission ordering, not execution conflicts. Analyze which storage slots your calls touch; see [Optimizing for parallelization](/evm/best-practices/optimizing-for-parallelization). +- **Lane policy.** One lane per in-flight intent is the simplest correct policy. If some calls must stay ordered relative to each other, put them on one lane (or on `ADMIN_LANE`) rather than falling back to lane `0`. + +### Before you adapt this for real trading + +The repository is explicit about what it leaves out. Before this design touches inventory, add at least: + +- audited account and integration contracts; +- hardware-backed or remote signing; +- a real strategy and risk engine with an idempotent intent model; +- durable, replicated queue and reconciliation storage; +- metrics, tracing, alerting, and structured logs; +- controlled deployment and delegation procedures; +- RPC redundancy and chain-specific fee policy; +- graceful shutdown and operator runbooks; and +- load, fault-injection, and live-chain recovery testing. + + + **Delegation is powerful.** EIP-7702 changes the code that executes at the trader's address. Before delegating, verify the implementation source and deployed address, verify the target chain, inspect any existing delegation, and use a throwaway account for this demo. `spray` refuses to run if the current designator does not exactly match `LANE_ACCOUNT_IMPL`. + + +Treat `.env`, `app/.state/`, signed raw transactions, and RPC URLs containing credentials as sensitive. The journal does not contain private keys, but it contains signed UserOperations and replayable raw transactions until their nonces are consumed. Clone the repository for a teammate and create fresh keys; do not copy a working directory. + +## Troubleshooting + + + + Check both `SEI_CHAIN_ID` and `SEI_RPC_URL`. For a local fork, pass `--chain-id 1328` to Anvil. The configured chain ID is part of the EIP-712 signature domain and cannot be guessed safely. + + + The RPC has no code at `0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108`. Confirm the chain and the fork source before deploying anything. The preflight checks for code presence, not byte-for-byte identity; verify canonical addresses independently before a real deployment. + + + Run `npm run status` and compare `delegated to` with `LANE_ACCOUNT_IMPL`. Do not blindly replace an unexpected designator. Confirm the account, chain, and implementation first, then run `npm run delegate` deliberately. + + + Run `npm run fund`, or send SEI to relayer `0` and run `npm run dispense`. + + + Common validation causes: `AA24` is an invalid trader signature or the wrong EIP-712 chain or domain; `AA25` is a stale or incorrect lane sequence; an insufficient EntryPoint prefund; or delegation to the wrong account implementation. Run `npm run status` and resolve the cause before widening bundles or retrying. + + + The bundle no longer fits the current block gas limit. Nothing in a bundle that fails simulation is broadcast or consumed. Rerun with a smaller `MAX_OPS_PER_BUNDLE`; the durable queue is repacked at the smaller width. + + + Only one lane-based process may use a trader at a time, even when `spray` and `swap:spray` use different journals. Stop the other process. A lock whose recorded PID is no longer alive is removed automatically on the next run. + + + Do not delete the journal and do not send the relayer's next nonce manually. Run `npm run spray` again once the RPC can answer receipt and nonce queries. If the application reports partial lane consumption or a state it cannot reconcile, stop and inspect the EntryPoint events, every attempted transaction hash, the relayer's confirmed nonce, and each lane sequence. + + + +## Resources + + + + Source, tests, and the full configuration reference. + + + The delegation mechanism that keeps the funded address. + + + UserOperations, the EntryPoint, and two-dimensional nonces. + + + The public-mempool rules the private queue sidesteps. + + + Why Sei has no pending state and one confirmation is final. + + + Type-4 support and authorization list requirements on Sei. + + diff --git a/evm/index.mdx b/evm/index.mdx index dbbf473..7bd5371 100644 --- a/evm/index.mdx +++ b/evm/index.mdx @@ -73,6 +73,9 @@ keywords: ["sei evm", "ethereum virtual machine", "web3 development", "blockchai Native USDC integration guide for seamless stablecoin transactions. + + Submit many independent orders from one funded address with EIP-7702 and ERC-4337 nonce lanes. + ## RPC Endpoints diff --git a/snippets/parallel-nonce-diagrams.jsx b/snippets/parallel-nonce-diagrams.jsx new file mode 100644 index 0000000..4af7307 --- /dev/null +++ b/snippets/parallel-nonce-diagrams.jsx @@ -0,0 +1,372 @@ +export const PnNonceQueue = () => { + const ink = 'currentColor'; + const ok = '#10b981'; + const bad = '#ef4444'; + const warn = '#f59e0b'; + const nonces = [ + { n: 5, s: 'landed', c: ok }, + { n: 6, s: 'dropped, never landed', c: bad }, + { n: 7, s: 'stranded', c: warn }, + { n: 8, s: 'stranded', c: warn } + ]; + return ( +
+
+ + + + + + + + One account, one queue + {nonces.map((it, i) => { + const x = 40 + i * 190; + return ( + + + nonce {it.n} + {it.s} + {i < nonces.length - 1 ? : null} + + ); + })} + nonces 7 and 8 are signed and valid, but nothing at or above 7 can execute until 6 is filled or replaced + on Sei there is no pending view to inspect: eth_getTransactionCount(addr, "pending") equals "latest", and a nonce gap is rejected with a bad nonce error + + + + The usual workaround: more hot wallets + {['A', 'B', 'C', 'N'].map((w, i) => { + const x = 40 + i * 190; + return ( + + + hot wallet {w} + own balance, own approvals + own key that can move inventory + + ); + })} + throughput scales with wallets, and so do fragmented balances, duplicated approvals, and keys that hold inventory + +
+
A transaction that lands and reverts consumes its nonce and blocks nothing. A transaction that never lands leaves a gap, and every later nonce waits behind it. Splitting inventory across hot wallets buys width at the cost of custody surface.
+
+ ); +}; + +export const PnLaneNonce = () => { + const ink = 'currentColor'; + const accent = 'var(--sei-maroon-50)'; + const ok = '#10b981'; + const bad = '#ef4444'; + const chip = (x, y, label, state, key) => { + const c = state === 'ok' ? ok : state === 'bad' ? bad : ink; + return ( + + + {label} + + ); + }; + return ( +
+
+ + + + + + + + One uint256 nonce, two dimensions + bit 255 + bit 64 + bit 0 + + uint192 key: the lane + any value from 1 to 2^192 - 1, chosen by the trader + + uint64 sequence + kept by the EntryPoint per lane + nonce = (lane << 64) | sequence + + + The EntryPoint keeps one sequence counter per lane + + lane 0 + + rejected by LaneAccount: SDKs default to key 0, and a book on key 0 is one queue again + + lane 1 + {chip(120, 210, 'seq 0', 'ok', 'l1s0')} + {chip(172, 210, 'seq 1', 'ok', 'l1s1')} + {chip(224, 210, 'seq 2', 'ok', 'l1s2')} + {chip(276, 210, 'next 3', 'next', 'l1n')} + three operations landed in order + + lane 2 + {chip(120, 246, 'seq 0', 'ok', 'l2s0')} + {chip(172, 246, 'seq 1', 'bad', 'l2s1')} + {chip(224, 246, 'next 2', 'next', 'l2n')} + seq 1 executed and reverted: it still consumed its sequence + + lane 3 + {chip(120, 282, 'next 0', 'next', 'l3n')} + a fresh lane starts at 0 and is valid immediately + + + no ordering between lanes + whatever happens on one lane, + the others stay valid + + + strictly ordered within a lane, left to right + +
+
EntryPoint v0.8 stores a UserOperation nonce as a 192-bit key plus a 64-bit sequence and tracks one sequence per key. Operations on different keys never queue behind each other; operations on the same key stay sequential. The repository calls a key a lane and forbids lane 0.
+
+ ); +}; + +export const PnDelegation = () => { + const ink = 'currentColor'; + const accent = 'var(--sei-maroon-50)'; + const gold = 'var(--sei-gold-25)'; + const box = { fill: ink, fillOpacity: 0.05, stroke: ink, strokeOpacity: 0.35, strokeWidth: 1 }; + return ( +
+
+ + + + + + + + + + + + Trading EOA, same address + same native SEI balance + same token balances and venue approvals + same private key signs every UserOperation + EVM nonce: spent once by the type-4 transaction, + then frozen on the trading path + + code slot (delegation designator) + 0xef0100 || LaneAccount address + + + EntryPoint v0.8 singleton + 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 + checks signature, lane sequence, and prefund + + handleOps calls + execute(target, value, data) + + + LaneAccount implementation + inherits the audited Simple7702Account + adds one rule: lane 0 is rejected + ADMIN_LANE = max uint192 for ordered admin calls + + runs this code + + + + venue contract + msg.sender = the trading EOA, not a proxy + tx.origin = the gas-paying relayer + +
+
One type-4 transaction writes the designator into the EOA's code slot. The address, balances, and approvals do not move. When the EntryPoint calls the account, the EVM runs LaneAccount's code in the EOA's context, so the venue sees the funded address as msg.sender.
+
+ ); +}; + +export const PnPipeline = () => { + const ink = 'currentColor'; + const accent = 'var(--sei-maroon-50)'; + const gold = 'var(--sei-gold-25)'; + const box = { fill: ink, fillOpacity: 0.05, stroke: ink, strokeOpacity: 0.35, strokeWidth: 1 }; + const relayers = ['relayer 0', 'relayer 1', 'relayer 2', 'relayer N']; + return ( +
+
+ + + + + + + + + holds inventory, signs intents + holds gas only, cannot forge an operation + + + Trading EOA + delegated to LaneAccount + signs one UserOperation per lane + EIP-712 digest, no nonce RPCs + EVM nonce does not move + + + + Private mempool + in-process FIFO of signed ops + one op per lane per bundle + no ERC-7562 4-op sender cap + bundles ≤ MAX_OPS_PER_BUNDLE + + {relayers.map((r, i) => { + const y = 40 + i * 72; + return ( + + + + {r} + own sequential nonce, 1 tx in flight + + + ); + })} + the sequential constraint moved here, away from inventory + + + EntryPoint v0.8 + handleOps(ops[], relayer) + validates every op, then executes + each one, refunds gas to the relayer + + + + LaneAccount.execute → venue + msg.sender is the trading EOA + +
+
UserOperations are not transactions. Gas-only relayers wrap each bundle in an EntryPoint.handleOps transaction and pay for it. Each relayer still has one sequential EVM nonce, but a relayer key can only lose its own gas: it cannot create a valid operation without the trader's signature.
+
+ ); +}; + +export const PnBundleLifecycle = () => { + const ink = 'currentColor'; + const accent = 'var(--sei-maroon-50)'; + const ok = '#10b981'; + const warn = '#f59e0b'; + const bad = '#ef4444'; + const steps = [ + { t: 'take bundle', s: 'from the mempool' }, + { t: 'simulate', s: 'eth_estimateGas' }, + { t: 'sign at nonce n', s: 'EIP-1559 outer tx' }, + { t: 'journal', s: 'bytes hit disk first' }, + { t: 'broadcast', s: 'sendRawTransaction' }, + { t: 'wait for receipt', s: 'until the timeout' }, + { t: 'settle lanes', s: 'then nonce n + 1' } + ]; + return ( +
+
+ + + + + + + + + + + + + + One relayer worker, one bundle at a time + {steps.map((st, i) => { + const x = 30 + i * 124; + const last = i === steps.length - 1; + return ( + + + {st.t} + {st.s} + {i < steps.length - 1 ? : null} + + ); + })} + + + + simulation fails (AA24, AA25, prefund, AA95) + nothing is broadcast, no lane sequence is consumed, + and the relayer nonce is not spent + + + + no receipt before the timeout + check every earlier attempt for a receipt, bump fees by REPLACEMENT_FEE_BUMP_PERCENT, + sign a replacement at the same nonce n, journal it, rebroadcast (up to BUNDLE_MAX_ATTEMPTS) + + + the worker never sends nonce n + 1 while a transaction at n might still land + on restart, the last exact raw transaction is rebroadcast first, then reconciled against lane sequences and the relayer's confirmed nonce + +
+
Write-ahead ordering is deliberate: the signed outer transaction is journaled before it reaches the network, so a crash between signing and broadcast cannot lose or duplicate work. Replacement always reuses the relayer nonce, which is why a stuck bundle cannot create a nonce gap.
+
+ ); +}; + +export const PnFailureIsolation = () => { + const ink = 'currentColor'; + const ok = '#10b981'; + const bad = '#ef4444'; + const warn = '#f59e0b'; + const lane = (x, y, label, sub, c, dashed, key) => ( + + + {label} + {sub} + + ); + return ( +
+
+ + Execution revert: isolated to its lane + + one handleOps transaction, one block + {lane(46, 70, 'lane 29', 'filled', ok, false, 'a29')} + {lane(142, 70, 'lane 30', 'reverted', bad, false, 'a30')} + {lane(238, 70, 'lane 31', 'filled', ok, false, 'a31')} + {lane(334, 70, 'lane 32', 'filled', ok, false, 'a32')} + all four sequences advance; lane 30 emits a failed UserOperationEvent and pays gas + the EntryPoint treats an account call failure as a per-operation result + and continues with the next operation in the bundle + + + + Validation failure: the whole bundle + + handleOps reverts, nothing in it is consumed + {lane(486, 70, 'lane 29', 'not consumed', ink, true, 'b29')} + {lane(582, 70, 'lane 30', 'AA25 stale seq', bad, false, 'b30')} + {lane(678, 70, 'lane 31', 'not consumed', ink, true, 'b31')} + {lane(774, 70, 'lane 32', 'not consumed', ink, true, 'b32')} + bad signature, stale sequence, or thin prefund fails before any execution + every bundle is simulated before broadcast, so this is normally caught + for free; MAX_OPS_PER_BUNDLE sets the size of this failure domain + + + + Never submitted: nothing consumed + a signed operation whose outer transaction was dropped, evicted, or never broadcast leaves its lane sequence untouched; neighbouring lanes stay valid and the journal requeues it + +
+
Three different failures, three different blast radii. An execution revert costs one lane sequence and nothing else. A validation failure costs the outer transaction attempt but no sequences. A dropped bundle costs nothing on chain, which is exactly the case that strands a sequential account.
+
+ ); +}; From a8c6eca4c7e9378e5736d65d53b00f2491b60e27 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Thu, 10 Sep 2026 14:49:03 +0200 Subject: [PATCH 2/9] docs(evm): rename diagram components and marker ids to satisfy typos check The spelling check treats the "Pn"/"pn" prefix as a misspelling of "on". Rename the snippet components to a ParallelNonce* prefix, matching the Giga* diagram convention, and give SVG marker ids descriptive names. Co-authored-by: Cursor --- evm/hft/parallel-nonce-submission.mdx | 14 +++---- snippets/parallel-nonce-diagrams.jsx | 56 +++++++++++++-------------- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/evm/hft/parallel-nonce-submission.mdx b/evm/hft/parallel-nonce-submission.mdx index 55493b2..21a8544 100644 --- a/evm/hft/parallel-nonce-submission.mdx +++ b/evm/hft/parallel-nonce-submission.mdx @@ -5,7 +5,7 @@ description: 'Submit many independent orders from one funded Sei address without keywords: ['sei', 'hft', 'high-frequency trading', 'nonce', 'parallel nonce', 'nonce lanes', 'eip-7702', 'erc-4337', 'entrypoint', 'userop', 'relayer', 'bundler', 'trading infrastructure', 'market making'] --- -import { PnNonceQueue, PnLaneNonce, PnDelegation, PnPipeline, PnBundleLifecycle, PnFailureIsolation } from '/snippets/parallel-nonce-diagrams.jsx'; +import { ParallelNonceQueue, ParallelNonceLanes, ParallelNonceDelegation, ParallelNoncePipeline, ParallelNonceBundleLifecycle, ParallelNonceFailureIsolation } from '/snippets/parallel-nonce-diagrams.jsx'; One funded account on Sei EVM has one sequential nonce. That is the right model for a wallet and the wrong model for an order flow: a single transaction that never lands freezes every transaction signed after it. The usual fix is a fleet of hot wallets, which multiplies balances, approvals, and keys. @@ -26,7 +26,7 @@ An EVM account's transaction nonces are strictly sequential. If nonce `n` has no The second case is the submission bottleneck. It covers transactions that are dropped, underpriced, rejected at admission, lost before broadcast, or stranded after a process crash. - + Two properties of Sei make this sharper than on Ethereum: @@ -56,7 +56,7 @@ nonce = (uint192 key << 64) | uint64 sequence The EntryPoint keeps one `sequence` counter for each `key`. The repository calls a key a **lane**. - + Four rules follow from that layout: @@ -77,7 +77,7 @@ An EIP-7702 authorization writes a delegation designator into the EOA's code slo The address does not change. Its native balance, token balances, venue state, and approvals stay attached to the same account. When the EntryPoint calls the account, the EVM runs `LaneAccount`'s code in the EOA's context, so `LaneAccount.execute` reaches the venue with the trading EOA as `msg.sender`. - + `LaneAccount` inherits the audited `Simple7702Account` from the [account-abstraction](https://github.com/eth-infinitism/account-abstraction) repository and adds a single policy check: @@ -103,7 +103,7 @@ The trader spends one ordinary EVM nonce to install the delegation, using a type UserOperations are not transactions. Something still has to wrap them in `EntryPoint.handleOps` transactions and pay for them. The repository uses a pool of gas-only relayers fed by a private in-process mempool. - + The sequential constraint has not disappeared; it has moved. Each relayer still has one sequential EVM nonce and keeps one outer transaction in flight at a time. What changes is the custody boundary. Relayers hold native SEI for gas and nothing else. A compromised relayer key can lose its own gas balance or rebroadcast operations the trader already signed. It cannot create a new operation, because every UserOperation carries the trader's EIP-712 signature over the EntryPoint's `PackedUserOperation` digest. @@ -113,7 +113,7 @@ The private mempool matters for a different reason. The canonical ERC-4337 mempo Each relayer runs one asynchronous worker. The worker takes a bundle of up to `MAX_OPS_PER_BUNDLE` operations (never two from the same lane), and then works through a fixed sequence. - + Two details carry most of the safety: @@ -126,7 +126,7 @@ Before any new work is created, a restarted process reconciles every incomplete The EntryPoint treats an account execution failure as a per-operation result: it emits a failed `UserOperationEvent`, charges gas, advances that lane, and continues with the next operation. A validation failure (bad signature, stale sequence, insufficient prefund) is different: it reverts the whole `handleOps` transaction and nothing in it is consumed. - + | Situation | Lane sequence | Other lanes in the bundle | Recovery | | --- | --- | --- | --- | diff --git a/snippets/parallel-nonce-diagrams.jsx b/snippets/parallel-nonce-diagrams.jsx index 4af7307..4a11d64 100644 --- a/snippets/parallel-nonce-diagrams.jsx +++ b/snippets/parallel-nonce-diagrams.jsx @@ -1,4 +1,4 @@ -export const PnNonceQueue = () => { +export const ParallelNonceQueue = () => { const ink = 'currentColor'; const ok = '#10b981'; const bad = '#ef4444'; @@ -14,7 +14,7 @@ export const PnNonceQueue = () => {
- + @@ -27,7 +27,7 @@ export const PnNonceQueue = () => { nonce {it.n} {it.s} - {i < nonces.length - 1 ? : null} + {i < nonces.length - 1 ? : null} ); })} @@ -56,7 +56,7 @@ export const PnNonceQueue = () => { ); }; -export const PnLaneNonce = () => { +export const ParallelNonceLanes = () => { const ink = 'currentColor'; const accent = 'var(--sei-maroon-50)'; const ok = '#10b981'; @@ -75,7 +75,7 @@ export const PnLaneNonce = () => {
- + @@ -116,12 +116,12 @@ export const PnLaneNonce = () => { {chip(120, 282, 'next 0', 'next', 'l3n')} a fresh lane starts at 0 and is valid immediately - + no ordering between lanes whatever happens on one lane, the others stay valid - + strictly ordered within a lane, left to right
@@ -130,7 +130,7 @@ export const PnLaneNonce = () => { ); }; -export const PnDelegation = () => { +export const ParallelNonceDelegation = () => { const ink = 'currentColor'; const accent = 'var(--sei-maroon-50)'; const gold = 'var(--sei-gold-25)'; @@ -140,10 +140,10 @@ export const PnDelegation = () => {
- + - + @@ -163,7 +163,7 @@ export const PnDelegation = () => { EntryPoint v0.8 singleton 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 checks signature, lane sequence, and prefund - + handleOps calls execute(target, value, data) @@ -172,10 +172,10 @@ export const PnDelegation = () => { inherits the audited Simple7702Account adds one rule: lane 0 is rejected ADMIN_LANE = max uint192 for ordered admin calls - + runs this code - + venue contract msg.sender = the trading EOA, not a proxy @@ -187,7 +187,7 @@ export const PnDelegation = () => { ); }; -export const PnPipeline = () => { +export const ParallelNoncePipeline = () => { const ink = 'currentColor'; const accent = 'var(--sei-maroon-50)'; const gold = 'var(--sei-gold-25)'; @@ -198,7 +198,7 @@ export const PnPipeline = () => {
- + @@ -213,7 +213,7 @@ export const PnPipeline = () => { signs one UserOperation per lane EIP-712 digest, no nonce RPCs EVM nonce does not move - + Private mempool @@ -226,11 +226,11 @@ export const PnPipeline = () => { const y = 40 + i * 72; return ( - + {r} own sequential nonce, 1 tx in flight - + ); })} @@ -241,7 +241,7 @@ export const PnPipeline = () => { handleOps(ops[], relayer) validates every op, then executes each one, refunds gas to the relayer - + LaneAccount.execute → venue @@ -253,7 +253,7 @@ export const PnPipeline = () => { ); }; -export const PnBundleLifecycle = () => { +export const ParallelNonceBundleLifecycle = () => { const ink = 'currentColor'; const accent = 'var(--sei-maroon-50)'; const ok = '#10b981'; @@ -273,13 +273,13 @@ export const PnBundleLifecycle = () => {
- + - + - + @@ -293,23 +293,23 @@ export const PnBundleLifecycle = () => { {st.t} {st.s} - {i < steps.length - 1 ? : null} + {i < steps.length - 1 ? : null} ); })} - + simulation fails (AA24, AA25, prefund, AA95) nothing is broadcast, no lane sequence is consumed, and the relayer nonce is not spent - + no receipt before the timeout check every earlier attempt for a receipt, bump fees by REPLACEMENT_FEE_BUMP_PERCENT, sign a replacement at the same nonce n, journal it, rebroadcast (up to BUNDLE_MAX_ATTEMPTS) - + the worker never sends nonce n + 1 while a transaction at n might still land on restart, the last exact raw transaction is rebroadcast first, then reconciled against lane sequences and the relayer's confirmed nonce @@ -320,7 +320,7 @@ export const PnBundleLifecycle = () => { ); }; -export const PnFailureIsolation = () => { +export const ParallelNonceFailureIsolation = () => { const ink = 'currentColor'; const ok = '#10b981'; const bad = '#ef4444'; From 6d25545239ea68178dea6199601d0c2439f85c87 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Fri, 11 Sep 2026 12:51:09 +0200 Subject: [PATCH 3/9] docs(evm): retitle around nonce lanes and correct the pending-nonce claim The page promised parallelism it does not deliver and narrowed its audience to one industry. "Parallel nonce" names a mechanism that does not exist: EVM nonces are never parallel, and the actual feature is ERC-4337's two-dimensional nonce. Inside Sei's docs "parallel" also reads as parallel execution, which is why the page previously needed a warning to undo the association its own title created. Retitle to "Nonce Lanes", lead with submission concurrency, and name the other audiences this serves (keepers, liquidators, oracle updaters, payout batchers) rather than HFT alone. Correct the load-bearing factual claim. The page asserted that `eth_getTransactionCount(address, "pending")` returns the same value as `"latest"`. evm/reference documents the opposite: the pending tag returns EvmNextPendingNonce from the mempool. The supportable claim is the one in evm/evm-parity/finality, that a pending nonce differing from the confirmed nonce is unreliable. The design argument is unaffected, since a hot path that reads no nonces is the right answer either way, so this is a rewording rather than a restructure. Fix the comparison table, which overstated the result. It put `LANE_POOL_SIZE` "up to 4096" against a wallet fleet's N, comparing signed unresolved operations against concurrent transactions. Split those into separate rows, state that broadcast width is `RELAYER_COUNT`, and say plainly that a wallet fleet can match the width -- what it cannot match is doing so from one balance, one approval set, and one key. Also: - Move the page from a new top-level "High-Frequency Trading" group into the existing "Ecosystem Tutorials" group, next to the other standalone tutorials. A dedicated top-level group for one page put a niche topic ahead of the core SDK group in reading order - Retitle "The moat" to "Why this is hard to replicate"; AGENTS.md asks for no investment framing - Attribute the mempool to Autobahn under Giga mode, matching node/technical-reference and evm/reference - Drop the hard-coded 400 ms figure in favor of a pointer to Twin-Turbo and Giga, since sei-giga-specs warns against mixing current and Giga numbers - Cross-link Pimlico and Thirdweb EIP-7702, and say when to prefer a hosted bundler; those were the only other 4337/7702 pages and went unlinked - Track the repository's renames: sei-nonce-lanes, `npm run submit`, `REVERT_ORDER_INDEX`, and Node 22 rather than 26 - Fix "neighbours"/"neighbouring"; the repo is American English in prose and _typos.toml sets no locale, so CI would not have caught it - Rename the snippet to nonce-lane-diagrams.jsx with matching components mint broken-links and mint validate both pass. Vale reports no errors; the one remaining Sei.Headings warning fires on any heading containing "ERC-" because ERC is absent from the exceptions list in .github/styles/Sei/Headings.yml, which also affects existing pages. Co-authored-by: Cursor --- docs.json | 7 +- evm/index.mdx | 4 +- ...l-nonce-submission.mdx => nonce-lanes.mdx} | 220 ++++++++++-------- ...e-diagrams.jsx => nonce-lane-diagrams.jsx} | 12 +- 4 files changed, 132 insertions(+), 111 deletions(-) rename evm/{hft/parallel-nonce-submission.mdx => nonce-lanes.mdx} (59%) rename snippets/{parallel-nonce-diagrams.jsx => nonce-lane-diagrams.jsx} (99%) diff --git a/docs.json b/docs.json index 97b6247..76be393 100644 --- a/docs.json +++ b/docs.json @@ -203,12 +203,6 @@ } ] }, - { - "group": "High-Frequency Trading", - "pages": [ - "evm/hft/parallel-nonce-submission" - ] - }, { "group": "sei-js Library", "pages": [ @@ -272,6 +266,7 @@ }, "evm/usdc-on-sei", "evm/dune", + "evm/nonce-lanes", { "group": "Oracles", "pages": [ diff --git a/evm/index.mdx b/evm/index.mdx index 7bd5371..07aa872 100644 --- a/evm/index.mdx +++ b/evm/index.mdx @@ -73,8 +73,8 @@ keywords: ["sei evm", "ethereum virtual machine", "web3 development", "blockchai Native USDC integration guide for seamless stablecoin transactions. - - Submit many independent orders from one funded address with EIP-7702 and ERC-4337 nonce lanes. + + Keep many independent transactions in flight from one funded address with EIP-7702 and ERC-4337 nonce lanes. diff --git a/evm/hft/parallel-nonce-submission.mdx b/evm/nonce-lanes.mdx similarity index 59% rename from evm/hft/parallel-nonce-submission.mdx rename to evm/nonce-lanes.mdx index 21a8544..b38c9dc 100644 --- a/evm/hft/parallel-nonce-submission.mdx +++ b/evm/nonce-lanes.mdx @@ -1,23 +1,29 @@ --- -title: 'Parallel Nonce Submission for High-Frequency Trading' -sidebarTitle: 'Parallel Nonce Submission' -description: 'Submit many independent orders from one funded Sei address without a sequential nonce queue. This guide explains how EIP-7702 delegation, ERC-4337 nonce lanes, a private mempool, and gas-only relayers fit together, and walks you through running the reference implementation.' -keywords: ['sei', 'hft', 'high-frequency trading', 'nonce', 'parallel nonce', 'nonce lanes', 'eip-7702', 'erc-4337', 'entrypoint', 'userop', 'relayer', 'bundler', 'trading infrastructure', 'market making'] +title: 'Nonce Lanes: Concurrent Submission from One Account' +sidebarTitle: 'Nonce Lanes' +description: 'Keep many independent transactions in flight from one funded Sei address without a sequential nonce queue. This guide explains how ERC-4337 nonce lanes, EIP-7702 delegation, a private mempool, and gas-only relayers fit together, and walks you through running the reference implementation.' +keywords: ['sei', 'nonce', 'nonce lanes', 'two-dimensional nonce', 'eip-7702', 'erc-4337', 'entrypoint', 'userop', 'relayer', 'bundler', 'account abstraction', 'throughput', 'market making', 'liquidation bot'] --- -import { ParallelNonceQueue, ParallelNonceLanes, ParallelNonceDelegation, ParallelNoncePipeline, ParallelNonceBundleLifecycle, ParallelNonceFailureIsolation } from '/snippets/parallel-nonce-diagrams.jsx'; +import { SequentialNonceQueue, NonceLanes, NonceLaneDelegation, NonceLanePipeline, NonceLaneBundleLifecycle, NonceLaneFailureIsolation } from '/snippets/nonce-lane-diagrams.jsx'; -One funded account on Sei EVM has one sequential nonce. That is the right model for a wallet and the wrong model for an order flow: a single transaction that never lands freezes every transaction signed after it. The usual fix is a fleet of hot wallets, which multiplies balances, approvals, and keys. +One funded account on Sei EVM has one sequential nonce. That is the right model for a wallet and the wrong model for a stream of independent work: a single transaction that never lands freezes every transaction signed after it. The usual fix is a fleet of hot wallets, which multiplies balances, approvals, and keys. -[sei-parallel-nonce-hft](https://github.com/sei-protocol/sei-parallel-nonce-hft) is a reference implementation that removes the queue instead. One funded externally owned account (EOA) keeps its address, balance, and approvals, and submits many mutually independent operations at the same time. This page explains how the pieces fit together, why the resulting design is hard to match with simpler setups, and how to run it against a local fork and Atlantic-2. +[sei-nonce-lanes](https://github.com/sei-protocol/sei-nonce-lanes) is a reference implementation that removes the queue instead. One funded externally owned account (EOA) keeps its address, balance, and approvals, and keeps many mutually independent operations in flight at the same time. + +The pattern applies whenever one funded address needs to do many things that do not depend on each other: market making and order flow, liquidation and keeper bots, oracle updates, payout and claim batching, or game and backend transactions. - The repository is a runnable engineering demonstration, not a production trading service. It uses a mock venue, plaintext development keys in `.env`, an in-process queue, and console output. Read [Before you adapt this for real trading](#before-you-adapt-this-for-real-trading) before pointing it at inventory. + The repository is a runnable engineering demonstration, not a production service. It uses a mock venue, plaintext development keys in `.env`, an in-process queue, and console output. Read [Before you adapt this](#before-you-adapt-this) before pointing it at real funds. + + This page is about submission concurrency. If you want a hosted bundler, a paymaster, and gas sponsorship for consumer wallets, start with [Pimlico](/evm/wallet-integrations/pimlico) or [Thirdweb EIP-7702](/evm/wallet-integrations/thirdweb-7702) instead. Nonce lanes solve the opposite problem: throughput from one address you control, with no third party in the submission path. + + ## Why one account is one queue -An EVM account's transaction nonces are strictly sequential. If nonce `n` has not executed, nonce `n + 1` cannot execute first. Two failures that look similar behave very differently: +An EVM account's transaction nonces are strictly sequential. If nonce `n` has not executed, nonce `n + 1` cannot execute first. Two failures that look similar behave differently: | Event | Blocks later nonces? | | --- | --- | @@ -26,18 +32,22 @@ An EVM account's transaction nonces are strictly sequential. If nonce `n` has no The second case is the submission bottleneck. It covers transactions that are dropped, underpriced, rejected at admission, lost before broadcast, or stranded after a process crash. - + Two properties of Sei make this sharper than on Ethereum: -- **No pending state.** `eth_getTransactionCount(address, "pending")` returns the same value as `"latest"`, so you cannot reconstruct an in-flight nonce queue from the RPC. See [Finality and block tags](/evm/evm-parity/finality#pending-state). -- **Strict nonce admission.** Under Giga, the producer mempool admits EVM transactions in per-sender nonce order and rejects a gap with a `bad nonce` error instead of holding it. See [Giga mode behavior](/node/technical-reference#giga-mode-behavior-and-per-block-limits). The repository's `npm run baseline` command probes the behavior of whatever RPC path you configure rather than assuming every endpoint behaves identically. +- **Strict nonce admission.** Under Giga, the Autobahn producer mempool admits EVM transactions in per-sender nonce order and rejects a gap with a `bad nonce` error instead of holding it for later. See [Giga mode behavior](/node/technical-reference#giga-mode-behavior-and-per-block-limits). The repository's `npm run baseline` command probes the behavior of whatever RPC path you configure rather than assuming every endpoint behaves identically. +- **No dependable pending view.** Sei does not expose Ethereum-style pending state, and [Finality and block tags](/evm/evm-parity/finality#pending-state) tells you not to rely on a pending nonce differing from the confirmed nonce. `txpool_content` is also [truncated and collapses the pending/queued distinction](/evm/reference). So even where a node answers a pending-nonce query, it is not a foundation to rebuild an in-flight queue on. -Splitting inventory across hot wallets raises throughput, but every wallet is another balance to rebalance, another set of approvals to maintain, and another key that can move funds. + + Do not design around a pending nonce on Sei. `eth_getTransactionCount(address, "pending")` is [documented](/evm/reference) as returning `EvmNextPendingNonce` from the mempool, so it is not an alias for `"latest"` — but the finality guidance marks the pending view as unreliable, and what you get back varies by node and by whether it runs Giga. The design below sidesteps the question entirely: its hot path reads no nonces at all. + + +Splitting work across hot wallets raises throughput, but every wallet is another balance to rebalance, another set of approvals to maintain, and another key that can move funds. ## How it works -The design combines four ingredients. Each one solves a specific part of the problem, and none of them is sufficient alone. +The design combines four ingredients. Each one solves a specific part of the problem, and none is sufficient alone. | Ingredient | What it contributes | | --- | --- | @@ -54,9 +64,9 @@ EntryPoint v0.8 stores a UserOperation nonce as a 192-bit key plus a 64-bit sequ nonce = (uint192 key << 64) | uint64 sequence ``` -The EntryPoint keeps one `sequence` counter for each `key`. The repository calls a key a **lane**. +The EntryPoint keeps one `sequence` counter for each `key`. The specification calls this a two-dimensional nonce; the repository calls a key a **lane**. - + Four rules follow from that layout: @@ -65,7 +75,7 @@ Four rules follow from that layout: - An operation that executes and reverts still consumes its lane sequence. - An operation that never reaches a successful `handleOps` transaction consumes nothing. -`LaneAccount` rejects lane `0`. Most SDKs pick key `0` when you do not pass one, and a book that lands entirely on key `0` is a single queue again. Rejecting it turns a silent fallback into a loud validation failure. `ADMIN_LANE` (the maximum `uint192`) is reserved for integrations that need one explicitly ordered lane for administrative calls. +`LaneAccount` rejects lane `0`. Most SDKs pick key `0` when you do not pass one, and work that lands entirely on key `0` is a single queue again. Rejecting it turns a silent fallback into a loud validation failure. `ADMIN_LANE` (the maximum `uint192`) is reserved for integrations that need one explicitly ordered lane for administrative calls. ### EIP-7702 keeps the funded address @@ -75,9 +85,9 @@ An EIP-7702 authorization writes a delegation designator into the EOA's code slo 0xef0100 || ``` -The address does not change. Its native balance, token balances, venue state, and approvals stay attached to the same account. When the EntryPoint calls the account, the EVM runs `LaneAccount`'s code in the EOA's context, so `LaneAccount.execute` reaches the venue with the trading EOA as `msg.sender`. +The address does not change. Its native balance, token balances, protocol state, and approvals stay attached to the same account. When the EntryPoint calls the account, the EVM runs `LaneAccount`'s code in the EOA's context, so `LaneAccount.execute` reaches the target with the funded EOA as `msg.sender`. - + `LaneAccount` inherits the audited `Simple7702Account` from the [account-abstraction](https://github.com/eth-infinitism/account-abstraction) repository and adds a single policy check: @@ -93,32 +103,32 @@ contract LaneAccount is Simple7702Account { } ``` -The trader spends one ordinary EVM nonce to install the delegation, using a type-4 transaction. Sei requires a non-empty authorization list on type-4 transactions; see [Transaction types](/evm/evm-parity/transaction-types#set-code-eip-7702-auth-list-requirement). After that, the trading path signs UserOperations only and the trader's EVM nonce stops moving. +The account spends one ordinary EVM nonce to install the delegation, using a type-4 transaction. Sei requires a non-empty authorization list on type-4 transactions; see [Transaction types](/evm/evm-parity/transaction-types#set-code-eip-7702-auth-list-requirement). After that, the submission path signs UserOperations only and the funded account's EVM nonce stops moving. - EIP-7702 alone does not create parallel nonces. It preserves the account. ERC-4337 supplies the independent nonce model. You need both. + EIP-7702 alone does not create independent nonces. It preserves the account. ERC-4337 supplies the independent nonce model. You need both. ### Gas-only relayers carry what is left of the queue UserOperations are not transactions. Something still has to wrap them in `EntryPoint.handleOps` transactions and pay for them. The repository uses a pool of gas-only relayers fed by a private in-process mempool. - + -The sequential constraint has not disappeared; it has moved. Each relayer still has one sequential EVM nonce and keeps one outer transaction in flight at a time. What changes is the custody boundary. Relayers hold native SEI for gas and nothing else. A compromised relayer key can lose its own gas balance or rebroadcast operations the trader already signed. It cannot create a new operation, because every UserOperation carries the trader's EIP-712 signature over the EntryPoint's `PackedUserOperation` digest. +The sequential constraint has not disappeared; it has moved. Each relayer still has one sequential EVM nonce and keeps one outer transaction in flight at a time. What changes is the custody boundary. Relayers hold native SEI for gas and nothing else. A compromised relayer key can lose its own gas balance or rebroadcast operations the funded account already signed. It cannot create a new operation, because every UserOperation carries an EIP-712 signature from the funded account over the EntryPoint's `PackedUserOperation` digest. -The private mempool matters for a different reason. The canonical ERC-4337 mempool enforces the [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562) validation rules, including `SAME_SENDER_MEMPOOL_COUNT = 4` for an unstaked sender. Four pending operations is a wallet number, not a trading number. Those rules exist so competing bundlers can safely pack strangers' operations together. Here every operation comes from one account you control, so there are no strangers to defend against. The EntryPoint still enforces everything that protects funds: the signature, per-lane nonce uniqueness, and prefund solvency. +The private mempool matters for a different reason. The canonical ERC-4337 mempool enforces the [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562) validation rules, including `SAME_SENDER_MEMPOOL_COUNT = 4` for an unstaked sender. Four pending operations is a wallet number, not a throughput number. Those rules exist so competing bundlers can safely pack strangers' operations together. Here every operation comes from one account you control, so there are no strangers to defend against. The EntryPoint still enforces everything that protects funds: the signature, per-lane nonce uniqueness, and prefund solvency. ### One bundle, start to finish Each relayer runs one asynchronous worker. The worker takes a bundle of up to `MAX_OPS_PER_BUNDLE` operations (never two from the same lane), and then works through a fixed sequence. - + Two details carry most of the safety: - **Write-ahead ordering.** The signed outer transaction is written to the journal before it is broadcast. A crash between signing and sending cannot lose or duplicate work; on restart, the exact raw bytes are rebroadcast first. -- **Same-nonce replacement.** If no receipt arrives within `BUNDLE_RECEIPT_TIMEOUT_MS`, the worker checks earlier attempts for a receipt, bumps fees by `REPLACEMENT_FEE_BUMP_PERCENT`, and signs a replacement at the same relayer nonce. It never sends nonce `n + 1` while a transaction at `n` might still land, which is exactly the gap this whole design exists to avoid. +- **Same-nonce replacement.** If no receipt arrives within `BUNDLE_RECEIPT_TIMEOUT_MS`, the worker checks earlier attempts for a receipt, bumps fees by `REPLACEMENT_FEE_BUMP_PERCENT`, and signs a replacement at the same relayer nonce. It never sends nonce `n + 1` while a transaction at `n` might still land, which is exactly the gap this design exists to avoid. Before any new work is created, a restarted process reconciles every incomplete journal entry against the EntryPoint. If the chain sequence is ahead of the journal, the operation was consumed. If they match, the lane is reserved and the operation is recovered or requeued. If the chain is behind the journal, the state is inconsistent and the process stops rather than guessing. @@ -126,7 +136,7 @@ Before any new work is created, a restarted process reconciles every incomplete The EntryPoint treats an account execution failure as a per-operation result: it emits a failed `UserOperationEvent`, charges gas, advances that lane, and continues with the next operation. A validation failure (bad signature, stale sequence, insufficient prefund) is different: it reverts the whole `handleOps` transaction and nothing in it is consumed. - + | Situation | Lane sequence | Other lanes in the bundle | Recovery | | --- | --- | --- | --- | @@ -140,41 +150,51 @@ The EntryPoint treats an account execution failure as a per-operation result: it Every bundle is simulated with `eth_estimateGas` before broadcast, so validation failures are normally caught before any gas is spent. `MAX_OPS_PER_BUNDLE` sets the size of the shared validation domain; keep it small when isolation matters more than amortized cost. -## The moat: why this design is hard to beat +## Why this is hard to replicate + +Faster hardware and better RPC routing improve every submission strategy equally. What changes here is structural: it changes what a single funded address is allowed to do, and it does so with the same custody surface as a single wallet. -Faster hardware and better RPC routing improve every submission strategy equally. The advantage here is structural: it changes what a single funded address is allowed to do, and it does so with the same custody surface as a single wallet. +The honest comparison is against a fleet of hot wallets, because that is what most teams actually run. A fleet can match the width. What it cannot match is doing so from one balance, one approval set, and one key. -| | One hot wallet | Fleet of hot wallets | Public ERC-4337 bundler | Parallel nonce lanes | +| | One hot wallet | Fleet of N hot wallets | Public ERC-4337 bundler | Nonce lanes | | --- | --- | --- | --- | --- | -| In-flight operations from your capital | 1 | N (one per wallet) | At most 4 per unstaked sender | `LANE_POOL_SIZE` (up to 4096 in this implementation) | -| Balances and approvals | One address | Split N ways, approved N times | One address | One address | -| Keys that can move inventory | 1 | N | 1 | 1 (relayers hold gas only) | -| A dropped submission strands the account | Yes | Yes, per wallet | No, but inclusion depends on a third party | No: lanes are independent, and relayers replace at the same nonce | -| Depends on pending-nonce visibility | Yes | Yes | No | No: sequences are read once at startup, then tracked locally | +| Unresolved operations from one balance | 1 | 1 per wallet | At most 4 per unstaked sender | `LANE_POOL_SIZE` (32 by default, 4096 maximum) | +| Outer transactions in flight | 1 | N | The bundler's policy, not yours | `RELAYER_COUNT` | +| Balances and approvals to maintain | One address | N addresses, approved N times | One address | One address | +| Keys that can move funds | 1 | N | 1 | 1; relayers hold gas only | +| Depends on a pending-nonce view | Yes | Yes | No | No: sequences are read once at startup, then tracked locally | | Failure blast radius | Everything behind the gap | Everything behind the gap, per wallet | One operation | One lane for execution, one bundle for validation | + + Read the first two rows together. `LANE_POOL_SIZE` caps how many operations can be signed and unresolved at once; `RELAYER_COUNT` caps how many outer transactions are actually broadcast at once. Lanes give you a large pool of independent intents, not a large number of simultaneous transactions. The rough per-block submission width is `RELAYER_COUNT × MAX_OPS_PER_BUNDLE`. + + The pillars behind that table: -1. **One balance, many lanes.** Capital, approvals, and venue state stay on one address. Width comes from lanes, not from splitting inventory. A new lane costs nothing to open and is valid at sequence `0` immediately. -2. **A custody boundary you can reason about.** The only key that can create a valid operation is the trader's. Relayer keys can be rotated, replaced, or lost with a bounded cost measured in gas. -3. **A frozen trading nonce.** After the one-time delegation, the trading EOA's EVM nonce never moves on the hot path. Nothing an RPC drops or a producer rejects can strand the account. +1. **One balance, many lanes.** Capital, approvals, and protocol state stay on one address. Width comes from lanes, not from splitting funds. A new lane costs nothing to open and is valid at sequence `0` immediately. +2. **A custody boundary you can reason about.** The only key that can create a valid operation is the funded account's. Relayer keys can be rotated, replaced, or lost with a bounded cost measured in gas. +3. **A frozen nonce on the hot path.** After the one-time delegation, the funded account's EVM nonce never moves while submitting. Nothing an RPC drops or a producer rejects can strand the account. 4. **No third-party bundler and no per-sender cap.** The private mempool removes the ERC-7562 `SAME_SENDER_MEMPOOL_COUNT` limit and the dependency on someone else's inclusion policy, while keeping every EntryPoint check that protects funds. 5. **Crash-safe by construction.** Signed operations and signed outer transactions are journaled before broadcast. Replacement reuses the relayer nonce. Restart reconciliation is deterministic and refuses to create duplicate work when the state is ambiguous. -6. **Built for how Sei actually behaves.** The hot signing path performs no nonce reads, so the absence of a pending view costs nothing. Sei's roughly 400 ms blocks and instant finality mean each relayer's receipt wait is short, so `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` is a meaningful per-block submission width rather than a theoretical one. +6. **Built for how Sei behaves.** The hot signing path performs no nonce reads, so an unreliable pending view costs nothing. Fast blocks and instant finality keep each relayer's receipt wait short, which is what makes `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` a meaningful per-block width rather than a theoretical one. - Submission parallelism is not execution parallelism. Independent lanes remove ordering between submissions. They do not make conflicting storage writes execute in parallel. A venue that funnels every order through one hot storage slot still serializes on that slot. See [Optimizing for parallelization](/evm/best-practices/optimizing-for-parallelization) and the [parallelization engine](/learn/parallelization-engine). + Submission concurrency is not execution parallelism. Independent lanes remove ordering between submissions. They do not make conflicting storage writes execute in parallel. A contract that funnels everything through one hot storage slot still serializes on that slot. See [Optimizing for parallelization](/evm/best-practices/optimizing-for-parallelization) and the [parallelization engine](/learn/parallelization-engine). + + Block time and gas limits differ between today's [Twin-Turbo consensus](/learn/twin-turbo-consensus) and [Sei Giga](/learn/sei-giga). Size a relayer pool against the network you are actually submitting to, and measure rather than assuming. + + ## Tutorial: run the reference implementation -The walkthrough below deploys the demo contracts, delegates a throwaway trading account, funds a relayer pool, and submits 24 operations across 32 lanes. One order is deliberately given an unfillable limit price so you can watch a revert land without disturbing its neighbours. +The walkthrough below deploys the demo contracts, delegates a throwaway account, funds a relayer pool, and submits 24 operations across 32 lanes. One order is deliberately given an unfillable limit price so you can watch a revert land without disturbing its neighbors. ### Prerequisites - Git with submodule support - [Foundry](https://getfoundry.sh/) with `forge`, `anvil`, and `cast` -- Node.js 26 and npm (the repository pins `>=26 <27`) +- Node.js 22 or newer, and npm - For the Atlantic-2 path: a fresh throwaway key funded from the [Sei faucet](/learn/faucet) @@ -182,8 +202,8 @@ The walkthrough below deploys the demo contracts, delegates a throwaway trading Clone with submodules so the pinned `account-abstraction` and OpenZeppelin dependencies come along: ```bash - git clone --recurse-submodules https://github.com/sei-protocol/sei-parallel-nonce-hft.git - cd sei-parallel-nonce-hft + git clone --recurse-submodules https://github.com/sei-protocol/sei-nonce-lanes.git + cd sei-nonce-lanes ``` For an existing clone, run `git submodule update --init --recursive`. @@ -233,7 +253,7 @@ The walkthrough below deploys the demo contracts, delegates a throwaway trading RELAYER_START_INDEX=1 ``` - Set `TRADER_PRIVATE_KEY` to account `0`'s private key from the Anvil startup output, and `RELAYER_MNEMONIC` to the mnemonic printed by that same Anvil process. Starting relayers at index `1` keeps the trader and relayer identities distinct; the application rejects overlapping identities. + Set `TRADER_PRIVATE_KEY` to account `0`'s private key from the Anvil startup output, and `RELAYER_MNEMONIC` to the mnemonic printed by that same Anvil process. Starting relayers at index `1` keeps the funded account and relayer identities distinct; the application rejects overlapping identities. Deploy the demo contracts from the repository root using Anvil's unlocked account: @@ -249,11 +269,11 @@ The walkthrough below deploys the demo contracts, delegates a throwaway trading Generate fresh credentials. Never reuse a key or mnemonic that has appeared in a tutorial, a test framework, or a shared document: ```bash - cast wallet new # trader key + cast wallet new # funded account key cast wallet new-mnemonic # relayer mnemonic ``` - Fund the trader address from the [Sei faucet](/learn/faucet), then configure: + Fund the address from the [Sei faucet](/learn/faucet), then configure: ```bash cp .env.example .env @@ -269,7 +289,7 @@ The walkthrough below deploys the demo contracts, delegates a throwaway trading RELAYER_START_INDEX=0 ``` - Deploy with a funded key. The deployer can be the throwaway trader, but it does not have to be: + Deploy with a funded key. The deployer can be the throwaway account, but it does not have to be: ```bash export DEPLOYER_PRIVATE_KEY=0x... @@ -308,15 +328,15 @@ The walkthrough below deploys the demo contracts, delegates a throwaway trading npm run status ``` - It prints the chain ID, whether code exists at the EntryPoint address `0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108`, the trader's current delegation, balances, the trader's EntryPoint deposit, each relayer's confirmed nonce and gas balance, the lane sequences, and the venue state. + It prints the chain ID, whether code exists at the EntryPoint address `0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108`, the account's current delegation, balances, its EntryPoint deposit, each relayer's confirmed nonce and gas balance, the lane sequences, and the venue state. - + ```bash npm run delegate ``` - This sends one type-4 transaction from the trader with an authorization for `LANE_ACCOUNT_IMPL`. It spends the trader's EVM nonce once. The command is idempotent: if the account already delegates to the configured implementation, it does nothing. If the account delegates to something else, it tells you before replacing it. + This sends one type-4 transaction with an authorization for `LANE_ACCOUNT_IMPL`. It spends the account's EVM nonce once. The command is idempotent: if the account already delegates to the configured implementation, it does nothing. If the account delegates to something else, it tells you before replacing it. @@ -325,15 +345,15 @@ The walkthrough below deploys the demo contracts, delegates a throwaway trading npm run status ``` - `fund` uses ordinary trader transactions to top each relayer up to `RELAYER_FUNDING` SEI and to bring the trader's `EntryPoint.depositTo` balance up to `ENTRYPOINT_DEPOSIT` SEI. The deposit is what the EntryPoint draws prefund from when it validates each operation. On a public network you can instead send SEI to relayer `0` and run `npm run dispense`, which waits for the balance and splits it across the pool. Use one bootstrapping path or the other, not both. + `fund` uses ordinary transactions to top each relayer up to `RELAYER_FUNDING` SEI and to bring the account's `EntryPoint.depositTo` balance up to `ENTRYPOINT_DEPOSIT` SEI. The deposit is what the EntryPoint draws prefund from when it validates each operation. On a public network you can instead send SEI to relayer `0` and run `npm run dispense`, which waits for the balance and splits it across the pool. Use one bootstrapping path or the other, not both. ```bash - npm run spray + npm run submit ``` - One `spray` process performs the complete run and exits. It runs the preflight, estimates the delegated call's gas, reads each lane's sequence once, signs 24 operations concurrently with no nonce RPCs, journals them, bundles them, drains the bundles through the relayer pool, and prints a report. By default, order `2` receives a limit price below the mark, so its operation reverts during execution while the neighbouring lanes continue. + One `submit` process performs the complete run and exits. It runs the preflight, estimates the delegated call's gas, reads each lane's sequence once, signs 24 operations concurrently with no nonce RPCs, journals them, bundles them, drains the bundles through the relayer pool, and prints a report. By default, order `2` receives a limit price below the mark, so its operation reverts during execution while the neighboring lanes continue. @@ -345,9 +365,9 @@ The output below is illustrative; your addresses, blocks, and timings will diffe === preflight === chain Sei Testnet (1328) entryPoint 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 (… bytes) -trader 0xf39F…2266 +account 0xf39F…2266 delegated to 0x5FbD…0aa3 -trader nonce 3 <- watch this, it must not move +account nonce 3 <- watch this, it must not move journal 0 pending op(s) relayer 0x7099…79C8 0.5 SEI … @@ -368,7 +388,7 @@ hash check local digest matches EntryPoint.getUserOpHash # lane seq exec filled land# block note 0 32 0 ok yes 1 187213 1 31 0 ok yes 2 187213 - 2 30 0 reverted no 0 187213 sabotaged (limit under mark) + 2 30 0 reverted no 0 187213 expected revert (limit under mark) 3 29 0 ok yes 3 187213 … @@ -384,39 +404,39 @@ distinct lanes 24 relayers used 4 throughput 13.0 landed ops/sec -trader EVM nonce 3 -> 3 UNCHANGED +account EVM nonce 3 -> 3 UNCHANGED ``` What to look for: -- **`trader nonce` before and `trader EVM nonce` after** must be identical. The trading key never entered a queue. -- **`exec`** is read from the `UserOperationEvent` in each receipt. `reverted` means the operation landed, consumed its lane sequence, and failed inside the venue call. `not mined` would mean the outer transaction never landed and nothing was consumed. +- **`account nonce` before and `account EVM nonce` after** must be identical. The funded key never entered a queue. +- **`exec`** is read from the `UserOperationEvent` in each receipt. `reverted` means the operation landed, consumed its lane sequence, and failed inside the call. `not mined` would mean the outer transaction never landed and nothing was consumed. - **`land#`** is the venue's global landing counter. It shows the order in which operations actually executed, which has nothing to do with lane number. Lane acquisition is last-in, first-out, so a fresh 32-lane pool starts at lane `32`; lane numbers carry no priority. - **`hash check`** confirms that the locally computed EIP-712 digest matches `EntryPoint.getUserOpHash` for the first operation, so client-side hashing matches consensus. -If a bundle is reported as `PENDING` or `FAILED`, the command exits non-zero and leaves the journal intact. Run `npm run spray` again once the RPC can answer receipt and nonce queries; the process recovers or replaces at the same relayer nonce before it creates new work. Do not delete the journal and do not send the relayer's next nonce by hand. +If a bundle is reported as `PENDING` or `FAILED`, the command exits non-zero and leaves the journal intact. Run `npm run submit` again once the RPC can answer receipt and nonce queries; the process recovers or replaces at the same relayer nonce before it creates new work. Do not delete the journal and do not send the relayer's next nonce by hand. ### Optional: real swaps on Atlantic-2 -The repository includes a real-venue path that routes tiny native SEI and native USDC swaps through the documented DragonSwap V1 deployment on Atlantic-2. It is hard-blocked on every other chain. Get testnet USDC for the trader from the [Circle faucet](https://faucet.circle.com/), then: +The repository includes a real-target path that routes tiny native SEI and native USDC swaps through the documented DragonSwap V1 deployment on Atlantic-2. It is hard-blocked on every other chain. Get testnet USDC from the [Circle faucet](https://faucet.circle.com/), then: ```bash cd app npm run swap:setup -npm run swap:spray +npm run swap:submit ``` -`swap:setup` uses ordinary trader transactions to approve a limited amount of USDC and to create and seed the WSEI/USDC pair if the factory has no live pair. `swap:spray` alternates SEI to USDC and USDC to SEI swaps through independent lanes and reports outcomes from EntryPoint events rather than one RPC read per swap. The same knobs apply: +`swap:setup` uses ordinary transactions to approve a limited amount of USDC and to create and seed the WSEI/USDC pair if the factory has no live pair. `swap:submit` alternates SEI to USDC and USDC to SEI swaps through independent lanes and reports outcomes from EntryPoint events rather than one RPC read per swap. The same knobs apply: ```bash ORDERS=3000 \ LANE_POOL_SIZE=3000 \ MAX_OPS_PER_BUNDLE=4 \ -SABOTAGE_INDEX=2 \ -npm run swap:spray +REVERT_ORDER_INDEX=2 \ +npm run swap:submit ``` -The trader must hold enough of both assets for every input-side swap to execute regardless of landing order. Set `SABOTAGE_INDEX=-1` when you want to measure maximum throughput. +The account must hold enough of both assets for every input-side swap to execute regardless of landing order. Set `REVERT_ORDER_INDEX=-1` when you want to measure maximum throughput. ### Optional: see the baseline you are escaping @@ -424,7 +444,7 @@ The trader must hold enough of both assets for every input-side swap to execute npm run baseline ``` -Using a gas-only relayer key so nothing of value is at risk, `baseline` sends nonce `n + 1` while deliberately skipping `n`, reports whether the RPC path rejected it or stranded it, then fills the gap. Compare that with a `spray` run where 24 operations from one account are mutually independent. +Using a gas-only relayer key so nothing of value is at risk, `baseline` sends nonce `n + 1` while deliberately skipping `n`, reports whether the RPC path rejected it or stranded it, then fills the gap. Compare that with a `submit` run where 24 operations from one account are mutually independent. ## Tuning @@ -432,14 +452,14 @@ Three knobs shape a run. They interact, so change one at a time and measure. - One lane holds at most one in-flight operation, so `LANE_POOL_SIZE` is the hard ceiling on unresolved UserOperations in a process. Larger pools permit more concurrently unresolved intents, add startup `getNonce` reads (batched with bounded concurrency so a public RPC does not rate-limit you), and increase the recovery state you must understand after a failure. `ORDERS` must not exceed `LANE_POOL_SIZE`; the application rejects that configuration instead of silently submitting fewer orders. + One lane holds at most one in-flight operation, so `LANE_POOL_SIZE` is the hard ceiling on unresolved UserOperations in a process. Larger pools permit more concurrently unresolved intents, add startup `getNonce` reads (batched with bounded concurrency so a public RPC does not rate-limit you), and increase the recovery state you must understand after a failure. `ORDERS` must not exceed `LANE_POOL_SIZE`; the application rejects that configuration instead of silently submitting fewer operations. `MAX_OPS_PER_BUNDLE` trades amortized outer-transaction overhead for the size of the shared validation failure domain. A width of `1` gives maximum isolation and the highest overhead. Execution reverts stay per-operation at any width. The relayer caps signed transaction gas below the live block gas limit and rejects a bundle whose estimate cannot fit. - As a reference point, on Atlantic-2 on September 3, 2026, the real-swap path sustained 77 operations per bundle; 78 hit the 12,500,000 block-gas ceiling and failed safely during simulation. Width 76 produced the best observed submission rate for that call shape, 47.7 landed swaps per second. Treat these as measurements for one call shape and one network state, not as protocol limits. + As a reference point, on Atlantic-2 on September 3, 2026, the real-swap path sustained 77 operations per bundle; 78 hit the 12,500,000 block-gas ceiling and failed safely during simulation. Width 76 produced the best observed submission rate for that call shape, 47.7 landed swaps per second. Treat these as measurements for one call shape on one network at one point in time, not as protocol limits. - + Each relayer has one sequential outer transaction stream. Under favorable admission and inclusion conditions, the immediate submission width is roughly `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` per block. That is a planning heuristic, not a throughput guarantee: RPC latency, block limits, state contention, gas, and producer policy still apply. Public RPC endpoints have [rate limits](/evm/networks); use a [dedicated provider](/learn/rpc-providers) or your own node for anything beyond a demo. @@ -453,37 +473,37 @@ The application always loads `.env` from the repository root. The variables you | `SEI_CHAIN_ID` | `1328` | `1328` (Atlantic-2) or `1329` (Pacific-1) | | `SEI_RPC_URL` | viem chain default | HTTP endpoint; its reported chain ID must match before any write | | `ALLOW_MAINNET` | `0` | Must be `1` for writes to a remote Pacific-1 RPC | -| `TRADER_PRIVATE_KEY` | required | The account that signs every UserOperation | +| `TRADER_PRIVATE_KEY` | required | The funded account that signs every UserOperation | | `RELAYER_MNEMONIC` | required | Fresh BIP-39 mnemonic used only for gas-paying relayers | | `RELAYER_COUNT` | `4` | Relayer workers, `1..256` | -| `RELAYER_START_INDEX` | `0` | First derivation index; offset it if the mnemonic also holds the trader | +| `RELAYER_START_INDEX` | `0` | First derivation index; offset it if the mnemonic also holds the funded account | | `RELAYER_FUNDING` | `0.5` | Target SEI balance per relayer for `fund` | -| `ENTRYPOINT_DEPOSIT` | `1` | Target trader deposit in the EntryPoint for `fund` | -| `LANE_ACCOUNT_IMPL` | unset | Deployed `LaneAccount`; required by `delegate` and `spray` | -| `VENUE` | unset | Deployed venue target; required by `spray` | +| `ENTRYPOINT_DEPOSIT` | `1` | Target deposit in the EntryPoint for `fund` | +| `LANE_ACCOUNT_IMPL` | unset | Deployed `LaneAccount`; required by `delegate` and `submit` | +| `VENUE` | unset | Deployed demo target; required by `submit` | | `ORDERS` | `24` | Operations per run; must not exceed `LANE_POOL_SIZE` | | `LANE_POOL_SIZE` | `32` | `1..4096` lanes and the in-flight ceiling | | `MAX_OPS_PER_BUNDLE` | `4` | `1..LANE_POOL_SIZE` operations sharing one validation domain | -| `SABOTAGE_INDEX` | `2` | Order that receives an unfillable limit price; `-1` disables | +| `REVERT_ORDER_INDEX` | `2` | Order that receives an unfillable limit price; `-1` disables | | `CALL_GAS_LIMIT` | `500000` | Execution-gas floor; the live estimate plus headroom can raise it | | `BUNDLE_RECEIPT_TIMEOUT_MS` | `12000` | Receipt wait before same-nonce replacement | | `BUNDLE_MAX_ATTEMPTS` | `3` | Same-nonce attempts per process invocation | | `REPLACEMENT_FEE_BUMP_PERCENT` | `25` | Fee increase per replacement, `10..1000` | | `OPERATION_JOURNAL_PATH` | `app/.state/pending-ops.json` | Durable signed-operation journal | -The [repository README](https://github.com/sei-protocol/sei-parallel-nonce-hft#configuration) documents the full set, including the real-swap variables. +The [repository README](https://github.com/sei-protocol/sei-nonce-lanes#configuration) documents the full set, including the real-swap variables. -## Adapting it to your venue +## Adapting it to your contract -The demo's `MockPerpVenue` is a stand-in that reverts on slippage so a failure is observable. Swapping it for a real venue is a matter of encoding a different call. `LaneAccount.execute(target, value, data)` forwards any call, and the venue sees the trading EOA as `msg.sender`: +The demo's `MockPerpVenue` is a stand-in that reverts on slippage so a failure is observable. Swapping it for a real target is a matter of encoding a different call. `LaneAccount.execute(target, value, data)` forwards any call, and the target sees the funded EOA as `msg.sender`: ```ts import { encodeFunctionData } from 'viem'; import { buildOp, signUserOp, userOpHash } from './userop.js'; -// Any call your venue accepts from the trading EOA. +// Any call your contract accepts from the funded EOA. const data = encodeFunctionData({ - abi: venueAbi, + abi: targetAbi, functionName: 'placeOrder', args: [market, side, size, limitPrice], }); @@ -493,10 +513,10 @@ const slot = lanePool.acquire(); if (!slot) throw new Error('lane pool exhausted; raise LANE_POOL_SIZE'); const unsigned = buildOp({ - sender: trader.address, // the delegated EOA + sender: account.address, // the delegated EOA lane: slot.lane, seq: slot.seq, - target: VENUE, + target: TARGET, value: 0n, // native SEI to forward, if the call needs it data, verificationGasLimit, @@ -506,7 +526,7 @@ const unsigned = buildOp({ maxPriorityFeePerGas, }); -const op = await signUserOp(trader, unsigned, chain.id, ENTRY_POINT); +const op = await signUserOp(account, unsigned, chain.id, ENTRY_POINT); mempool.add({ op, hash: userOpHash(op, chain.id, ENTRY_POINT), @@ -517,22 +537,22 @@ mempool.add({ }); ``` -The real-swap path in `app/src/swap-spray.ts` is a complete example of this against a live router, including forwarding native SEI as `value`. +The real-swap path in `app/src/swap-submit.ts` is a complete example of this against a live router, including forwarding native SEI as `value`. Check these before you trust a new target: -- **`msg.sender` and `tx.origin`.** At the venue, `msg.sender` is the trading EOA and `tx.origin` is the gas-paying relayer. Contracts that require `tx.origin == msg.sender` are incompatible. Audit each router, approval path, callback, reentrancy assumption, and authorization rule. +- **`msg.sender` and `tx.origin`.** At the target, `msg.sender` is the funded EOA and `tx.origin` is the gas-paying relayer. Contracts that require `tx.origin == msg.sender` are incompatible. Audit each router, approval path, callback, reentrancy assumption, and authorization rule. - **Gas on Sei.** Storage writes cost materially more than on Ethereum. The application estimates the delegated call live and raises `CALL_GAS_LIMIT` when the estimate plus headroom is higher, so do not copy Ethereum-sized static limits. See [Gas and fees](/evm/evm-parity/gas-and-fees). -- **Storage contention at the venue.** Lanes remove submission ordering, not execution conflicts. Analyze which storage slots your calls touch; see [Optimizing for parallelization](/evm/best-practices/optimizing-for-parallelization). +- **Storage contention.** Lanes remove submission ordering, not execution conflicts. Analyze which storage slots your calls touch; see [Optimizing for parallelization](/evm/best-practices/optimizing-for-parallelization). - **Lane policy.** One lane per in-flight intent is the simplest correct policy. If some calls must stay ordered relative to each other, put them on one lane (or on `ADMIN_LANE`) rather than falling back to lane `0`. -### Before you adapt this for real trading +### Before you adapt this -The repository is explicit about what it leaves out. Before this design touches inventory, add at least: +The repository is explicit about what it leaves out. Before this design touches real funds, add at least: - audited account and integration contracts; - hardware-backed or remote signing; -- a real strategy and risk engine with an idempotent intent model; +- a real risk engine with an idempotent intent model; - durable, replicated queue and reconciliation storage; - metrics, tracing, alerting, and structured logs; - controlled deployment and delegation procedures; @@ -541,7 +561,7 @@ The repository is explicit about what it leaves out. Before this design touches - load, fault-injection, and live-chain recovery testing. - **Delegation is powerful.** EIP-7702 changes the code that executes at the trader's address. Before delegating, verify the implementation source and deployed address, verify the target chain, inspect any existing delegation, and use a throwaway account for this demo. `spray` refuses to run if the current designator does not exactly match `LANE_ACCOUNT_IMPL`. + **Delegation is powerful.** EIP-7702 changes the code that executes at your address. Before delegating, verify the implementation source and deployed address, verify the target chain, inspect any existing delegation, and use a throwaway account for this demo. `submit` refuses to run if the current designator does not exactly match `LANE_ACCOUNT_IMPL`. Treat `.env`, `app/.state/`, signed raw transactions, and RPC URLs containing credentials as sensitive. The journal does not contain private keys, but it contains signed UserOperations and replayable raw transactions until their nonces are consumed. Clone the repository for a teammate and create fresh keys; do not copy a working directory. @@ -562,23 +582,23 @@ Treat `.env`, `app/.state/`, signed raw transactions, and RPC URLs containing cr Run `npm run fund`, or send SEI to relayer `0` and run `npm run dispense`. - Common validation causes: `AA24` is an invalid trader signature or the wrong EIP-712 chain or domain; `AA25` is a stale or incorrect lane sequence; an insufficient EntryPoint prefund; or delegation to the wrong account implementation. Run `npm run status` and resolve the cause before widening bundles or retrying. + Common validation causes: `AA24` is an invalid signature or the wrong EIP-712 chain or domain; `AA25` is a stale or incorrect lane sequence; an insufficient EntryPoint prefund; or delegation to the wrong account implementation. Run `npm run status` and resolve the cause before widening bundles or retrying. The bundle no longer fits the current block gas limit. Nothing in a bundle that fails simulation is broadcast or consumed. Rerun with a smaller `MAX_OPS_PER_BUNDLE`; the durable queue is repacked at the smaller width. - Only one lane-based process may use a trader at a time, even when `spray` and `swap:spray` use different journals. Stop the other process. A lock whose recorded PID is no longer alive is removed automatically on the next run. + Only one lane-based process may use an account at a time, even when `submit` and `swap:submit` use different journals. Stop the other process. A lock whose recorded PID is no longer alive is removed automatically on the next run. - Do not delete the journal and do not send the relayer's next nonce manually. Run `npm run spray` again once the RPC can answer receipt and nonce queries. If the application reports partial lane consumption or a state it cannot reconcile, stop and inspect the EntryPoint events, every attempted transaction hash, the relayer's confirmed nonce, and each lane sequence. + Do not delete the journal and do not send the relayer's next nonce manually. Run `npm run submit` again once the RPC can answer receipt and nonce queries. If the application reports partial lane consumption or a state it cannot reconcile, stop and inspect the EntryPoint events, every attempted transaction hash, the relayer's confirmed nonce, and each lane sequence. ## Resources - + Source, tests, and the full configuration reference. @@ -591,9 +611,15 @@ Treat `.env`, `app/.state/`, signed raw transactions, and RPC URLs containing cr The public-mempool rules the private queue sidesteps. - Why Sei has no pending state and one confirmation is final. + Why one confirmation is final and the pending view is unreliable. Type-4 support and authorization list requirements on Sei. + + A hosted ERC-4337 bundler and paymaster, if you want sponsorship instead of throughput. + + + EIP-7702 delegation for consumer wallet flows. + diff --git a/snippets/parallel-nonce-diagrams.jsx b/snippets/nonce-lane-diagrams.jsx similarity index 99% rename from snippets/parallel-nonce-diagrams.jsx rename to snippets/nonce-lane-diagrams.jsx index 4a11d64..9b472a0 100644 --- a/snippets/parallel-nonce-diagrams.jsx +++ b/snippets/nonce-lane-diagrams.jsx @@ -1,4 +1,4 @@ -export const ParallelNonceQueue = () => { +export const SequentialNonceQueue = () => { const ink = 'currentColor'; const ok = '#10b981'; const bad = '#ef4444'; @@ -56,7 +56,7 @@ export const ParallelNonceQueue = () => { ); }; -export const ParallelNonceLanes = () => { +export const NonceLanes = () => { const ink = 'currentColor'; const accent = 'var(--sei-maroon-50)'; const ok = '#10b981'; @@ -130,7 +130,7 @@ export const ParallelNonceLanes = () => { ); }; -export const ParallelNonceDelegation = () => { +export const NonceLaneDelegation = () => { const ink = 'currentColor'; const accent = 'var(--sei-maroon-50)'; const gold = 'var(--sei-gold-25)'; @@ -187,7 +187,7 @@ export const ParallelNonceDelegation = () => { ); }; -export const ParallelNoncePipeline = () => { +export const NonceLanePipeline = () => { const ink = 'currentColor'; const accent = 'var(--sei-maroon-50)'; const gold = 'var(--sei-gold-25)'; @@ -253,7 +253,7 @@ export const ParallelNoncePipeline = () => { ); }; -export const ParallelNonceBundleLifecycle = () => { +export const NonceLaneBundleLifecycle = () => { const ink = 'currentColor'; const accent = 'var(--sei-maroon-50)'; const ok = '#10b981'; @@ -320,7 +320,7 @@ export const ParallelNonceBundleLifecycle = () => { ); }; -export const ParallelNonceFailureIsolation = () => { +export const NonceLaneFailureIsolation = () => { const ink = 'currentColor'; const ok = '#10b981'; const bad = '#ef4444'; From 20fa992842917eeff0ae11b7afd35666271b0e1d Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Fri, 11 Sep 2026 17:19:13 +0200 Subject: [PATCH 4/9] docs(evm): call the queue a bundling queue, not a private mempool A mempool is a shared admission buffer: transactions arrive from strangers, are gossiped, and wait for whichever producer or bundler picks them up. sei-nonce-lanes has none of that. Its queue is an in-process array, filled only by the process that signed the operations and gone when that process exits. Calling it a "private mempool" sent readers looking for gossip, admission policy, and eviction that do not exist, and it was especially confusing on a Sei page where "mempool" otherwise means the Autobahn producer mempool two sections earlier. Rename it to "bundling queue" throughout, which says what it does: hold signed UserOperations and pack them into lane-safe handleOps bundles. The paragraph that explains the ERC-7562 exemption now leads with why the component is not a mempool, since that is precisely what earns the exemption -- an in-process queue never enters the alt-mempool, so SAME_SENDER_MEMPOOL_COUNT never applies to it. - Retitle the pipeline diagram box and update its aria-label - "take bundle from the mempool" -> "from the queue" in the bundle lifecycle diagram - Rename the `mempool.add(...)` call in the integration snippet to `bundlingQueue.add(...)`, tracking the repository rename of PrivateMempool to BundlingQueue and mempool.ts to bundling-queue.ts The remaining "mempool" mentions on the page are deliberate and refer to real mempools: Autobahn's strict nonce admission, the pending-nonce warning, and the canonical ERC-4337 alt-mempool this design bypasses. Vale and typos both report no errors. Co-authored-by: Cursor --- evm/nonce-lanes.mdx | 14 +++++++------- snippets/nonce-lane-diagrams.jsx | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/evm/nonce-lanes.mdx b/evm/nonce-lanes.mdx index b38c9dc..d25d716 100644 --- a/evm/nonce-lanes.mdx +++ b/evm/nonce-lanes.mdx @@ -1,7 +1,7 @@ --- title: 'Nonce Lanes: Concurrent Submission from One Account' sidebarTitle: 'Nonce Lanes' -description: 'Keep many independent transactions in flight from one funded Sei address without a sequential nonce queue. This guide explains how ERC-4337 nonce lanes, EIP-7702 delegation, a private mempool, and gas-only relayers fit together, and walks you through running the reference implementation.' +description: 'Keep many independent transactions in flight from one funded Sei address without a sequential nonce queue. This guide explains how ERC-4337 nonce lanes, EIP-7702 delegation, an in-process bundling queue, and gas-only relayers fit together, and walks you through running the reference implementation.' keywords: ['sei', 'nonce', 'nonce lanes', 'two-dimensional nonce', 'eip-7702', 'erc-4337', 'entrypoint', 'userop', 'relayer', 'bundler', 'account abstraction', 'throughput', 'market making', 'liquidation bot'] --- @@ -53,7 +53,7 @@ The design combines four ingredients. Each one solves a specific part of the pro | --- | --- | | ERC-4337 v0.8 two-dimensional nonces | Independent nonce lanes for one account | | EIP-7702 delegation | The existing funded address keeps its balance and approvals | -| A private in-process mempool | No per-sender pending cap and no third-party bundler | +| An in-process bundling queue | No per-sender pending cap and no third-party bundler | | Gas-only relayers with a write-ahead journal | Someone still has to send sequential transactions, but they hold no inventory | ### ERC-4337 nonce lanes @@ -111,13 +111,13 @@ The account spends one ordinary EVM nonce to install the delegation, using a typ ### Gas-only relayers carry what is left of the queue -UserOperations are not transactions. Something still has to wrap them in `EntryPoint.handleOps` transactions and pay for them. The repository uses a pool of gas-only relayers fed by a private in-process mempool. +UserOperations are not transactions. Something still has to wrap them in `EntryPoint.handleOps` transactions and pay for them. The repository uses a pool of gas-only relayers fed by an in-process bundling queue. The sequential constraint has not disappeared; it has moved. Each relayer still has one sequential EVM nonce and keeps one outer transaction in flight at a time. What changes is the custody boundary. Relayers hold native SEI for gas and nothing else. A compromised relayer key can lose its own gas balance or rebroadcast operations the funded account already signed. It cannot create a new operation, because every UserOperation carries an EIP-712 signature from the funded account over the EntryPoint's `PackedUserOperation` digest. -The private mempool matters for a different reason. The canonical ERC-4337 mempool enforces the [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562) validation rules, including `SAME_SENDER_MEMPOOL_COUNT = 4` for an unstaked sender. Four pending operations is a wallet number, not a throughput number. Those rules exist so competing bundlers can safely pack strangers' operations together. Here every operation comes from one account you control, so there are no strangers to defend against. The EntryPoint still enforces everything that protects funds: the signature, per-lane nonce uniqueness, and prefund solvency. +The bundling queue matters for a different reason. It is a plain in-process queue, not a mempool: nothing is gossiped, nothing arrives from a stranger, and it is gone when the process exits. That is what lets it skip the canonical ERC-4337 alt-mempool, which enforces the [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562) validation rules, including `SAME_SENDER_MEMPOOL_COUNT = 4` for an unstaked sender. Four pending operations is a wallet number, not a throughput number. Those rules exist so competing bundlers can safely pack strangers' operations together. Here every operation comes from one account you control, so there are no strangers to defend against. The EntryPoint still enforces everything that protects funds: the signature, per-lane nonce uniqueness, and prefund solvency. ### One bundle, start to finish @@ -174,7 +174,7 @@ The pillars behind that table: 1. **One balance, many lanes.** Capital, approvals, and protocol state stay on one address. Width comes from lanes, not from splitting funds. A new lane costs nothing to open and is valid at sequence `0` immediately. 2. **A custody boundary you can reason about.** The only key that can create a valid operation is the funded account's. Relayer keys can be rotated, replaced, or lost with a bounded cost measured in gas. 3. **A frozen nonce on the hot path.** After the one-time delegation, the funded account's EVM nonce never moves while submitting. Nothing an RPC drops or a producer rejects can strand the account. -4. **No third-party bundler and no per-sender cap.** The private mempool removes the ERC-7562 `SAME_SENDER_MEMPOOL_COUNT` limit and the dependency on someone else's inclusion policy, while keeping every EntryPoint check that protects funds. +4. **No third-party bundler and no per-sender cap.** Keeping the bundling queue in-process removes the ERC-7562 `SAME_SENDER_MEMPOOL_COUNT` limit and the dependency on someone else's inclusion policy, while keeping every EntryPoint check that protects funds. 5. **Crash-safe by construction.** Signed operations and signed outer transactions are journaled before broadcast. Replacement reuses the relayer nonce. Restart reconciliation is deterministic and refuses to create duplicate work when the state is ambiguous. 6. **Built for how Sei behaves.** The hot signing path performs no nonce reads, so an unreliable pending view costs nothing. Fast blocks and instant finality keep each relayer's receipt wait short, which is what makes `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` a meaningful per-block width rather than a theoretical one. @@ -527,7 +527,7 @@ const unsigned = buildOp({ }); const op = await signUserOp(account, unsigned, chain.id, ENTRY_POINT); -mempool.add({ +bundlingQueue.add({ op, hash: userOpHash(op, chain.id, ENTRY_POINT), lane: slot.lane, @@ -608,7 +608,7 @@ Treat `.env`, `app/.state/`, signed raw transactions, and RPC URLs containing cr UserOperations, the EntryPoint, and two-dimensional nonces. - The public-mempool rules the private queue sidesteps. + The alt-mempool rules the in-process bundling queue sidesteps. Why one confirmation is final and the pending view is unreliable. diff --git a/snippets/nonce-lane-diagrams.jsx b/snippets/nonce-lane-diagrams.jsx index 9b472a0..457614c 100644 --- a/snippets/nonce-lane-diagrams.jsx +++ b/snippets/nonce-lane-diagrams.jsx @@ -196,7 +196,7 @@ export const NonceLanePipeline = () => { return (
- + @@ -216,7 +216,7 @@ export const NonceLanePipeline = () => { - Private mempool + Bundling queue in-process FIFO of signed ops one op per lane per bundle no ERC-7562 4-op sender cap @@ -260,7 +260,7 @@ export const NonceLaneBundleLifecycle = () => { const warn = '#f59e0b'; const bad = '#ef4444'; const steps = [ - { t: 'take bundle', s: 'from the mempool' }, + { t: 'take bundle', s: 'from the queue' }, { t: 'simulate', s: 'eth_estimateGas' }, { t: 'sign at nonce n', s: 'EIP-1559 outer tx' }, { t: 'journal', s: 'bytes hit disk first' }, From 5edabe61a8824e7f932f57c0f55cd6e21e208aac Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Sun, 13 Sep 2026 13:18:04 +0200 Subject: [PATCH 5/9] docs(evm): correct the pending-nonce claim in the queue diagram The prose was rewritten to say that eth_getTransactionCount(addr, "pending") is not an alias for "latest", but the first diagram still carried the original claim, asserting both that Sei has "no pending view to inspect" and that the pending tag "equals latest". Both are wrong in the way the prose already explains: evm/reference documents the pending tag as returning EvmNextPendingNonce from the mempool, and the finality guidance marks that value as unreliable rather than absent, which is a different statement from the value not existing. Restate the caption as the mempool read that it is, keep the unreliability, and keep the bad-nonce rejection, which was accurate as written. The replacement runs 146 characters against the previous 152, so it still fits inside the 880-unit viewBox. The Sei.Headings exception for ERC that previously rode along on this branch now lives in its own pull request, so the advisory warning on the "ERC-4337 nonce lanes" heading reappears here until that one merges. Co-authored-by: Cursor --- snippets/nonce-lane-diagrams.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snippets/nonce-lane-diagrams.jsx b/snippets/nonce-lane-diagrams.jsx index 457614c..8a67944 100644 --- a/snippets/nonce-lane-diagrams.jsx +++ b/snippets/nonce-lane-diagrams.jsx @@ -32,7 +32,7 @@ export const SequentialNonceQueue = () => { ); })} nonces 7 and 8 are signed and valid, but nothing at or above 7 can execute until 6 is filled or replaced - on Sei there is no pending view to inspect: eth_getTransactionCount(addr, "pending") equals "latest", and a nonce gap is rejected with a bad nonce error + on Sei eth_getTransactionCount(addr, "pending") reads the mempool, not "latest", and is unreliable; a nonce gap is rejected with a bad nonce error From 3261b2bbd2efc874a20b73d6f73a827d1fc3ca73 Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Sun, 13 Sep 2026 14:28:35 +0200 Subject: [PATCH 6/9] docs(evm): stop understating a single hot wallet in the comparison table The first two rows claimed one unresolved transaction for a single hot wallet and one per wallet for a fleet, which contradicted both node/technical-reference and this page's own diagram. Sequential nonces constrain ordering, not submission count: an address can sign and broadcast n, n+1, n+2 and onward and have all of them unresolved, and the producer mempool admits them as long as they arrive in order, rejecting only a gap. The SequentialNonceQueue diagram a hundred lines earlier illustrates exactly that, with nonces 5 through 8 outstanding from one account. Overstating the baseline weakened a section that stands on its own merits, so separate the concerns those rows were conflating. The counts now say "many" where they are many, a new row asks whether the submissions are independent of one another, which is the real differentiator, and the paragraph above the table states the distinction outright rather than leaving it to be inferred. Replace the "Depends on a pending-nonce view" row too. A wallet or a fleet can track its nonce locally from one confirmed read at startup, which is the same technique this design credits itself with, so answering "Yes" for them was wrong. What a wallet genuinely cannot do without a durable journal is recover its in-flight set after a crash, so that is the row's new subject. Also fix "neighbouring" in the diagram snippet, missed when the other British spellings were corrected: .vale.ini lints only *.{md,mdx} and _typos.toml sets no locale, so nothing in CI reads prose inside .jsx. Co-authored-by: Cursor --- evm/nonce-lanes.mdx | 9 ++++++--- snippets/nonce-lane-diagrams.jsx | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/evm/nonce-lanes.mdx b/evm/nonce-lanes.mdx index d25d716..d2e0205 100644 --- a/evm/nonce-lanes.mdx +++ b/evm/nonce-lanes.mdx @@ -156,13 +156,16 @@ Faster hardware and better RPC routing improve every submission strategy equally The honest comparison is against a fleet of hot wallets, because that is what most teams actually run. A fleet can match the width. What it cannot match is doing so from one balance, one approval set, and one key. +Be precise about what the baseline actually is. A single address was never limited to one transaction at a time: it can sign and broadcast nonces `n`, `n+1`, `n+2`, and onward and have all of them unresolved, and [the producer mempool admits them](/node/technical-reference#giga-mode-behavior-and-per-block-limits) as long as they arrive in order. The constraint is not how many it can have outstanding but that they form a single ordered queue, so one that never lands strands every later nonce behind it. + | | One hot wallet | Fleet of N hot wallets | Public ERC-4337 bundler | Nonce lanes | | --- | --- | --- | --- | --- | -| Unresolved operations from one balance | 1 | 1 per wallet | At most 4 per unstaked sender | `LANE_POOL_SIZE` (32 by default, 4096 maximum) | -| Outer transactions in flight | 1 | N | The bundler's policy, not yours | `RELAYER_COUNT` | +| Submissions signed and unresolved at once | Many | Many per wallet | At most 4 per unstaked sender | `LANE_POOL_SIZE` (32 by default, 4096 maximum) | +| Outer transactions broadcast at once | Many | Many per wallet | The bundler's policy, not yours | `RELAYER_COUNT` | +| Are those submissions independent? | No: one ordered queue | Only across wallets, ordered within each | Yes | Yes: one sequence per lane | | Balances and approvals to maintain | One address | N addresses, approved N times | One address | One address | | Keys that can move funds | 1 | N | 1 | 1; relayers hold gas only | -| Depends on a pending-nonce view | Yes | Yes | No | No: sequences are read once at startup, then tracked locally | +| Recovering the in-flight set after a crash | Re-derive it from the chain | Re-derive it per wallet | The bundler's problem, not yours | Durable journal and deterministic reconciliation | | Failure blast radius | Everything behind the gap | Everything behind the gap, per wallet | One operation | One lane for execution, one bundle for validation | diff --git a/snippets/nonce-lane-diagrams.jsx b/snippets/nonce-lane-diagrams.jsx index 8a67944..afde8df 100644 --- a/snippets/nonce-lane-diagrams.jsx +++ b/snippets/nonce-lane-diagrams.jsx @@ -363,7 +363,7 @@ export const NonceLaneFailureIsolation = () => { Never submitted: nothing consumed - a signed operation whose outer transaction was dropped, evicted, or never broadcast leaves its lane sequence untouched; neighbouring lanes stay valid and the journal requeues it + a signed operation whose outer transaction was dropped, evicted, or never broadcast leaves its lane sequence untouched; neighboring lanes stay valid and the journal requeues it
Three different failures, three different blast radii. An execution revert costs one lane sequence and nothing else. A validation failure costs the outer transaction attempt but no sequences. A dropped bundle costs nothing on chain, which is exactly the case that strands a sequential account.
From 44029a4a8287b09ba3cdc1c7824db9f06992888a Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Mon, 14 Sep 2026 13:41:07 +0200 Subject: [PATCH 7/9] docs(evm): track the September 13 hardening of sei-nonce-lanes Follow sei-protocol/sei-nonce-lanes@9080ff0 ("Harden file locking, journal durability, and nonce handling") and the two commits before it. Explanation - A self-sponsored EIP-7702 delegation advances the account's EVM nonce by two, not one: the authorization is signed over nonce + 1 and applied after the transaction's own increment. Text and delegation diagram corrected. - The journal is fsynced before the rename and the directory after it, so write-ahead ordering now survives a lost kernel or host, not only a crashed process. Still one file on one disk. - New RECEIPT_POLLING_INTERVAL_MS (250 ms): viem's 4 s default reported timeouts for bundles that had already landed and triggered needless fee-bumped replacements on Sei's sub-second blocks. - The simulation is issued from the relayer's address so viem adds no pending-nonce read of its own, and one inode-identified file lock is re-checked before signing and before every journal write. Tutorial - `delegate` pins both nonces to one confirmed read instead of viem's pending lookup, because a wrong authorization nonce is skipped silently rather than rejected; it verifies the designator after the receipt. - The illustrative report now matches what the tool prints: `trader nonce` and `trader EVM nonce` (the page had renamed them to `account ...`), plus the new `call gas`, `journal pending`, timing, and wall-time lines. - `exec` is derived from the bundle receipt and the venue's isFilled, not from the UserOperationEvent as the page claimed. - `baseline` rewritten around the new probe: both nonce tags sampled at every stage, and four verdicts (rejected, admitted then dropped, queued, included above a gap) instead of two. - Danger callout moved ahead of both network tabs and extended with the chain-1328 replay caveat; the Atlantic-2 deploy uses `--interactives 1` instead of exporting the key; the ALLOW_MAINNET note now also mentions the refusal of remote writes with Anvil account 0 or the Anvil mnemonic. Tuning and reference - CALL_GAS_LIMIT is unset by default and only a floor; the September 3 swap measurement is attributed to the then-default 500000. - Configuration table: CALL_GAS_LIMIT default, RECEIPT_POLLING_INTERVAL_MS. - Troubleshooting: the `could not acquire the lock ... within 2000ms` case. Also: "funded EOA" / "holds only gas" wording in the pipeline diagram, "reference" rather than "audited" Simple7702Account, Twin Turbo Consensus casing, a wrapped caption in the failure-isolation diagram, and the three Use Cases cards on evm/index.mdx in one row (cols={3}). Co-authored-by: Cursor --- evm/index.mdx | 2 +- evm/nonce-lanes.mdx | 87 ++++++++++++++++++++++---------- snippets/nonce-lane-diagrams.jsx | 43 ++++++++-------- 3 files changed, 83 insertions(+), 49 deletions(-) diff --git a/evm/index.mdx b/evm/index.mdx index 07aa872..d6c9b68 100644 --- a/evm/index.mdx +++ b/evm/index.mdx @@ -66,7 +66,7 @@ keywords: ["sei evm", "ethereum virtual machine", "web3 development", "blockchai ## Use Cases - + Implement HTTP 402 Payment Required for API monetization and premium content. diff --git a/evm/nonce-lanes.mdx b/evm/nonce-lanes.mdx index d2e0205..149c0db 100644 --- a/evm/nonce-lanes.mdx +++ b/evm/nonce-lanes.mdx @@ -54,7 +54,7 @@ The design combines four ingredients. Each one solves a specific part of the pro | ERC-4337 v0.8 two-dimensional nonces | Independent nonce lanes for one account | | EIP-7702 delegation | The existing funded address keeps its balance and approvals | | An in-process bundling queue | No per-sender pending cap and no third-party bundler | -| Gas-only relayers with a write-ahead journal | Someone still has to send sequential transactions, but they hold no inventory | +| Gas-only relayers with a write-ahead journal | Someone still has to send sequential transactions, but they hold only gas | ### ERC-4337 nonce lanes @@ -89,7 +89,7 @@ The address does not change. Its native balance, token balances, protocol state, -`LaneAccount` inherits the audited `Simple7702Account` from the [account-abstraction](https://github.com/eth-infinitism/account-abstraction) repository and adds a single policy check: +`LaneAccount` inherits the reference `Simple7702Account` from the eth-infinitism [account-abstraction](https://github.com/eth-infinitism/account-abstraction) repository and adds a single policy check: ```solidity contract LaneAccount is Simple7702Account { @@ -103,7 +103,9 @@ contract LaneAccount is Simple7702Account { } ``` -The account spends one ordinary EVM nonce to install the delegation, using a type-4 transaction. Sei requires a non-empty authorization list on type-4 transactions; see [Transaction types](/evm/evm-parity/transaction-types#set-code-eip-7702-auth-list-requirement). After that, the submission path signs UserOperations only and the funded account's EVM nonce stops moving. +Installing the delegation takes one type-4 transaction. Sei requires a non-empty authorization list on type-4 transactions; see [Transaction types](/evm/evm-parity/transaction-types#set-code-eip-7702-auth-list-requirement). + +Count the nonce cost carefully. An authorization list is applied after the transaction's own nonce has been incremented, so when the authority is also the sender, the authorization must be signed over `nonce + 1`, and applying it increments the account a second time. A self-sponsored delegation therefore advances the account by two, which is the path `npm run delegate` takes. Only a delegation sponsored by a different sender costs the funded account a single increment, the one the authorization itself performs. Either way, once the designator is in place the submission path signs UserOperations only and the funded account's EVM nonce stops moving. EIP-7702 alone does not create independent nonces. It preserves the account. ERC-4337 supplies the independent nonce model. You need both. @@ -127,9 +129,13 @@ Each relayer runs one asynchronous worker. The worker takes a bundle of up to `M Two details carry most of the safety: -- **Write-ahead ordering.** The signed outer transaction is written to the journal before it is broadcast. A crash between signing and sending cannot lose or duplicate work; on restart, the exact raw bytes are rebroadcast first. +- **Write-ahead ordering.** The signed outer transaction is written to the journal before it is broadcast. Each journal snapshot is fsynced before it is renamed into place, and the directory is fsynced after it, so a record that has been written survives a lost kernel or host and not only a crashed process. On restart, the exact raw bytes are rebroadcast first, so a crash between signing and sending cannot lose or duplicate work. The journal is still one file on one disk, not a replicated database. - **Same-nonce replacement.** If no receipt arrives within `BUNDLE_RECEIPT_TIMEOUT_MS`, the worker checks earlier attempts for a receipt, bumps fees by `REPLACEMENT_FEE_BUMP_PERCENT`, and signs a replacement at the same relayer nonce. It never sends nonce `n + 1` while a transaction at `n` might still land, which is exactly the gap this design exists to avoid. +The receipt wait polls every `RECEIPT_POLLING_INTERVAL_MS`, 250 ms by default. Sei produces blocks in well under a second, and viem's 4-second default would spend most of the timeout idle between polls, then report a timeout for a bundle that had already landed and trigger a fee-bumped replacement nobody needed. + +Two smaller guards keep the simulation and the journal honest. The `eth_estimateGas` call is issued from the relayer's address rather than its signing account, so the client library does not add a pending-nonce lookup of its own before the estimate. And one file lock, which identifies its holder by inode rather than by the existence of a file, keeps a second process off the same account. The process re-checks that it still holds the lock immediately before it signs and before every journal write, so a process that lost a lock race stops instead of signing. + Before any new work is created, a restarted process reconciles every incomplete journal entry against the EntryPoint. If the chain sequence is ahead of the journal, the operation was consumed. If they match, the lane is reserved and the operation is recovered or requeued. If the chain is behind the journal, the state is inconsistent and the process stops rather than guessing. ### What fails alone and what fails together @@ -178,7 +184,7 @@ The pillars behind that table: 2. **A custody boundary you can reason about.** The only key that can create a valid operation is the funded account's. Relayer keys can be rotated, replaced, or lost with a bounded cost measured in gas. 3. **A frozen nonce on the hot path.** After the one-time delegation, the funded account's EVM nonce never moves while submitting. Nothing an RPC drops or a producer rejects can strand the account. 4. **No third-party bundler and no per-sender cap.** Keeping the bundling queue in-process removes the ERC-7562 `SAME_SENDER_MEMPOOL_COUNT` limit and the dependency on someone else's inclusion policy, while keeping every EntryPoint check that protects funds. -5. **Crash-safe by construction.** Signed operations and signed outer transactions are journaled before broadcast. Replacement reuses the relayer nonce. Restart reconciliation is deterministic and refuses to create duplicate work when the state is ambiguous. +5. **Crash-safe by construction.** Signed operations and signed outer transactions are journaled and fsynced before broadcast. Replacement reuses the relayer nonce. Restart reconciliation is deterministic and refuses to create duplicate work when the state is ambiguous. 6. **Built for how Sei behaves.** The hot signing path performs no nonce reads, so an unreliable pending view costs nothing. Fast blocks and instant finality keep each relayer's receipt wait short, which is what makes `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` a meaningful per-block width rather than a theoretical one. @@ -186,7 +192,7 @@ The pillars behind that table: - Block time and gas limits differ between today's [Twin-Turbo consensus](/learn/twin-turbo-consensus) and [Sei Giga](/learn/sei-giga). Size a relayer pool against the network you are actually submitting to, and measure rather than assuming. + Block time and gas limits differ between today's [Twin Turbo Consensus](/learn/twin-turbo-consensus) and [Sei Giga](/learn/sei-giga). Size a relayer pool against the network you are actually submitting to, and measure rather than assuming. ## Tutorial: run the reference implementation @@ -229,6 +235,10 @@ The walkthrough below deploys the demo contracts, delegates a throwaway account, Start with a local Prague fork. It carries the deployed EntryPoint bytecode from Atlantic-2 but spends only Anvil funds. + + Never use Anvil, Hardhat, tutorial, or shared test mnemonics on Atlantic-2 or Pacific-1. Their addresses and keys are public, and some are already delegated to sweeper code, so a successful funding transaction can still leave a zero balance. The fork below also runs as chain `1328`, so an authorization signed against it is replayable on Atlantic-2 itself whenever the account's nonce lines up. Regenerate every key and mnemonic before you point a fork's `.env` at a public network. + + EIP-7702 requires a Prague-capable node. Keep this terminal running: @@ -258,6 +268,8 @@ The walkthrough below deploys the demo contracts, delegates a throwaway account, Set `TRADER_PRIVATE_KEY` to account `0`'s private key from the Anvil startup output, and `RELAYER_MNEMONIC` to the mnemonic printed by that same Anvil process. Starting relayers at index `1` keeps the funded account and relayer identities distinct; the application rejects overlapping identities. + Those are Anvil's published credentials, which is why this `.env` belongs to the fork and nowhere else. + Deploy the demo contracts from the repository root using Anvil's unlocked account: ```bash @@ -295,19 +307,13 @@ The walkthrough below deploys the demo contracts, delegates a throwaway account, Deploy with a funded key. The deployer can be the throwaway account, but it does not have to be: ```bash - export DEPLOYER_PRIVATE_KEY=0x... - forge script script/Deploy.s.sol:Deploy \ --rpc-url https://evm-rpc-testnet.sei-apis.com \ --broadcast \ - --private-key "$DEPLOYER_PRIVATE_KEY" - - unset DEPLOYER_PRIVATE_KEY + --interactives 1 ``` - - Never use Anvil, Hardhat, tutorial, or shared test mnemonics on Atlantic-2 or Pacific-1. Their addresses and keys are public, and some are already delegated to sweeper code, so a successful funding transaction can still leave a zero balance. - + `--interactives 1` prompts for the key and does not echo it, so it never reaches your shell history, the process table, or an environment variable. @@ -319,7 +325,7 @@ The walkthrough below deploys the demo contracts, delegates a throwaway account, ``` - Mutating commands refuse to write to a remote Pacific-1 RPC unless `ALLOW_MAINNET=1` is set explicitly. That guard prevents an accidental `SEI_CHAIN_ID=1329` run. It does not guard the separate Forge deployment command, and it does not make the demo production-ready. + Mutating commands refuse to write to a remote Pacific-1 RPC unless `ALLOW_MAINNET=1` is set explicitly, and they refuse any remote-chain write when `TRADER_PRIVATE_KEY` is Anvil account `0` or `RELAYER_MNEMONIC` is the Anvil mnemonic. Those guards catch an accidental `SEI_CHAIN_ID=1329` run and the two most common leaked credentials, nothing more. They do not guard the separate Forge deployment command, and they do not make the demo production-ready. @@ -339,7 +345,9 @@ The walkthrough below deploys the demo contracts, delegates a throwaway account, npm run delegate ``` - This sends one type-4 transaction with an authorization for `LANE_ACCOUNT_IMPL`. It spends the account's EVM nonce once. The command is idempotent: if the account already delegates to the configured implementation, it does nothing. If the account delegates to something else, it tells you before replacing it. + This sends one type-4 transaction with an authorization for `LANE_ACCOUNT_IMPL`, signed and paid for by the account itself, so the account's EVM nonce advances by two: once for the transaction, once for the authorization. The command reads the account's confirmed nonce once and pins both the transaction nonce and the authorization nonce (`nonce + 1`) to that read, instead of letting the client fill them from a pending-nonce lookup. That matters because a wrong authorization nonce is not rejected: the EVM skips the authorization, the transaction still succeeds, and nothing is installed. The command therefore re-reads the designator after the receipt and fails if the delegation did not take effect. + + The command is idempotent: if the account already delegates to the configured implementation, it does nothing. If the account delegates to something else, it tells you before replacing it. @@ -367,15 +375,18 @@ The output below is illustrative; your addresses, blocks, and timings will diffe ```text === preflight === chain Sei Testnet (1328) +rpc http://127.0.0.1:8545 entryPoint 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 (… bytes) -account 0xf39F…2266 +trader 0xf39F…2266 delegated to 0x5FbD…0aa3 -account nonce 3 <- watch this, it must not move +… +trader nonce 9 <- watch this, it must not move journal 0 pending op(s) relayer 0x7099…79C8 0.5 SEI … === build === +call gas 78125 (estimate 62500) lane pool 32 lanes, 32 idle signed 24 new ops in 52ms, without nonce RPCs hash check local digest matches EntryPoint.getUserOpHash @@ -403,17 +414,22 @@ ops landed 24 bundles 6 across 2 block(s) pending recovery 0 failed safely 0 +journal pending 0 distinct lanes 24 relayers used 4 +sign time 52ms +submit time 1846ms throughput 13.0 landed ops/sec -account EVM nonce 3 -> 3 UNCHANGED +trader EVM nonce 9 -> 9 UNCHANGED +total wall time 4210ms ``` What to look for: -- **`account nonce` before and `account EVM nonce` after** must be identical. The funded key never entered a queue. -- **`exec`** is read from the `UserOperationEvent` in each receipt. `reverted` means the operation landed, consumed its lane sequence, and failed inside the call. `not mined` would mean the outer transaction never landed and nothing was consumed. +- **`trader nonce` before and `trader EVM nonce` after** must be identical. The tool calls the funded account the trader. Its key never entered a queue. +- **`call gas`** is the measured delegated-call estimate plus 25 percent. A `CALL_GAS_LIMIT floor` appears in that line only when you set one; leave it unset unless you have a reason, because the EntryPoint reserves the declared limit before running each operation. +- **`exec`** combines the bundle receipt with the venue's `isFilled` read. `reverted` means the outer transaction landed, so the operation consumed its lane sequence, but the call failed inside the venue. `not mined` would mean the outer transaction never landed and nothing was consumed. - **`land#`** is the venue's global landing counter. It shows the order in which operations actually executed, which has nothing to do with lane number. Lane acquisition is last-in, first-out, so a fresh 32-lane pool starts at lane `32`; lane numbers carry no priority. - **`hash check`** confirms that the locally computed EIP-712 digest matches `EntryPoint.getUserOpHash` for the first operation, so client-side hashing matches consensus. @@ -424,7 +440,7 @@ If a bundle is reported as `PENDING` or `FAILED`, the command exits non-zero and The repository includes a real-target path that routes tiny native SEI and native USDC swaps through the documented DragonSwap V1 deployment on Atlantic-2. It is hard-blocked on every other chain. Get testnet USDC from the [Circle faucet](https://faucet.circle.com/), then: ```bash -cd app +# from the app directory, where the walkthrough left off npm run swap:setup npm run swap:submit ``` @@ -444,10 +460,20 @@ The account must hold enough of both assets for every input-side swap to execute ### Optional: see the baseline you are escaping ```bash +# from the app directory, where the walkthrough left off npm run baseline ``` -Using a gas-only relayer key so nothing of value is at risk, `baseline` sends nonce `n + 1` while deliberately skipping `n`, reports whether the RPC path rejected it or stranded it, then fills the gap. Compare that with a `submit` run where 24 operations from one account are mutually independent. +Using a gas-only relayer key so nothing of value is at risk, `baseline` measures the constraint directly. It sends nonce `n + 1` while deliberately skipping `n`, waits to see whether that transaction can be included above a gap, fills the gap with nonce `n`, then checks whether the skipped-ahead transaction lands once the gap is closed. The verdict is one of four outcomes: + +| Verdict | What the RPC path did | In flight per sender | +| --- | --- | --- | +| `REJECTED at admission` | Refused the gapped nonce, typically with `bad nonce` | 1; a gap cannot even be created | +| `ADMITTED then dropped` | Returned a hash, then discarded the transaction | 1 in effect, despite admission succeeding | +| `QUEUED, then included once the gap filled` | Held it and released it in nonce order | More than 1, released in order | +| `ACCEPTED and included above a gap` | Included it out of order | More than 1, not strictly ordered on this path | + +The probe samples both the `latest` and `pending` nonce tags before, during, and after, and prints any rejection message verbatim, because the exact string is the finding. If the two tags already disagree before the probe starts, it warns you: either the account has work in flight or the node reports a mempool-derived pending nonce, and in both cases the gap it is about to create may not be a gap at all, so the verdict cannot be attributed to nonce ordering. Compare the result with a `submit` run, where 24 operations from one account are mutually independent and one failure strands nothing. ## Tuning @@ -458,9 +484,13 @@ Three knobs shape a run. They interact, so change one at a time and measure. One lane holds at most one in-flight operation, so `LANE_POOL_SIZE` is the hard ceiling on unresolved UserOperations in a process. Larger pools permit more concurrently unresolved intents, add startup `getNonce` reads (batched with bounded concurrency so a public RPC does not rate-limit you), and increase the recovery state you must understand after a failure. `ORDERS` must not exceed `LANE_POOL_SIZE`; the application rejects that configuration instead of silently submitting fewer operations. - `MAX_OPS_PER_BUNDLE` trades amortized outer-transaction overhead for the size of the shared validation failure domain. A width of `1` gives maximum isolation and the highest overhead. Execution reverts stay per-operation at any width. The relayer caps signed transaction gas below the live block gas limit and rejects a bundle whose estimate cannot fit. + `MAX_OPS_PER_BUNDLE` trades amortized outer-transaction overhead for the size of the shared validation failure domain. A width of `1` gives maximum isolation and the highest overhead. Execution reverts stay per-operation at any width. The relayer caps signed transaction gas below the block gas limit it read at startup and rejects a bundle whose estimate cannot fit. - As a reference point, on Atlantic-2 on September 3, 2026, the real-swap path sustained 77 operations per bundle; 78 hit the 12,500,000 block-gas ceiling and failed safely during simulation. Width 76 produced the best observed submission rate for that call shape, 47.7 landed swaps per second. Treat these as measurements for one call shape on one network at one point in time, not as protocol limits. + A bundle does not need the sum of its operations' declared call gas, which is the arithmetic most people try first. The EntryPoint checks before each operation that enough gas remains to honor that operation's `callGasLimit`, and whatever an operation leaves unspent passes to the next one, so the outer transaction needs the gas the bundle actually consumes plus about one operation's declared limit in reserve. Declaring more than you need is still not free: v0.8 charges 10 percent of unused call gas beyond a 40,000-gas threshold, and each operation reserves prefund from the deposit against its declared limits rather than its measured cost, so an inflated limit ties up deposit that is only refunded afterwards. + + That is why `CALL_GAS_LIMIT` is unset by default. Both submit paths measure the real call gas for the chain and call shape and add 25 percent; the variable is only a floor, and it changes anything only when you set it above that measurement. Because the EntryPoint reserves each operation's declared call gas before running it, a floor above the measurement costs block gas, and therefore operations per block, without changing the gas actually used. + + As a reference point, on Atlantic-2 on September 3, 2026, with `CALL_GAS_LIMIT=500000`, which was the default at the time, the real-swap path sustained 77 operations per bundle; 78 no longer fit the 12,500,000 block gas limit in effect that day and failed safely during simulation. Width 76 produced the best observed submission rate for that call shape, 47.7 landed swaps per second. Treat these as measurements for one call shape, one configuration, and one network at one point in time, not as protocol limits. With the floor unset, each operation reserves only its measured gas plus headroom, so the width that fits will differ; measure again rather than reusing that number. Each relayer has one sequential outer transaction stream. Under favorable admission and inclusion conditions, the immediate submission width is roughly `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` per block. That is a planning heuristic, not a throughput guarantee: RPC latency, block limits, state contention, gas, and producer policy still apply. Public RPC endpoints have [rate limits](/evm/networks); use a [dedicated provider](/learn/rpc-providers) or your own node for anything beyond a demo. @@ -488,8 +518,9 @@ The application always loads `.env` from the repository root. The variables you | `LANE_POOL_SIZE` | `32` | `1..4096` lanes and the in-flight ceiling | | `MAX_OPS_PER_BUNDLE` | `4` | `1..LANE_POOL_SIZE` operations sharing one validation domain | | `REVERT_ORDER_INDEX` | `2` | Order that receives an unfillable limit price; `-1` disables | -| `CALL_GAS_LIMIT` | `500000` | Execution-gas floor; the live estimate plus headroom can raise it | +| `CALL_GAS_LIMIT` | unset | Optional floor for each operation's declared call gas; when unset, the live estimate plus 25 percent is used | | `BUNDLE_RECEIPT_TIMEOUT_MS` | `12000` | Receipt wait before same-nonce replacement | +| `RECEIPT_POLLING_INTERVAL_MS` | `250` | Receipt poll interval, `10..60000`; viem's own default is `4000` | | `BUNDLE_MAX_ATTEMPTS` | `3` | Same-nonce attempts per process invocation | | `REPLACEMENT_FEE_BUMP_PERCENT` | `25` | Fee increase per replacement, `10..1000` | | `OPERATION_JOURNAL_PATH` | `app/.state/pending-ops.json` | Durable signed-operation journal | @@ -545,7 +576,7 @@ The real-swap path in `app/src/swap-submit.ts` is a complete example of this aga Check these before you trust a new target: - **`msg.sender` and `tx.origin`.** At the target, `msg.sender` is the funded EOA and `tx.origin` is the gas-paying relayer. Contracts that require `tx.origin == msg.sender` are incompatible. Audit each router, approval path, callback, reentrancy assumption, and authorization rule. -- **Gas on Sei.** Storage writes cost materially more than on Ethereum. The application estimates the delegated call live and raises `CALL_GAS_LIMIT` when the estimate plus headroom is higher, so do not copy Ethereum-sized static limits. See [Gas and fees](/evm/evm-parity/gas-and-fees). +- **Gas on Sei.** Storage writes cost materially more than on Ethereum. The application estimates the delegated call live and declares that estimate plus 25 percent, raised to `CALL_GAS_LIMIT` only when you set that floor higher. Do not copy Ethereum-sized static limits in either direction: too low runs out of gas, and too high reserves block gas your operations never use. See [Gas and fees](/evm/evm-parity/gas-and-fees). - **Storage contention.** Lanes remove submission ordering, not execution conflicts. Analyze which storage slots your calls touch; see [Optimizing for parallelization](/evm/best-practices/optimizing-for-parallelization). - **Lane policy.** One lane per in-flight intent is the simplest correct policy. If some calls must stay ordered relative to each other, put them on one lane (or on `ADMIN_LANE`) rather than falling back to lane `0`. @@ -592,6 +623,8 @@ Treat `.env`, `app/.state/`, signed raw transactions, and RPC URLs containing cr Only one lane-based process may use an account at a time, even when `submit` and `swap:submit` use different journals. Stop the other process. A lock whose recorded PID is no longer alive is removed automatically on the next run. + + `could not acquire the lock … within 2000ms` is different. The lock file exists but its contents could not be read, which is what a live holder can look like while it is still writing the file. Removing such a lock would evict a running process, so the next run waits for it instead. If the message persists across runs, no process owns the file and you can delete it by hand. Do not delete the journal and do not send the relayer's next nonce manually. Run `npm run submit` again once the RPC can answer receipt and nonce queries. If the application reports partial lane consumption or a state it cannot reconcile, stop and inspect the EntryPoint events, every attempted transaction hash, the relayer's confirmed nonce, and each lane sequence. diff --git a/snippets/nonce-lane-diagrams.jsx b/snippets/nonce-lane-diagrams.jsx index afde8df..856e5e0 100644 --- a/snippets/nonce-lane-diagrams.jsx +++ b/snippets/nonce-lane-diagrams.jsx @@ -32,7 +32,7 @@ export const SequentialNonceQueue = () => { ); })} nonces 7 and 8 are signed and valid, but nothing at or above 7 can execute until 6 is filled or replaced - on Sei eth_getTransactionCount(addr, "pending") reads the mempool, not "latest", and is unreliable; a nonce gap is rejected with a bad nonce error + on Sei under Giga the producer mempool rejects 7 and 8 with a bad nonce error rather than queueing them: the same stall @@ -44,14 +44,14 @@ export const SequentialNonceQueue = () => { hot wallet {w} own balance, own approvals - own key that can move inventory + own key that can move funds ); })} - throughput scales with wallets, and so do fragmented balances, duplicated approvals, and keys that hold inventory + throughput scales with wallets, and so do fragmented balances, duplicated approvals, and keys that hold funds
-
A transaction that lands and reverts consumes its nonce and blocks nothing. A transaction that never lands leaves a gap, and every later nonce waits behind it. Splitting inventory across hot wallets buys width at the cost of custody surface.
+
A transaction that lands and reverts consumes its nonce and blocks nothing. A transaction that never lands leaves a gap, and every later nonce waits behind it. On Sei a pending-nonce read comes from the mempool rather than aliasing latest, and it is too unreliable to rebuild an in-flight queue from. Splitting funds across hot wallets buys width at the cost of custody surface.
); }; @@ -86,7 +86,7 @@ export const NonceLanes = () => { bit 0 uint192 key: the lane - any value from 1 to 2^192 - 1, chosen by the trader + any value from 1 to 2^192 - 1, chosen by the caller uint64 sequence kept by the EntryPoint per lane @@ -138,7 +138,7 @@ export const NonceLaneDelegation = () => { return (
- + @@ -149,12 +149,12 @@ export const NonceLaneDelegation = () => { - Trading EOA, same address + Funded EOA, same address same native SEI balance same token balances and venue approvals same private key signs every UserOperation - EVM nonce: spent once by the type-4 transaction, - then frozen on the trading path + EVM nonce: a self-sponsored delegation spends two, + then it is frozen on the submission path code slot (delegation designator) 0xef0100 || LaneAccount address @@ -169,7 +169,7 @@ export const NonceLaneDelegation = () => { LaneAccount implementation - inherits the audited Simple7702Account + inherits the reference Simple7702Account adds one rule: lane 0 is rejected ADMIN_LANE = max uint192 for ordered admin calls @@ -178,7 +178,7 @@ export const NonceLaneDelegation = () => { venue contract - msg.sender = the trading EOA, not a proxy + msg.sender = the funded EOA, not a proxy tx.origin = the gas-paying relayer
@@ -196,7 +196,7 @@ export const NonceLanePipeline = () => { return (
- + @@ -204,11 +204,11 @@ export const NonceLanePipeline = () => { - holds inventory, signs intents + holds the funds, signs intents holds gas only, cannot forge an operation - Trading EOA + Funded EOA delegated to LaneAccount signs one UserOperation per lane EIP-712 digest, no nonce RPCs @@ -234,7 +234,7 @@ export const NonceLanePipeline = () => { ); })} - the sequential constraint moved here, away from inventory + the sequential constraint moved here, away from the funds EntryPoint v0.8 @@ -245,10 +245,10 @@ export const NonceLanePipeline = () => { LaneAccount.execute → venue - msg.sender is the trading EOA + msg.sender is the funded EOA
-
UserOperations are not transactions. Gas-only relayers wrap each bundle in an EntryPoint.handleOps transaction and pay for it. Each relayer still has one sequential EVM nonce, but a relayer key can only lose its own gas: it cannot create a valid operation without the trader's signature.
+
UserOperations are not transactions. Gas-only relayers wrap each bundle in an EntryPoint.handleOps transaction and pay for it. Each relayer still has one sequential EVM nonce, but a relayer key can only lose its own gas: it cannot create a valid operation without the funded account's signature.
); }; @@ -265,7 +265,7 @@ export const NonceLaneBundleLifecycle = () => { { t: 'sign at nonce n', s: 'EIP-1559 outer tx' }, { t: 'journal', s: 'bytes hit disk first' }, { t: 'broadcast', s: 'sendRawTransaction' }, - { t: 'wait for receipt', s: 'until the timeout' }, + { t: 'wait for receipt', s: 'poll until the timeout' }, { t: 'settle lanes', s: 'then nonce n + 1' } ]; return ( @@ -335,7 +335,7 @@ export const NonceLaneFailureIsolation = () => { return (
- + Execution revert: isolated to its lane one handleOps transaction, one block @@ -361,9 +361,10 @@ export const NonceLaneFailureIsolation = () => { for free; MAX_OPS_PER_BUNDLE sets the size of this failure domain - + Never submitted: nothing consumed - a signed operation whose outer transaction was dropped, evicted, or never broadcast leaves its lane sequence untouched; neighboring lanes stay valid and the journal requeues it + a signed operation whose outer transaction was dropped, evicted, or never broadcast + leaves its lane sequence untouched; neighboring lanes stay valid and the journal requeues it
Three different failures, three different blast radii. An execution revert costs one lane sequence and nothing else. A validation failure costs the outer transaction attempt but no sequences. A dropped bundle costs nothing on chain, which is exactly the case that strands a sequential account.
From 19dab311b9f5cfac1d91762c3e5e67f40522d82f Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Mon, 14 Sep 2026 13:41:23 +0200 Subject: [PATCH 8/9] docs(evm): add the Atlantic-2 benchmarks from sei-nonce-lanes Follow sei-protocol/sei-nonce-lanes@6adb72c and @c20eea0, which add app/bench/ and document the measurements taken on Atlantic-2 on September 14, 2026, plus @bdfa553, the same-nonce replacement fix that surfaced under that load. - New "Benchmarks" section: the bench:lanes, bench:sweep, bench:baseline, bench:report, and bench:latency commands, the five baseline modes (serial, pipelined, pipelined-gap, batch, fleet), the measured table (one EOA at 1.0 / 6.9 / 51.2 ops/s serial / pipelined / batched; a fleet of 8 wallets at 51.2; lanes at 60.2-64.0 against a ~73 ops/s block-gas ceiling for the ~331k-gas venue call), and what the numbers say. Dated and caveated as one call shape, one network state, one client machine. - "Why this is hard to replicate" gets a one-paragraph summary of those numbers with a link to the section. - Tuning: the mock-venue width reference (36 fits, 37 fails safely; widths 8, 16, 36 pack 4, 2, 1 bundles per block), the advice to raise BUNDLE_RECEIPT_TIMEOUT_MS for wide shapes with many relayers, and the ~1.5 s relayer bundle cycle with the ~120-in-flight crossover where block gas takes over from the pool. - Baseline: the two verdicts the probe returned for two Atlantic-2 RPC paths on the same day (dedicated provider queued and released; public endpoint returned a hash and dropped it), and a clause in "Strict nonce admission" that what an endpoint does can differ from what the producer does. - Keywords and the repository card mention benchmarks. The subsection is titled "Measured against the alternatives" with the date and network in the body, so Sei.Headings stays clean without touching the style exceptions. Co-authored-by: Cursor --- evm/nonce-lanes.mdx | 74 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/evm/nonce-lanes.mdx b/evm/nonce-lanes.mdx index 149c0db..a7c929b 100644 --- a/evm/nonce-lanes.mdx +++ b/evm/nonce-lanes.mdx @@ -2,7 +2,7 @@ title: 'Nonce Lanes: Concurrent Submission from One Account' sidebarTitle: 'Nonce Lanes' description: 'Keep many independent transactions in flight from one funded Sei address without a sequential nonce queue. This guide explains how ERC-4337 nonce lanes, EIP-7702 delegation, an in-process bundling queue, and gas-only relayers fit together, and walks you through running the reference implementation.' -keywords: ['sei', 'nonce', 'nonce lanes', 'two-dimensional nonce', 'eip-7702', 'erc-4337', 'entrypoint', 'userop', 'relayer', 'bundler', 'account abstraction', 'throughput', 'market making', 'liquidation bot'] +keywords: ['sei', 'nonce', 'nonce lanes', 'two-dimensional nonce', 'eip-7702', 'erc-4337', 'entrypoint', 'userop', 'relayer', 'bundler', 'account abstraction', 'throughput', 'benchmark', 'market making', 'liquidation bot'] --- import { SequentialNonceQueue, NonceLanes, NonceLaneDelegation, NonceLanePipeline, NonceLaneBundleLifecycle, NonceLaneFailureIsolation } from '/snippets/nonce-lane-diagrams.jsx'; @@ -36,7 +36,7 @@ The second case is the submission bottleneck. It covers transactions that are dr Two properties of Sei make this sharper than on Ethereum: -- **Strict nonce admission.** Under Giga, the Autobahn producer mempool admits EVM transactions in per-sender nonce order and rejects a gap with a `bad nonce` error instead of holding it for later. See [Giga mode behavior](/node/technical-reference#giga-mode-behavior-and-per-block-limits). The repository's `npm run baseline` command probes the behavior of whatever RPC path you configure rather than assuming every endpoint behaves identically. +- **Strict nonce admission.** Under Giga, the Autobahn producer mempool admits EVM transactions in per-sender nonce order and rejects a gap with a `bad nonce` error instead of holding it for later. See [Giga mode behavior](/node/technical-reference#giga-mode-behavior-and-per-block-limits). What you observe through an RPC endpoint can differ, because the node in front of the producer may hold a gapped transaction and release it later, or accept it and then drop it. The repository's `npm run baseline` command probes the behavior of whatever RPC path you configure rather than assuming every endpoint behaves identically; on one day it returned [two different verdicts](#optional-see-the-baseline-you-are-escaping) for two Atlantic-2 endpoints. - **No dependable pending view.** Sei does not expose Ethereum-style pending state, and [Finality and block tags](/evm/evm-parity/finality#pending-state) tells you not to rely on a pending nonce differing from the confirmed nonce. `txpool_content` is also [truncated and collapses the pending/queued distinction](/evm/reference). So even where a node answers a pending-nonce query, it is not a foundation to rebuild an in-flight queue on. @@ -178,6 +178,8 @@ Be precise about what the baseline actually is. A single address was never limit Read the first two rows together. `LANE_POOL_SIZE` caps how many operations can be signed and unresolved at once; `RELAYER_COUNT` caps how many outer transactions are actually broadcast at once. Lanes give you a large pool of independent intents, not a large number of simultaneous transactions. The rough per-block submission width is `RELAYER_COUNT × MAX_OPS_PER_BUNDLE`. +Measured on Atlantic-2 on September 14, 2026, against the same venue call: one address with sequential nonces landed 1 operation per second sending serially and 7 per second pipelining one request at a time, and reached 51 per second only by sending all 1,024 transactions in one JSON-RPC batch. A fleet of 8 hot wallets also landed 51 per second. Nonce lanes landed 60 to 64 per second from one address whose EVM nonce never moved, against a block-gas ceiling of about 73 for that call shape. The method, the run shapes, and the caveats are in [Benchmarks](#benchmarks). + The pillars behind that table: 1. **One balance, many lanes.** Capital, approvals, and protocol state stay on one address. Width comes from lanes, not from splitting funds. A new lane costs nothing to open and is valid at sequence `0` immediately. @@ -475,6 +477,8 @@ Using a gas-only relayer key so nothing of value is at risk, `baseline` measures The probe samples both the `latest` and `pending` nonce tags before, during, and after, and prints any rejection message verbatim, because the exact string is the finding. If the two tags already disagree before the probe starts, it warns you: either the account has work in flight or the node reports a mempool-derived pending nonce, and in both cases the gap it is about to create may not be a gap at all, so the verdict cannot be attributed to nonce ordering. Compare the result with a `submit` run, where 24 operations from one account are mutually independent and one failure strands nothing. +The verdict depends on the path. On September 14, 2026, two Atlantic-2 RPC endpoints answered differently on the same day: a dedicated provider queued the gapped transaction and released it once the gap filled, while the public endpoint returned a hash and then dropped it, so it never landed even after the gap was filled. Neither rejected it at admission. Probe the path you will actually use. + ## Tuning Three knobs shape a run. They interact, so change one at a time and measure. @@ -490,10 +494,14 @@ Three knobs shape a run. They interact, so change one at a time and measure. That is why `CALL_GAS_LIMIT` is unset by default. Both submit paths measure the real call gas for the chain and call shape and add 25 percent; the variable is only a floor, and it changes anything only when you set it above that measurement. Because the EntryPoint reserves each operation's declared call gas before running it, a floor above the measurement costs block gas, and therefore operations per block, without changing the gas actually used. - As a reference point, on Atlantic-2 on September 3, 2026, with `CALL_GAS_LIMIT=500000`, which was the default at the time, the real-swap path sustained 77 operations per bundle; 78 no longer fit the 12,500,000 block gas limit in effect that day and failed safely during simulation. Width 76 produced the best observed submission rate for that call shape, 47.7 landed swaps per second. Treat these as measurements for one call shape, one configuration, and one network at one point in time, not as protocol limits. With the floor unset, each operation reserves only its measured gas plus headroom, so the width that fits will differ; measure again rather than reusing that number. + As a reference point, on Atlantic-2 on September 3, 2026, with `CALL_GAS_LIMIT=500000`, which was the default at the time, the real-swap path sustained 77 operations per bundle; 78 no longer fit the 12,500,000 block gas limit in effect that day and failed safely during simulation. Width 76 produced the best observed submission rate for that call shape, 47.7 landed swaps per second. Treat these as measurements for one call shape, one configuration, and one network at one point in time, not as protocol limits. + + The mock venue is a heavier call. On September 14, 2026, with `CALL_GAS_LIMIT` unset, each `place` operation used about 331,000 gas of outer transaction gas, so 36 operations was the widest bundle that fit and 37 failed safely in simulation. Widths 8, 16, and 36 packed 4, 2, and 1 bundles into a 12,500,000-gas block; width 9, which should fit 4, landed 3. Wide bundles also queue behind each other, because only one 11,700,000-gas bundle fits a block. With more than about 8 relayers at width 32 or 36, receipt waits exceed the default `BUNDLE_RECEIPT_TIMEOUT_MS`; raise the timeout for those shapes rather than paying for fee-bumped replacements of bundles that will land anyway. Each relayer has one sequential outer transaction stream. Under favorable admission and inclusion conditions, the immediate submission width is roughly `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` per block. That is a planning heuristic, not a throughput guarantee: RPC latency, block limits, state contention, gas, and producer policy still apply. Public RPC endpoints have [rate limits](/evm/networks); use a [dedicated provider](/learn/rpc-providers) or your own node for anything beyond a demo. + + One relayer's bundle cycle is five sequential RPC round trips (gas estimate, fee estimate, block number, broadcast, receipt poll) plus inclusion, about 1.5 seconds on a 120 ms endpoint. Below roughly 120 operations in flight the pool is the bottleneck and throughput scales with `RELAYER_COUNT`; above that, block gas is. In the September 14, 2026 measurements, 4 relayers landed 2.7, 8.9, 15.3, 24.3, 45.4, and 50.5 operations per second at widths 1, 4, 8, 16, 32, and 36, and width 4 landed 2.2, 8.9, 15.4, 27.9, and 47.2 per second with 1, 4, 8, 16, and 32 relayers. @@ -527,6 +535,64 @@ The application always loads `.env` from the repository root. The variables you The [repository README](https://github.com/sei-protocol/sei-nonce-lanes#configuration) documents the full set, including the real-swap variables. +## Benchmarks + +`app/bench/` measures the lane path against the thing it replaces: one EOA sending ordinary transactions with sequential nonces. Every mode calls the same `MockPerpVenue.place` on the configured `VENUE`, so the numbers differ only in how the calls were submitted. The commands read the root `.env`, and the shell environment overrides it: + +```bash +cd app + +# Lane throughput for one run shape. Same safety path as submit, outcomes read +# from the bundle receipts, one JSON line appended to .state/bench/lanes-results.jsonl. +ORDERS=1024 LANE_POOL_SIZE=1024 MAX_OPS_PER_BUNDLE=16 RELAYER_COUNT=16 \ +REVERT_ORDER_INDEX=-1 BENCH_LABEL="16x16" npm run bench:lanes + +# The same over several "RELAYER_COUNT MAX_OPS_PER_BUNDLE" combinations. +BUNDLE_RECEIPT_TIMEOUT_MS=60000 npm run bench:sweep -- sat "32 8" "16 16" "16 36" + +# One EOA with sequential nonces, every nonce taken from one confirmed read. +BENCH_MODE=serial BENCH_TXS=30 npm run bench:baseline +BENCH_MODE=pipelined BENCH_TXS=200 npm run bench:baseline +BENCH_MODE=batch BENCH_TXS=1024 npm run bench:baseline +BENCH_MODE=pipelined-gap BENCH_TXS=100 BENCH_GAP_INDEX=50 npm run bench:baseline +BENCH_MODE=fleet BENCH_TXS=64 BENCH_FLEET_SIZE=8 BENCH_FUND_SEI=4 npm run bench:baseline + +# What the chain included, block by block, independent of any client clock. +REPORT_FROM_BLOCK= REPORT_TO_BLOCK= npm run bench:report +npm run bench:latency +``` + +The `bench:baseline` modes are the ways one address can drive a sequential queue. `serial` sends one transaction and waits for its receipt before the next. `pipelined` signs everything up front and broadcasts in nonce order without waiting. `pipelined-gap` does the same but never broadcasts the transaction at `BENCH_GAP_INDEX`, then repairs the gap. `batch` sends everything in one JSON-RPC batch request. `fleet` derives `BENCH_FLEET_SIZE` wallets from `RELAYER_MNEMONIC`, funds them from the account, and runs `pipelined` on each at once. + +`bench:lanes` shares the account-wide run lock with `submit` and `swap:submit` but keeps its own journal under `app/.state/bench/`, so a benchmark never replays the tutorial's pending operations. If a `submit` run was interrupted, recover it with `npm run submit` first. The remaining `BENCH_*` and `REPORT_*` knobs are documented in the [repository README](https://github.com/sei-protocol/sei-nonce-lanes#benchmarks). + +### Measured against the alternatives + +Every row below was measured on Atlantic-2 on September 14, 2026. Conditions: block gas limit 12,500,000; base fee 50 gwei plus a 1 gwei tip; about 2.0 blocks per second under load; 120 ms warm request latency to the configured endpoint. Each direct `place` transaction used 328,425 gas and each lane operation about 331,000 gas of outer transaction gas, so the block admits 36 to 38 operations however they are submitted: roughly 73 landed operations per second for this call shape. Chain-side rates divide landed operations by the block-timestamp span, which Sei stamps in whole seconds. Client-side rates divide by wall time from first broadcast to last receipt. + +| Submission | Landed ops/s, chain-side | Client-side | Block gas used, average | +| --- | --- | --- | --- | +| One EOA, serial send and wait (30 tx) | 1.0 | 1.0 | | +| One EOA, pipelined, one request in flight (200 tx) | 6.9 | 6.7 | | +| Tutorial lanes, 4 relayers × 4 per bundle (24 ops) | 8.0 | 5.7 | 11% | +| Fleet of 4 wallets × 100 tx | 26.7 | 24.8 | | +| Fleet of 8 wallets × 64 tx | 51.2 | 49.7 | 54% | +| One EOA, 1,024 tx in one JSON-RPC batch | 51.2 | 47.3 | 68% | +| Lanes, 32 relayers × 8 per bundle (1,024 ops) | 60.2 | 55.3 | 83% | +| Lanes, 16 relayers × 16 per bundle (1,024 ops) | 64.0 | 54.6 | 81% | +| Lanes, 16 relayers × 36 per bundle (1,024 ops) | 64.0 | 55.1 | 88% | + +What the numbers say: + +- **Block gas bounds raw throughput, not the nonce model.** Lanes came closest to the ceiling, with 28 of 31 blocks at least 90 percent full in the 16 × 36 run, from one address whose EVM nonce never moved. A single ordered queue matched the fleet only when every transaction left in one JSON-RPC batch; one request at a time, it is bounded by the round trip, about 7 per second. +- **Small relayer pools are client-bound.** Throughput scales with `RELAYER_COUNT` until roughly 120 operations are in flight, then block gas takes over. The relayer and width series are in [Tuning](#tuning). +- **A gap costs the whole queue.** One lost transaction in the sequential queue (`pipelined-gap`, nonce 50 of 100) left the 49 transactions behind it accepted but unmined until the client resent it 15 seconds later. In the lane runs, the deliberately reverting order consumed only its own lane while the rest of its bundle landed. +- **RPC paths differ.** `npm run baseline` gave [two different verdicts](#optional-see-the-baseline-you-are-escaping) for two Atlantic-2 endpoints on the same day. + + + These are measurements of one call shape, one network state, and one client machine, not protocol limits. Cost was about 0.02 SEI per landed operation at the effective 52 gwei. Rerun the benchmarks against the network and endpoint you will actually use; block time and gas limits differ between [Twin Turbo Consensus](/learn/twin-turbo-consensus) and [Sei Giga](/learn/sei-giga). The raw records under `app/.state/bench/` include a journal for `bench:lanes`, which you should treat like `pending-ops.json`. + + ## Adapting it to your contract The demo's `MockPerpVenue` is a stand-in that reverts on slippage so a failure is observable. Swapping it for a real target is a matter of encoding a different call. `LaneAccount.execute(target, value, data)` forwards any call, and the target sees the funded EOA as `msg.sender`: @@ -635,7 +701,7 @@ Treat `.env`, `app/.state/`, signed raw transactions, and RPC URLs containing cr - Source, tests, and the full configuration reference. + Source, tests, benchmarks, and the full configuration reference. The delegation mechanism that keeps the funded address. From bfffeea5a8de39570192f1bcc11f6ffe5ca1076c Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Mon, 14 Sep 2026 14:17:37 +0200 Subject: [PATCH 9/9] docs(evm): resolve the review blockers and drop dates from the measurements Review blockers - The baseline verdict table had an "In flight per sender" column that read gap handling as submission capacity and contradicted the page's own statement that one sender can have many consecutive nonces outstanding. The column now describes what happened to the out-of-order transaction, and a sentence below points to the section that answers the capacity question. - The lock troubleshooting entry told readers that a persistent `could not acquire the lock` message meant the file was unowned and could be deleted. A torn file from a dead process and a live holder mid-publish produce the same message. The entry now requires confirming that no submit, swap:submit, or bench:lanes process is running against the account (recorded pid, process table on every host sharing the state directory), names both lock files under app/.state/, and states the consequence of evicting a live holder: two processes signing the same lane sequences with conflicting relayer nonces. Review suggestions - "issued from the relayer's address rather than its signing account" said nothing. The estimate passes the address string as `account` instead of the viem Account object, which skips viem's transaction preparation and with it the chain ID read, the fee lookup, and the pending-nonce call. - AA95 now leads with what the EntryPoint is reporting (too little gas left in the outer transaction for an operation's declared limits) before the bundle-widening case, so a reader who meets it elsewhere does not read it as a network capacity limit. - Empty "Block gas used" cells in the benchmark table read as dropped data; they now say "not recorded". Dates - Measurement text no longer carries calendar dates. The comparison, Tuning, baseline, and Benchmarks sections refer to one measurement session on Atlantic-2 and its recorded conditions, and the swap figure attributes CALL_GAS_LIMIT=500000 to an earlier revision of the repository rather than to a day. Prose - Removed the remaining em dash, the slogan lead-ins in the "hard to replicate" list and the benchmark bullets, the "Delegation is powerful" bold, and signposting such as "Be precise about what the baseline actually is" and "Count the nonce cost carefully". Diagram labels lose "a book on key 0" (order-book framing) and "thin prefund". Vale, typos, and mint broken-links report nothing. Co-authored-by: Cursor --- evm/nonce-lanes.mdx | 132 ++++++++++++++++--------------- snippets/nonce-lane-diagrams.jsx | 6 +- 2 files changed, 71 insertions(+), 67 deletions(-) diff --git a/evm/nonce-lanes.mdx b/evm/nonce-lanes.mdx index a7c929b..aa59fc1 100644 --- a/evm/nonce-lanes.mdx +++ b/evm/nonce-lanes.mdx @@ -7,9 +7,9 @@ keywords: ['sei', 'nonce', 'nonce lanes', 'two-dimensional nonce', 'eip-7702', ' import { SequentialNonceQueue, NonceLanes, NonceLaneDelegation, NonceLanePipeline, NonceLaneBundleLifecycle, NonceLaneFailureIsolation } from '/snippets/nonce-lane-diagrams.jsx'; -One funded account on Sei EVM has one sequential nonce. That is the right model for a wallet and the wrong model for a stream of independent work: a single transaction that never lands freezes every transaction signed after it. The usual fix is a fleet of hot wallets, which multiplies balances, approvals, and keys. +One funded account on Sei EVM has one sequential nonce. That suits a wallet, where each action follows the last. It does not suit a stream of independent work: one transaction that never lands blocks every transaction signed after it. The usual fix is a fleet of hot wallets, which multiplies balances, approvals, and keys. -[sei-nonce-lanes](https://github.com/sei-protocol/sei-nonce-lanes) is a reference implementation that removes the queue instead. One funded externally owned account (EOA) keeps its address, balance, and approvals, and keeps many mutually independent operations in flight at the same time. +[sei-nonce-lanes](https://github.com/sei-protocol/sei-nonce-lanes) is a reference implementation that removes the queue instead. One funded externally owned account (EOA) keeps its address, balance, and approvals while holding many mutually independent operations in flight at the same time. The pattern applies whenever one funded address needs to do many things that do not depend on each other: market making and order flow, liquidation and keeper bots, oracle updates, payout and claim batching, or game and backend transactions. @@ -36,25 +36,25 @@ The second case is the submission bottleneck. It covers transactions that are dr Two properties of Sei make this sharper than on Ethereum: -- **Strict nonce admission.** Under Giga, the Autobahn producer mempool admits EVM transactions in per-sender nonce order and rejects a gap with a `bad nonce` error instead of holding it for later. See [Giga mode behavior](/node/technical-reference#giga-mode-behavior-and-per-block-limits). What you observe through an RPC endpoint can differ, because the node in front of the producer may hold a gapped transaction and release it later, or accept it and then drop it. The repository's `npm run baseline` command probes the behavior of whatever RPC path you configure rather than assuming every endpoint behaves identically; on one day it returned [two different verdicts](#optional-see-the-baseline-you-are-escaping) for two Atlantic-2 endpoints. -- **No dependable pending view.** Sei does not expose Ethereum-style pending state, and [Finality and block tags](/evm/evm-parity/finality#pending-state) tells you not to rely on a pending nonce differing from the confirmed nonce. `txpool_content` is also [truncated and collapses the pending/queued distinction](/evm/reference). So even where a node answers a pending-nonce query, it is not a foundation to rebuild an in-flight queue on. +- **Strict nonce admission.** Under Giga, the Autobahn producer mempool admits EVM transactions in per-sender nonce order and rejects a gap with a `bad nonce` error instead of holding it for later. See [Giga mode behavior](/node/technical-reference#giga-mode-behavior-and-per-block-limits). What you observe through an RPC endpoint can differ, because the node in front of the producer may hold a gapped transaction and release it later, or accept it and then drop it. The repository's `npm run baseline` command probes the RPC path you configure; in one session it returned [two different verdicts](#optional-see-the-baseline-you-are-escaping) for two Atlantic-2 endpoints. +- **No dependable pending view.** Sei does not expose Ethereum-style pending state, and [Finality and block tags](/evm/evm-parity/finality#pending-state) tells you not to rely on a pending nonce differing from the confirmed nonce. `txpool_content` is also [truncated and collapses the pending/queued distinction](/evm/reference). Even where a node answers a pending-nonce query, the value cannot be used to rebuild an in-flight queue. - Do not design around a pending nonce on Sei. `eth_getTransactionCount(address, "pending")` is [documented](/evm/reference) as returning `EvmNextPendingNonce` from the mempool, so it is not an alias for `"latest"` — but the finality guidance marks the pending view as unreliable, and what you get back varies by node and by whether it runs Giga. The design below sidesteps the question entirely: its hot path reads no nonces at all. + Do not design around a pending nonce on Sei. `eth_getTransactionCount(address, "pending")` is [documented](/evm/reference) as returning `EvmNextPendingNonce` from the mempool, so it is not an alias for `"latest"`. The finality guidance still marks the pending view as unreliable, and the value varies by node and by whether the node runs Giga. The design below does not depend on the answer: its hot path reads no nonces. Splitting work across hot wallets raises throughput, but every wallet is another balance to rebalance, another set of approvals to maintain, and another key that can move funds. ## How it works -The design combines four ingredients. Each one solves a specific part of the problem, and none is sufficient alone. +The design combines four mechanisms. Each solves one part of the problem; none is sufficient alone. -| Ingredient | What it contributes | +| Mechanism | What it contributes | | --- | --- | | ERC-4337 v0.8 two-dimensional nonces | Independent nonce lanes for one account | | EIP-7702 delegation | The existing funded address keeps its balance and approvals | | An in-process bundling queue | No per-sender pending cap and no third-party bundler | -| Gas-only relayers with a write-ahead journal | Someone still has to send sequential transactions, but they hold only gas | +| Gas-only relayers with a write-ahead journal | Sequential transactions still exist, but the accounts that send them hold only gas | ### ERC-4337 nonce lanes @@ -75,7 +75,7 @@ Four rules follow from that layout: - An operation that executes and reverts still consumes its lane sequence. - An operation that never reaches a successful `handleOps` transaction consumes nothing. -`LaneAccount` rejects lane `0`. Most SDKs pick key `0` when you do not pass one, and work that lands entirely on key `0` is a single queue again. Rejecting it turns a silent fallback into a loud validation failure. `ADMIN_LANE` (the maximum `uint192`) is reserved for integrations that need one explicitly ordered lane for administrative calls. +`LaneAccount` rejects lane `0`. Most SDKs pick key `0` when you do not pass one, and work that lands entirely on key `0` is a single queue again. Rejecting it converts a silent fallback into an explicit validation failure. `ADMIN_LANE` (the maximum `uint192`) is reserved for integrations that need one explicitly ordered lane for administrative calls. ### EIP-7702 keeps the funded address @@ -105,10 +105,10 @@ contract LaneAccount is Simple7702Account { Installing the delegation takes one type-4 transaction. Sei requires a non-empty authorization list on type-4 transactions; see [Transaction types](/evm/evm-parity/transaction-types#set-code-eip-7702-auth-list-requirement). -Count the nonce cost carefully. An authorization list is applied after the transaction's own nonce has been incremented, so when the authority is also the sender, the authorization must be signed over `nonce + 1`, and applying it increments the account a second time. A self-sponsored delegation therefore advances the account by two, which is the path `npm run delegate` takes. Only a delegation sponsored by a different sender costs the funded account a single increment, the one the authorization itself performs. Either way, once the designator is in place the submission path signs UserOperations only and the funded account's EVM nonce stops moving. +The nonce cost depends on who sends that transaction. The EVM applies the authorization list after incrementing the sender's nonce for the transaction itself, so when the authority is also the sender, the authorization must be signed over `nonce + 1` and applying it increments the account a second time. A self-sponsored delegation advances the account's nonce by two; `npm run delegate` takes this path. A delegation sponsored by a different sender advances the funded account by one, the increment the authorization itself performs. In both cases, once the designator is in place, the submission path signs UserOperations only and the funded account's EVM nonce stops moving. - EIP-7702 alone does not create independent nonces. It preserves the account. ERC-4337 supplies the independent nonce model. You need both. + EIP-7702 alone does not create independent nonces; it preserves the account. ERC-4337 supplies the nonce model. The design needs both. ### Gas-only relayers carry what is left of the queue @@ -117,9 +117,9 @@ UserOperations are not transactions. Something still has to wrap them in `EntryP -The sequential constraint has not disappeared; it has moved. Each relayer still has one sequential EVM nonce and keeps one outer transaction in flight at a time. What changes is the custody boundary. Relayers hold native SEI for gas and nothing else. A compromised relayer key can lose its own gas balance or rebroadcast operations the funded account already signed. It cannot create a new operation, because every UserOperation carries an EIP-712 signature from the funded account over the EntryPoint's `PackedUserOperation` digest. +The sequential constraint moves rather than disappears. Each relayer has one sequential EVM nonce and keeps one outer transaction in flight at a time. The custody boundary is what changes: relayers hold native SEI for gas and nothing else. A compromised relayer key can lose its own gas balance or rebroadcast operations the funded account already signed. It cannot create a new operation, because every UserOperation carries an EIP-712 signature from the funded account over the EntryPoint's `PackedUserOperation` digest. -The bundling queue matters for a different reason. It is a plain in-process queue, not a mempool: nothing is gossiped, nothing arrives from a stranger, and it is gone when the process exits. That is what lets it skip the canonical ERC-4337 alt-mempool, which enforces the [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562) validation rules, including `SAME_SENDER_MEMPOOL_COUNT = 4` for an unstaked sender. Four pending operations is a wallet number, not a throughput number. Those rules exist so competing bundlers can safely pack strangers' operations together. Here every operation comes from one account you control, so there are no strangers to defend against. The EntryPoint still enforces everything that protects funds: the signature, per-lane nonce uniqueness, and prefund solvency. +The bundling queue matters for a different reason. It is an in-process queue, not a mempool: nothing is gossiped, nothing arrives from another party, and it is gone when the process exits. It therefore never enters the canonical ERC-4337 alt-mempool, and the [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562) validation rules that govern that mempool, including `SAME_SENDER_MEMPOOL_COUNT = 4` for an unstaked sender, do not apply to it. A cap of four pending operations per sender is sized for wallets, not for a submission pipeline. Those rules exist so competing bundlers can safely pack operations from unrelated senders into one bundle. Here every operation comes from one account you control, so that threat does not arise. The EntryPoint still enforces everything that protects funds: the signature, per-lane nonce uniqueness, and prefund solvency. ### One bundle, start to finish @@ -127,14 +127,14 @@ Each relayer runs one asynchronous worker. The worker takes a bundle of up to `M -Two details carry most of the safety: +Two rules provide most of the safety: -- **Write-ahead ordering.** The signed outer transaction is written to the journal before it is broadcast. Each journal snapshot is fsynced before it is renamed into place, and the directory is fsynced after it, so a record that has been written survives a lost kernel or host and not only a crashed process. On restart, the exact raw bytes are rebroadcast first, so a crash between signing and sending cannot lose or duplicate work. The journal is still one file on one disk, not a replicated database. -- **Same-nonce replacement.** If no receipt arrives within `BUNDLE_RECEIPT_TIMEOUT_MS`, the worker checks earlier attempts for a receipt, bumps fees by `REPLACEMENT_FEE_BUMP_PERCENT`, and signs a replacement at the same relayer nonce. It never sends nonce `n + 1` while a transaction at `n` might still land, which is exactly the gap this design exists to avoid. +- **Write-ahead ordering.** The signed outer transaction is written to the journal before it is broadcast. Each journal snapshot is fsynced before it is renamed into place, and the directory is fsynced after it, so a written record survives a kernel panic or host loss, not only a process crash. On restart, the exact raw bytes are rebroadcast first, so a crash between signing and sending cannot lose or duplicate work. The journal is still one file on one disk, not a replicated database. +- **Same-nonce replacement.** If no receipt arrives within `BUNDLE_RECEIPT_TIMEOUT_MS`, the worker checks earlier attempts for a receipt, bumps fees by `REPLACEMENT_FEE_BUMP_PERCENT`, and signs a replacement at the same relayer nonce. It never sends nonce `n + 1` while a transaction at `n` might still land, which is the gap this design exists to avoid. -The receipt wait polls every `RECEIPT_POLLING_INTERVAL_MS`, 250 ms by default. Sei produces blocks in well under a second, and viem's 4-second default would spend most of the timeout idle between polls, then report a timeout for a bundle that had already landed and trigger a fee-bumped replacement nobody needed. +The receipt wait polls every `RECEIPT_POLLING_INTERVAL_MS`, 250 ms by default. Sei produces blocks in well under a second. With viem's 4-second default, most of the timeout passes idle between polls, and a bundle that has already landed can be reported as timed out, which triggers an unnecessary fee-bumped replacement. -Two smaller guards keep the simulation and the journal honest. The `eth_estimateGas` call is issued from the relayer's address rather than its signing account, so the client library does not add a pending-nonce lookup of its own before the estimate. And one file lock, which identifies its holder by inode rather than by the existence of a file, keeps a second process off the same account. The process re-checks that it still holds the lock immediately before it signs and before every journal write, so a process that lost a lock race stops instead of signing. +Two further guards cover the simulation and the journal. The `eth_estimateGas` call passes the relayer's address string as `account`, not the viem `Account` object. Given a local account object, viem prepares a full transaction request before estimating, which adds a chain ID read, a fee lookup, and an `eth_getTransactionCount(address, "pending")` call; passing the address skips all three, including the pending-nonce read this hot path is built to avoid. A file lock keeps a second process off the same account. The lock identifies its holder by inode rather than by the existence of a file, and the process re-checks that it still holds the lock immediately before it signs and before every journal write, so a process that lost a lock race stops instead of signing. Before any new work is created, a restarted process reconciles every incomplete journal entry against the EntryPoint. If the chain sequence is ahead of the journal, the operation was consumed. If they match, the lane is reserved and the operation is recovered or requeued. If the chain is behind the journal, the state is inconsistent and the process stops rather than guessing. @@ -147,7 +147,7 @@ The EntryPoint treats an account execution failure as a per-operation result: it | Situation | Lane sequence | Other lanes in the bundle | Recovery | | --- | --- | --- | --- | | Operation executes successfully | Consumed | Continue | None | -| Operation execution reverts | Consumed | Continue | Retry the intent on the lane's next sequence if you want | +| Operation execution reverts | Consumed | Continue | Optionally retry the intent on the lane's next sequence | | Operation validation fails | Not consumed | Entire `handleOps` reverts | Fix the cause and resubmit | | Signed operation never reached a mined bundle | Not consumed | Independent lanes stay valid | The journal requeues it | | Outer transaction times out or is evicted | Unknown until reconciled | Bundle stays intact | Rebroadcast and fee-bump at the same relayer nonce | @@ -158,43 +158,43 @@ Every bundle is simulated with `eth_estimateGas` before broadcast, so validation ## Why this is hard to replicate -Faster hardware and better RPC routing improve every submission strategy equally. What changes here is structural: it changes what a single funded address is allowed to do, and it does so with the same custody surface as a single wallet. +Faster hardware and better RPC routing help every submission strategy. The difference here is structural: one funded address holds many independent operations in flight while keeping the custody surface of a single wallet. -The honest comparison is against a fleet of hot wallets, because that is what most teams actually run. A fleet can match the width. What it cannot match is doing so from one balance, one approval set, and one key. +The relevant comparison is a fleet of hot wallets, which is what most teams run. A fleet can match the width. It cannot do so from one balance, one approval set, and one key. -Be precise about what the baseline actually is. A single address was never limited to one transaction at a time: it can sign and broadcast nonces `n`, `n+1`, `n+2`, and onward and have all of them unresolved, and [the producer mempool admits them](/node/technical-reference#giga-mode-behavior-and-per-block-limits) as long as they arrive in order. The constraint is not how many it can have outstanding but that they form a single ordered queue, so one that never lands strands every later nonce behind it. +The baseline is not one transaction at a time. A single address can sign and broadcast nonces `n`, `n+1`, `n+2`, and onward and have all of them unresolved, and [the producer mempool admits them](/node/technical-reference#giga-mode-behavior-and-per-block-limits) as long as they arrive in order. The constraint is ordering: the outstanding transactions form one queue, and one that never lands strands every later nonce behind it. | | One hot wallet | Fleet of N hot wallets | Public ERC-4337 bundler | Nonce lanes | | --- | --- | --- | --- | --- | | Submissions signed and unresolved at once | Many | Many per wallet | At most 4 per unstaked sender | `LANE_POOL_SIZE` (32 by default, 4096 maximum) | -| Outer transactions broadcast at once | Many | Many per wallet | The bundler's policy, not yours | `RELAYER_COUNT` | +| Outer transactions broadcast at once | Many | Many per wallet | Set by the bundler's policy | `RELAYER_COUNT` | | Are those submissions independent? | No: one ordered queue | Only across wallets, ordered within each | Yes | Yes: one sequence per lane | | Balances and approvals to maintain | One address | N addresses, approved N times | One address | One address | | Keys that can move funds | 1 | N | 1 | 1; relayers hold gas only | -| Recovering the in-flight set after a crash | Re-derive it from the chain | Re-derive it per wallet | The bundler's problem, not yours | Durable journal and deterministic reconciliation | +| Recovering the in-flight set after a crash | Re-derive it from the chain | Re-derive it per wallet | Handled by the bundler | Durable journal and deterministic reconciliation | | Failure blast radius | Everything behind the gap | Everything behind the gap, per wallet | One operation | One lane for execution, one bundle for validation | - Read the first two rows together. `LANE_POOL_SIZE` caps how many operations can be signed and unresolved at once; `RELAYER_COUNT` caps how many outer transactions are actually broadcast at once. Lanes give you a large pool of independent intents, not a large number of simultaneous transactions. The rough per-block submission width is `RELAYER_COUNT × MAX_OPS_PER_BUNDLE`. + Read the first two rows together. `LANE_POOL_SIZE` caps how many operations can be signed and unresolved at once; `RELAYER_COUNT` caps how many outer transactions are broadcast at once. Lanes provide a large pool of independent intents rather than a large number of simultaneous transactions. The approximate per-block submission width is `RELAYER_COUNT × MAX_OPS_PER_BUNDLE`. -Measured on Atlantic-2 on September 14, 2026, against the same venue call: one address with sequential nonces landed 1 operation per second sending serially and 7 per second pipelining one request at a time, and reached 51 per second only by sending all 1,024 transactions in one JSON-RPC batch. A fleet of 8 hot wallets also landed 51 per second. Nonce lanes landed 60 to 64 per second from one address whose EVM nonce never moved, against a block-gas ceiling of about 73 for that call shape. The method, the run shapes, and the caveats are in [Benchmarks](#benchmarks). +Measured on Atlantic-2 against the same venue call, one address with sequential nonces landed 1 operation per second sending serially and 7 per second pipelining one request at a time, and reached 51 per second only by sending all 1,024 transactions in one JSON-RPC batch. A fleet of 8 hot wallets also landed 51 per second. Nonce lanes landed 60 to 64 per second from one address whose EVM nonce never moved, against a block-gas ceiling of about 73 for that call shape. The method, the run shapes, and the caveats are in [Benchmarks](#benchmarks). -The pillars behind that table: +Six properties of the design produce those rows: -1. **One balance, many lanes.** Capital, approvals, and protocol state stay on one address. Width comes from lanes, not from splitting funds. A new lane costs nothing to open and is valid at sequence `0` immediately. -2. **A custody boundary you can reason about.** The only key that can create a valid operation is the funded account's. Relayer keys can be rotated, replaced, or lost with a bounded cost measured in gas. -3. **A frozen nonce on the hot path.** After the one-time delegation, the funded account's EVM nonce never moves while submitting. Nothing an RPC drops or a producer rejects can strand the account. -4. **No third-party bundler and no per-sender cap.** Keeping the bundling queue in-process removes the ERC-7562 `SAME_SENDER_MEMPOOL_COUNT` limit and the dependency on someone else's inclusion policy, while keeping every EntryPoint check that protects funds. -5. **Crash-safe by construction.** Signed operations and signed outer transactions are journaled and fsynced before broadcast. Replacement reuses the relayer nonce. Restart reconciliation is deterministic and refuses to create duplicate work when the state is ambiguous. -6. **Built for how Sei behaves.** The hot signing path performs no nonce reads, so an unreliable pending view costs nothing. Fast blocks and instant finality keep each relayer's receipt wait short, which is what makes `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` a meaningful per-block width rather than a theoretical one. +1. Capital, approvals, and protocol state stay on one address. Width comes from lanes, not from splitting funds. A new lane costs nothing to open and is valid at sequence `0` immediately. +2. The only key that can create a valid operation is the funded account's. Relayer keys can be rotated, replaced, or lost at a cost bounded by their gas balances. +3. After the one-time delegation, the funded account's EVM nonce does not move while submitting. Nothing an RPC drops or a producer rejects can strand the account. +4. The in-process bundling queue removes the ERC-7562 `SAME_SENDER_MEMPOOL_COUNT` limit and the dependency on a third-party bundler's inclusion policy, while every EntryPoint check that protects funds still runs. +5. Signed operations and signed outer transactions are journaled and fsynced before broadcast, replacement reuses the relayer nonce, and restart reconciliation is deterministic: it refuses to create new work when the recorded state is ambiguous. +6. The signing path performs no nonce reads, so an unreliable pending view costs nothing. Fast blocks and instant finality keep each relayer's receipt wait short, which is what makes `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` a usable per-block width. Submission concurrency is not execution parallelism. Independent lanes remove ordering between submissions. They do not make conflicting storage writes execute in parallel. A contract that funnels everything through one hot storage slot still serializes on that slot. See [Optimizing for parallelization](/evm/best-practices/optimizing-for-parallelization) and the [parallelization engine](/learn/parallelization-engine). - Block time and gas limits differ between today's [Twin Turbo Consensus](/learn/twin-turbo-consensus) and [Sei Giga](/learn/sei-giga). Size a relayer pool against the network you are actually submitting to, and measure rather than assuming. + Block time and gas limits differ between today's [Twin Turbo Consensus](/learn/twin-turbo-consensus) and [Sei Giga](/learn/sei-giga). Size a relayer pool against the network you submit to, and measure rather than assume. ## Tutorial: run the reference implementation @@ -210,7 +210,7 @@ The walkthrough below deploys the demo contracts, delegates a throwaway account, - Clone with submodules so the pinned `account-abstraction` and OpenZeppelin dependencies come along: + Clone with submodules so the pinned `account-abstraction`, OpenZeppelin, and `forge-std` dependencies are checked out: ```bash git clone --recurse-submodules https://github.com/sei-protocol/sei-nonce-lanes.git @@ -231,7 +231,7 @@ The walkthrough below deploys the demo contracts, delegates a throwaway account, forge test -vv ``` - The Foundry suite runs against the real EntryPoint v0.8 bytecode from the pinned dependency, placed at the canonical address inside the test VM. It proves that different lanes can land in any order, that an execution revert affects only its own lane, that an operation that is never submitted blocks nothing, that a gap on one shared lane reproduces sequential blocking, that one validation failure reverts the whole bundle, that lane `0` is rejected, and that a 50-lane bundle fits in one outer transaction. + The Foundry suite runs against the real EntryPoint v0.8 bytecode from the pinned dependency, placed at the canonical address inside the test VM. It verifies that different lanes can land in any order, that an execution revert affects only its own lane, that an operation that is never submitted blocks nothing, that a gap on one shared lane reproduces sequential blocking, that one validation failure reverts the whole bundle, that lane `0` is rejected, and that a 50-lane bundle fits in one outer transaction. @@ -270,7 +270,7 @@ The walkthrough below deploys the demo contracts, delegates a throwaway account, Set `TRADER_PRIVATE_KEY` to account `0`'s private key from the Anvil startup output, and `RELAYER_MNEMONIC` to the mnemonic printed by that same Anvil process. Starting relayers at index `1` keeps the funded account and relayer identities distinct; the application rejects overlapping identities. - Those are Anvil's published credentials, which is why this `.env` belongs to the fork and nowhere else. + Those are Anvil's published credentials, so this `.env` must only ever point at the fork. Deploy the demo contracts from the repository root using Anvil's unlocked account: @@ -327,7 +327,7 @@ The walkthrough below deploys the demo contracts, delegates a throwaway account, ``` - Mutating commands refuse to write to a remote Pacific-1 RPC unless `ALLOW_MAINNET=1` is set explicitly, and they refuse any remote-chain write when `TRADER_PRIVATE_KEY` is Anvil account `0` or `RELAYER_MNEMONIC` is the Anvil mnemonic. Those guards catch an accidental `SEI_CHAIN_ID=1329` run and the two most common leaked credentials, nothing more. They do not guard the separate Forge deployment command, and they do not make the demo production-ready. + Mutating commands refuse to write to a remote Pacific-1 RPC unless `ALLOW_MAINNET=1` is set explicitly, and they refuse any remote-chain write when `TRADER_PRIVATE_KEY` is Anvil account `0` or `RELAYER_MNEMONIC` is the Anvil mnemonic. Those guards catch only an accidental `SEI_CHAIN_ID=1329` run and the two most common leaked credentials. They do not cover the separate Forge deployment command, and they do not make the demo production-ready. @@ -468,16 +468,18 @@ npm run baseline Using a gas-only relayer key so nothing of value is at risk, `baseline` measures the constraint directly. It sends nonce `n + 1` while deliberately skipping `n`, waits to see whether that transaction can be included above a gap, fills the gap with nonce `n`, then checks whether the skipped-ahead transaction lands once the gap is closed. The verdict is one of four outcomes: -| Verdict | What the RPC path did | In flight per sender | +| Verdict | What the RPC path did | What happened to the out-of-order transaction | | --- | --- | --- | -| `REJECTED at admission` | Refused the gapped nonce, typically with `bad nonce` | 1; a gap cannot even be created | -| `ADMITTED then dropped` | Returned a hash, then discarded the transaction | 1 in effect, despite admission succeeding | -| `QUEUED, then included once the gap filled` | Held it and released it in nonce order | More than 1, released in order | -| `ACCEPTED and included above a gap` | Included it out of order | More than 1, not strictly ordered on this path | +| `REJECTED at admission` | Refused the gapped nonce, typically with `bad nonce` | Refused synchronously, so the sender learns at once and no gap forms on this path | +| `ADMITTED then dropped` | Returned a hash, then discarded the transaction | Lost without an error; it did not land even after the gap was filled | +| `QUEUED, then included once the gap filled` | Held it and released it in nonce order | Waited, then landed after nonce `n` arrived | +| `ACCEPTED and included above a gap` | Included it out of order | Landed before nonce `n`; this path does not enforce nonce order | -The probe samples both the `latest` and `pending` nonce tags before, during, and after, and prints any rejection message verbatim, because the exact string is the finding. If the two tags already disagree before the probe starts, it warns you: either the account has work in flight or the node reports a mempool-derived pending nonce, and in both cases the gap it is about to create may not be a gap at all, so the verdict cannot be attributed to nonce ordering. Compare the result with a `submit` run, where 24 operations from one account are mutually independent and one failure strands nothing. +The probe measures gap handling only. How many consecutive nonces one sender can have outstanding is a separate question, answered in [Why this is hard to replicate](#why-this-is-hard-to-replicate): many, as long as they arrive in order. -The verdict depends on the path. On September 14, 2026, two Atlantic-2 RPC endpoints answered differently on the same day: a dedicated provider queued the gapped transaction and released it once the gap filled, while the public endpoint returned a hash and then dropped it, so it never landed even after the gap was filled. Neither rejected it at admission. Probe the path you will actually use. +The probe samples both the `latest` and `pending` nonce tags before, during, and after, and prints any rejection message verbatim, because the exact message is the finding. If the two tags already disagree before the probe starts, it warns you: either the account has work in flight or the node reports a mempool-derived pending nonce, and in both cases the gap it is about to create may not be a gap at all, so the verdict cannot be attributed to nonce ordering. Compare the result with a `submit` run, where 24 operations from one account are mutually independent and one failure strands nothing. + +The verdict depends on the path. In one session, two Atlantic-2 RPC endpoints answered differently: a dedicated provider queued the gapped transaction and released it once the gap filled, while the public endpoint returned a hash and then dropped it, so it never landed even after the gap was filled. Neither rejected it at admission. Probe the path you will actually use. ## Tuning @@ -490,18 +492,18 @@ Three knobs shape a run. They interact, so change one at a time and measure. `MAX_OPS_PER_BUNDLE` trades amortized outer-transaction overhead for the size of the shared validation failure domain. A width of `1` gives maximum isolation and the highest overhead. Execution reverts stay per-operation at any width. The relayer caps signed transaction gas below the block gas limit it read at startup and rejects a bundle whose estimate cannot fit. - A bundle does not need the sum of its operations' declared call gas, which is the arithmetic most people try first. The EntryPoint checks before each operation that enough gas remains to honor that operation's `callGasLimit`, and whatever an operation leaves unspent passes to the next one, so the outer transaction needs the gas the bundle actually consumes plus about one operation's declared limit in reserve. Declaring more than you need is still not free: v0.8 charges 10 percent of unused call gas beyond a 40,000-gas threshold, and each operation reserves prefund from the deposit against its declared limits rather than its measured cost, so an inflated limit ties up deposit that is only refunded afterwards. + A bundle does not need the sum of its operations' declared call gas. The EntryPoint checks before each operation that enough gas remains to honor that operation's `callGasLimit`, and whatever an operation leaves unspent passes to the next one, so the outer transaction needs the gas the bundle actually consumes plus about one operation's declared limit in reserve. Over-declaring still has a cost: v0.8 charges 10 percent of unused call gas beyond a 40,000-gas threshold, and each operation reserves prefund from the deposit against its declared limits rather than its measured cost, so an inflated limit ties up deposit that is only refunded afterwards. - That is why `CALL_GAS_LIMIT` is unset by default. Both submit paths measure the real call gas for the chain and call shape and add 25 percent; the variable is only a floor, and it changes anything only when you set it above that measurement. Because the EntryPoint reserves each operation's declared call gas before running it, a floor above the measurement costs block gas, and therefore operations per block, without changing the gas actually used. + That is why `CALL_GAS_LIMIT` is unset by default. Both submit paths measure the real call gas for the chain and call shape and add 25 percent; the variable is only a floor, and it has an effect only when you set it above that measurement. Because the EntryPoint reserves each operation's declared call gas before running it, a floor above the measurement costs block gas, and therefore operations per block, without changing the gas actually used. - As a reference point, on Atlantic-2 on September 3, 2026, with `CALL_GAS_LIMIT=500000`, which was the default at the time, the real-swap path sustained 77 operations per bundle; 78 no longer fit the 12,500,000 block gas limit in effect that day and failed safely during simulation. Width 76 produced the best observed submission rate for that call shape, 47.7 landed swaps per second. Treat these as measurements for one call shape, one configuration, and one network at one point in time, not as protocol limits. + As a reference point, on Atlantic-2 with `CALL_GAS_LIMIT=500000`, the default in an earlier revision of the repository, the real-swap path sustained 77 operations per bundle; 78 no longer fit the 12,500,000 block gas limit in effect during that run and failed safely during simulation. Width 76 produced the best observed submission rate for that call shape, 47.7 landed swaps per second. These are measurements for one call shape, one configuration, and one network state, not protocol limits. - The mock venue is a heavier call. On September 14, 2026, with `CALL_GAS_LIMIT` unset, each `place` operation used about 331,000 gas of outer transaction gas, so 36 operations was the widest bundle that fit and 37 failed safely in simulation. Widths 8, 16, and 36 packed 4, 2, and 1 bundles into a 12,500,000-gas block; width 9, which should fit 4, landed 3. Wide bundles also queue behind each other, because only one 11,700,000-gas bundle fits a block. With more than about 8 relayers at width 32 or 36, receipt waits exceed the default `BUNDLE_RECEIPT_TIMEOUT_MS`; raise the timeout for those shapes rather than paying for fee-bumped replacements of bundles that will land anyway. + The mock venue is a heavier call. In the [benchmark session](#benchmarks), with `CALL_GAS_LIMIT` unset, each `place` operation used about 331,000 gas of outer transaction gas, so 36 operations was the widest bundle that fit and 37 failed safely in simulation. Widths 8, 16, and 36 packed 4, 2, and 1 bundles into a 12,500,000-gas block; width 9, which should fit 4, landed 3. Wide bundles also queue behind each other, because only one 11,700,000-gas bundle fits a block. With more than about 8 relayers at width 32 or 36, receipt waits exceed the default `BUNDLE_RECEIPT_TIMEOUT_MS`; raise the timeout for those shapes instead of paying for fee-bumped replacements of bundles that land anyway. Each relayer has one sequential outer transaction stream. Under favorable admission and inclusion conditions, the immediate submission width is roughly `RELAYER_COUNT × MAX_OPS_PER_BUNDLE` per block. That is a planning heuristic, not a throughput guarantee: RPC latency, block limits, state contention, gas, and producer policy still apply. Public RPC endpoints have [rate limits](/evm/networks); use a [dedicated provider](/learn/rpc-providers) or your own node for anything beyond a demo. - One relayer's bundle cycle is five sequential RPC round trips (gas estimate, fee estimate, block number, broadcast, receipt poll) plus inclusion, about 1.5 seconds on a 120 ms endpoint. Below roughly 120 operations in flight the pool is the bottleneck and throughput scales with `RELAYER_COUNT`; above that, block gas is. In the September 14, 2026 measurements, 4 relayers landed 2.7, 8.9, 15.3, 24.3, 45.4, and 50.5 operations per second at widths 1, 4, 8, 16, 32, and 36, and width 4 landed 2.2, 8.9, 15.4, 27.9, and 47.2 per second with 1, 4, 8, 16, and 32 relayers. + One relayer's bundle cycle is five sequential RPC round trips (gas estimate, fee estimate, block number, broadcast, receipt poll) plus inclusion, about 1.5 seconds on a 120 ms endpoint. Below roughly 120 operations in flight the pool is the bottleneck and throughput scales with `RELAYER_COUNT`; above that, block gas is. In the [benchmark session](#benchmarks), 4 relayers landed 2.7, 8.9, 15.3, 24.3, 45.4, and 50.5 operations per second at widths 1, 4, 8, 16, 32, and 36, and width 4 landed 2.2, 8.9, 15.4, 27.9, and 47.2 per second with 1, 4, 8, 16, and 32 relayers. @@ -537,7 +539,7 @@ The [repository README](https://github.com/sei-protocol/sei-nonce-lanes#configur ## Benchmarks -`app/bench/` measures the lane path against the thing it replaces: one EOA sending ordinary transactions with sequential nonces. Every mode calls the same `MockPerpVenue.place` on the configured `VENUE`, so the numbers differ only in how the calls were submitted. The commands read the root `.env`, and the shell environment overrides it: +`app/bench/` measures the lane path against the baseline it replaces: one EOA sending ordinary transactions with sequential nonces. Every mode calls the same `MockPerpVenue.place` on the configured `VENUE`, so the numbers differ only in how the calls were submitted. The commands read the root `.env`, and the shell environment overrides it: ```bash cd app @@ -568,26 +570,26 @@ The `bench:baseline` modes are the ways one address can drive a sequential queue ### Measured against the alternatives -Every row below was measured on Atlantic-2 on September 14, 2026. Conditions: block gas limit 12,500,000; base fee 50 gwei plus a 1 gwei tip; about 2.0 blocks per second under load; 120 ms warm request latency to the configured endpoint. Each direct `place` transaction used 328,425 gas and each lane operation about 331,000 gas of outer transaction gas, so the block admits 36 to 38 operations however they are submitted: roughly 73 landed operations per second for this call shape. Chain-side rates divide landed operations by the block-timestamp span, which Sei stamps in whole seconds. Client-side rates divide by wall time from first broadcast to last receipt. +Every row below comes from one measurement session on Atlantic-2. Conditions during the session: block gas limit 12,500,000; base fee 50 gwei plus a 1 gwei tip; about 2.0 blocks per second under load; 120 ms warm request latency to the configured endpoint. Each direct `place` transaction used 328,425 gas and each lane operation about 331,000 gas of outer transaction gas, so the block admits 36 to 38 operations however they are submitted: roughly 73 landed operations per second for this call shape. Chain-side rates divide landed operations by the block-timestamp span, which Sei stamps in whole seconds. Client-side rates divide by wall time from first broadcast to last receipt. | Submission | Landed ops/s, chain-side | Client-side | Block gas used, average | | --- | --- | --- | --- | -| One EOA, serial send and wait (30 tx) | 1.0 | 1.0 | | -| One EOA, pipelined, one request in flight (200 tx) | 6.9 | 6.7 | | +| One EOA, serial send and wait (30 tx) | 1.0 | 1.0 | not recorded | +| One EOA, pipelined, one request in flight (200 tx) | 6.9 | 6.7 | not recorded | | Tutorial lanes, 4 relayers × 4 per bundle (24 ops) | 8.0 | 5.7 | 11% | -| Fleet of 4 wallets × 100 tx | 26.7 | 24.8 | | +| Fleet of 4 wallets × 100 tx | 26.7 | 24.8 | not recorded | | Fleet of 8 wallets × 64 tx | 51.2 | 49.7 | 54% | | One EOA, 1,024 tx in one JSON-RPC batch | 51.2 | 47.3 | 68% | | Lanes, 32 relayers × 8 per bundle (1,024 ops) | 60.2 | 55.3 | 83% | | Lanes, 16 relayers × 16 per bundle (1,024 ops) | 64.0 | 54.6 | 81% | | Lanes, 16 relayers × 36 per bundle (1,024 ops) | 64.0 | 55.1 | 88% | -What the numbers say: +What the numbers show: -- **Block gas bounds raw throughput, not the nonce model.** Lanes came closest to the ceiling, with 28 of 31 blocks at least 90 percent full in the 16 × 36 run, from one address whose EVM nonce never moved. A single ordered queue matched the fleet only when every transaction left in one JSON-RPC batch; one request at a time, it is bounded by the round trip, about 7 per second. -- **Small relayer pools are client-bound.** Throughput scales with `RELAYER_COUNT` until roughly 120 operations are in flight, then block gas takes over. The relayer and width series are in [Tuning](#tuning). -- **A gap costs the whole queue.** One lost transaction in the sequential queue (`pipelined-gap`, nonce 50 of 100) left the 49 transactions behind it accepted but unmined until the client resent it 15 seconds later. In the lane runs, the deliberately reverting order consumed only its own lane while the rest of its bundle landed. -- **RPC paths differ.** `npm run baseline` gave [two different verdicts](#optional-see-the-baseline-you-are-escaping) for two Atlantic-2 endpoints on the same day. +- Block gas, not the nonce model, set the ceiling. Lanes came closest to it, with 28 of 31 blocks at least 90 percent full in the 16 × 36 run, from one address whose EVM nonce never moved. A single ordered queue matched the fleet only when every transaction left in one JSON-RPC batch; one request at a time, it is bounded by the round trip, about 7 per second. +- Small relayer pools are client-bound. Throughput scales with `RELAYER_COUNT` until roughly 120 operations are in flight, then block gas takes over. The relayer and width series are in [Tuning](#tuning). +- A gap costs the whole queue. One lost transaction in the sequential queue (`pipelined-gap`, nonce 50 of 100) left the 49 transactions behind it accepted but unmined until the client resent it 15 seconds later. In the lane runs, the deliberately reverting order consumed only its own lane while the rest of its bundle landed. +- RPC paths differ. `npm run baseline` gave [two different verdicts](#optional-see-the-baseline-you-are-escaping) for two Atlantic-2 endpoints in the same session. These are measurements of one call shape, one network state, and one client machine, not protocol limits. Cost was about 0.02 SEI per landed operation at the effective 52 gwei. Rerun the benchmarks against the network and endpoint you will actually use; block time and gas limits differ between [Twin Turbo Consensus](/learn/twin-turbo-consensus) and [Sei Giga](/learn/sei-giga). The raw records under `app/.state/bench/` include a journal for `bench:lanes`, which you should treat like `pending-ops.json`. @@ -595,7 +597,7 @@ What the numbers say: ## Adapting it to your contract -The demo's `MockPerpVenue` is a stand-in that reverts on slippage so a failure is observable. Swapping it for a real target is a matter of encoding a different call. `LaneAccount.execute(target, value, data)` forwards any call, and the target sees the funded EOA as `msg.sender`: +The demo's `MockPerpVenue` is a stand-in that reverts on slippage so a failure is observable. Swapping it for a real target means encoding a different call. `LaneAccount.execute(target, value, data)` forwards any call, and the target sees the funded EOA as `msg.sender`: ```ts import { encodeFunctionData } from 'viem'; @@ -661,7 +663,7 @@ The repository is explicit about what it leaves out. Before this design touches - load, fault-injection, and live-chain recovery testing. - **Delegation is powerful.** EIP-7702 changes the code that executes at your address. Before delegating, verify the implementation source and deployed address, verify the target chain, inspect any existing delegation, and use a throwaway account for this demo. `submit` refuses to run if the current designator does not exactly match `LANE_ACCOUNT_IMPL`. + EIP-7702 changes the code that executes at your address. Before delegating, verify the implementation source and deployed address, verify the target chain, inspect any existing delegation, and use a throwaway account for this demo. `submit` refuses to run if the current designator does not exactly match `LANE_ACCOUNT_IMPL`. Treat `.env`, `app/.state/`, signed raw transactions, and RPC URLs containing credentials as sensitive. The journal does not contain private keys, but it contains signed UserOperations and replayable raw transactions until their nonces are consumed. Clone the repository for a teammate and create fresh keys; do not copy a working directory. @@ -685,12 +687,14 @@ Treat `.env`, `app/.state/`, signed raw transactions, and RPC URLs containing cr Common validation causes: `AA24` is an invalid signature or the wrong EIP-712 chain or domain; `AA25` is a stale or incorrect lane sequence; an insufficient EntryPoint prefund; or delegation to the wrong account implementation. Run `npm run status` and resolve the cause before widening bundles or retrying. - The bundle no longer fits the current block gas limit. Nothing in a bundle that fails simulation is broadcast or consumed. Rerun with a smaller `MAX_OPS_PER_BUNDLE`; the durable queue is repacked at the smaller width. + `AA95` is the EntryPoint's report that the outer transaction had less gas left than an operation's declared limits require. In general it points to an outer gas limit or estimation headroom that is too low, not to network capacity. While widening bundles, the usual cause is the block gas limit: simulation cannot find an outer gas amount under that limit that satisfies the check, and the relayer never signs above the limit it read at startup. Nothing in a bundle that fails simulation is broadcast or consumed. Rerun with a smaller `MAX_OPS_PER_BUNDLE`; the durable queue is repacked at the smaller width. Only one lane-based process may use an account at a time, even when `submit` and `swap:submit` use different journals. Stop the other process. A lock whose recorded PID is no longer alive is removed automatically on the next run. - `could not acquire the lock … within 2000ms` is different. The lock file exists but its contents could not be read, which is what a live holder can look like while it is still writing the file. Removing such a lock would evict a running process, so the next run waits for it instead. If the message persists across runs, no process owns the file and you can delete it by hand. + `could not acquire the lock … within 2000ms` is different. The lock file exists but its contents could not be parsed, so the process cannot tell whether a live holder owns it, and it refuses to remove a lock it cannot attribute. A torn or truncated file left by a dead process and a live holder caught mid-publish produce the same message, and the message repeating across runs does not distinguish between them. Removing a lock that a live process holds lets two processes sign for the same account, which produces duplicate operations on the same lane sequences and conflicting relayer nonces. + + Before deleting a lock file by hand, confirm that no `submit`, `swap:submit`, or `bench:lanes` process is running against this account. Read the `pid` from the file if it is legible, and check the process table on every host that shares the state directory. Both locks live under `app/.state/`: the account-wide run lock (`sender--
.lock`, or `SENDER_RUN_LOCK_PATH`) and one `.lock` file next to each journal. Delete the file only after that check. Do not delete the journal and do not send the relayer's next nonce manually. Run `npm run submit` again once the RPC can answer receipt and nonce queries. If the application reports partial lane consumption or a state it cannot reconcile, stop and inspect the EntryPoint events, every attempted transaction hash, the relayer's confirmed nonce, and each lane sequence. diff --git a/snippets/nonce-lane-diagrams.jsx b/snippets/nonce-lane-diagrams.jsx index 856e5e0..a202c13 100644 --- a/snippets/nonce-lane-diagrams.jsx +++ b/snippets/nonce-lane-diagrams.jsx @@ -97,7 +97,7 @@ export const NonceLanes = () => { lane 0 - rejected by LaneAccount: SDKs default to key 0, and a book on key 0 is one queue again + rejected by LaneAccount: SDKs default to key 0, and work on key 0 is one queue again lane 1 {chip(120, 210, 'seq 0', 'ok', 'l1s0')} @@ -356,7 +356,7 @@ export const NonceLaneFailureIsolation = () => { {lane(582, 70, 'lane 30', 'AA25 stale seq', bad, false, 'b30')} {lane(678, 70, 'lane 31', 'not consumed', ink, true, 'b31')} {lane(774, 70, 'lane 32', 'not consumed', ink, true, 'b32')} - bad signature, stale sequence, or thin prefund fails before any execution + bad signature, stale sequence, or insufficient prefund fails before any execution every bundle is simulated before broadcast, so this is normally caught for free; MAX_OPS_PER_BUNDLE sets the size of this failure domain @@ -367,7 +367,7 @@ export const NonceLaneFailureIsolation = () => { leaves its lane sequence untouched; neighboring lanes stay valid and the journal requeues it
-
Three different failures, three different blast radii. An execution revert costs one lane sequence and nothing else. A validation failure costs the outer transaction attempt but no sequences. A dropped bundle costs nothing on chain, which is exactly the case that strands a sequential account.
+
The three failures have different blast radii. An execution revert costs one lane sequence and nothing else. A validation failure costs the outer transaction attempt but no sequences. A dropped bundle costs nothing on chain, which is exactly the case that strands a sequential account.
); };