From 1b156cb916037734d121d0d8db508460428959dc Mon Sep 17 00:00:00 2001 From: shitikyan Date: Wed, 17 Jun 2026 23:15:49 +0400 Subject: [PATCH 1/2] fix(mempool): exempt l2psBatch from sequential nonce enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2PSBatchAggregator submits a consolidated `l2psBatch` transaction from the node's own ed25519 identity every aggregation tick. That tx carries amount=0 and no `nonce` GCR edit, so it never advances the node's account.nonce; its nonce is a monotonic-for-uniqueness value (getNextBatchNonce returns Date.now()*1000), not a sequential per-account counter. The `nonceEnforcement` fork added a strict `expected = account.nonce + 1 + pendingCount` TOCTOU recheck in Mempool.addTransaction for any sender with a numeric nonce. That check is correct for value-transfer txs but wrong for this system relay tx: the timestamp nonce can never equal account.nonce+1, so every batch is rejected with "Nonce TOCTOU recheck failed" and the aggregator retries forever — L2PS transactions never reach L1. Fix: exempt system relay tx types (currently just `l2psBatch`) from the sequential nonce check, mirroring how the non-hex `l2ps:consensus` system sender is already implicitly exempt. Replay safety for these txs comes from the in-mempool hash dedup, not the nonce — they carry no balance edits, so there is no double-spend surface. Observed on dev devnet: aggregator looping every ~10s with `tx.content.nonce=1781723273110000, expected=1`. Repro: send an L2PS tx (`bun scripts/send-l2-batch.ts --uid `), watch the batch aggregator logs. Co-Authored-By: Claude Opus 4.7 --- src/libs/blockchain/mempool.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/libs/blockchain/mempool.ts b/src/libs/blockchain/mempool.ts index fae57aed..a80303e9 100644 --- a/src/libs/blockchain/mempool.ts +++ b/src/libs/blockchain/mempool.ts @@ -191,8 +191,9 @@ export default class Mempool { // re-check fails, return error without inserting. // // Skip when not native, when no sender (genesis path), when - // fork inactive, or when the tx doesn't carry a nonce — - // those paths preserve the legacy behaviour bit-identically. + // fork inactive, when the tx doesn't carry a nonce, or when the + // tx is a system relay type (see below) — those paths preserve + // the legacy behaviour bit-identically. const senderFromRaw = transaction.content?.from const senderFrom = typeof senderFromRaw === "string" @@ -201,9 +202,25 @@ export default class Mempool { const txNonce = transaction.content?.nonce const blockHeight = getSharedState.lastBlockNumber ?? 0 + // System relay transactions carry a node-generated nonce that is + // monotonic-for-uniqueness, NOT a sequential per-account counter: + // `l2psBatch` is emitted by L2PSBatchAggregator from the node's + // own identity, carries amount=0 and no `nonce` GCR edit, so it + // never advances the sender's account.nonce. The sequential + // `account.nonce + 1 + pendingCount` check below is designed for + // value-transfer txs and would reject every batch (the + // timestamp-based nonce never equals account.nonce+1), trapping + // the aggregator in a permanent retry loop. Replay safety for + // these txs comes from the in-mempool hash dedup, not the nonce. + const SYSTEM_RELAY_TX_TYPES = new Set(["l2psBatch"]) + const isSystemRelayTx = + typeof transaction.content?.type === "string" && + SYSTEM_RELAY_TX_TYPES.has(transaction.content.type) + if ( senderFrom && typeof txNonce === "number" && + !isSystemRelayTx && isForkActive("nonceEnforcement", blockHeight) ) { try { From 15f930fdadcea2d326ac787f25f7116edfd31d30 Mon Sep 17 00:00:00 2001 From: shitikyan Date: Wed, 17 Jun 2026 23:30:42 +0400 Subject: [PATCH 2/2] review: gate l2psBatch nonce exemption on own identity + hoist Set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address greptile review on PR #945: - P1 (security): the nonce-enforcement bypass was conditioned solely on the caller-supplied `content.type`. Any signer reaching addTransaction could self-label a tx `l2psBatch` and skip the per-account nonce throttle, flooding the mempool with unique-hash timestamp-nonce txs. Gate the exemption on the tx originating from THIS node's own identity (getSharedState.publicKeyHex) — the aggregator only ever submits from the node's own keypair via a direct local call, and legitimate batch txs reach peers inside a block, not via mempool admission. A foreign `from` no longer matches, so the throttle still applies to everyone else. - P2: hoist SYSTEM_RELAY_TX_TYPES to module scope so the Set is not reallocated on every addTransaction call, and the exemption list is a discoverable top-level constant. Co-Authored-By: Claude Opus 4.7 --- src/libs/blockchain/mempool.ts | 53 +++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/src/libs/blockchain/mempool.ts b/src/libs/blockchain/mempool.ts index a80303e9..dabfa6bd 100644 --- a/src/libs/blockchain/mempool.ts +++ b/src/libs/blockchain/mempool.ts @@ -21,6 +21,19 @@ import { CHUNK_MEMPOOL_TX, chunkedInsert } from "./chainDb" import { isForkActive } from "@/forks" import { GCRMain } from "@/model/entities/GCRv2/GCR_Main" +/** + * System relay transaction types: node-generated txs that carry no + * balance edits and no `nonce` GCR edit, so they never advance the + * sender's account.nonce. Their `content.nonce` is monotonic-for- + * uniqueness (see L2PSBatchAggregator.getNextBatchNonce), NOT a + * sequential per-account counter, so the value-transfer nonce TOCTOU + * check in `addTransaction` must not apply to them. Admission is still + * gated on the tx originating from THIS node's own identity (see + * `addTransaction`) so an arbitrary signer cannot label a tx with one + * of these types to bypass the per-account nonce throttle. + */ +const SYSTEM_RELAY_TX_TYPES = new Set(["l2psBatch"]) + export default class Mempool { public static repo: Repository = null public static async init() { @@ -202,25 +215,37 @@ export default class Mempool { const txNonce = transaction.content?.nonce const blockHeight = getSharedState.lastBlockNumber ?? 0 - // System relay transactions carry a node-generated nonce that is - // monotonic-for-uniqueness, NOT a sequential per-account counter: - // `l2psBatch` is emitted by L2PSBatchAggregator from the node's - // own identity, carries amount=0 and no `nonce` GCR edit, so it - // never advances the sender's account.nonce. The sequential - // `account.nonce + 1 + pendingCount` check below is designed for - // value-transfer txs and would reject every batch (the - // timestamp-based nonce never equals account.nonce+1), trapping - // the aggregator in a permanent retry loop. Replay safety for - // these txs comes from the in-mempool hash dedup, not the nonce. - const SYSTEM_RELAY_TX_TYPES = new Set(["l2psBatch"]) - const isSystemRelayTx = + // System relay transactions (SYSTEM_RELAY_TX_TYPES, e.g. + // `l2psBatch`) carry a node-generated monotonic-for-uniqueness + // nonce, not a sequential per-account counter, and never advance + // the sender's account.nonce (no `nonce` GCR edit). The + // sequential `account.nonce + 1 + pendingCount` check below is + // built for value-transfer txs and would reject every batch (the + // timestamp nonce never equals account.nonce+1), trapping the + // L2PSBatchAggregator in a permanent retry loop. + // + // The exemption is gated on the tx originating from THIS node's + // OWN identity. The aggregator only ever submits batch txs from + // the node's own keypair, via a direct local addTransaction call; + // legitimate batch txs reach other nodes inside a block, not via + // mempool admission. Gating on own-identity means an arbitrary + // signer (or a remote peer) cannot self-label a tx `l2psBatch` + // to skip the per-account nonce throttle and flood the mempool — + // their `from` won't match this node's identity and they stay on + // the enforced path. Replay safety for the node's own batches + // comes from the in-mempool hash dedup above, not the nonce. + const ownIdentityHex = + getSharedState.publicKeyHex?.toLowerCase() ?? null + const isOwnSystemRelayTx = typeof transaction.content?.type === "string" && - SYSTEM_RELAY_TX_TYPES.has(transaction.content.type) + SYSTEM_RELAY_TX_TYPES.has(transaction.content.type) && + ownIdentityHex !== null && + senderFrom === ownIdentityHex if ( senderFrom && typeof txNonce === "number" && - !isSystemRelayTx && + !isOwnSystemRelayTx && isForkActive("nonceEnforcement", blockHeight) ) { try {