Skip to content

feat: merge-train/spartan-v5#24576

Merged
AztecBot merged 7 commits into
v5-nextfrom
merge-train/spartan-v5
Jul 7, 2026
Merged

feat: merge-train/spartan-v5#24576
AztecBot merged 7 commits into
v5-nextfrom
merge-train/spartan-v5

Conversation

@AztecBot

@AztecBot AztecBot commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

BEGIN_COMMIT_OVERRIDE
fix(node): fork-aware getWorldState that fails closed on sync errors (#24563)
fix(validator): prevent double-sign on stuck-duty cleanup race and handle pg pool errors (A-1313, A-1314) (#24556)
fix(p2p): bound reqresp response buffering before size check (#24553)
fix(p2p): reject reqresp requests larger than a single muxer frame (#24554)
fix(p2p): process one request per reqresp stream to enforce rate limit (#24552)
fix(validator): fail closed on signing-protection persistence gaps (A-1315, A-1317, A-1318) (#24565)
feat(p2p): retain finalized txs a configurable margin behind finality (#24329)
END_COMMIT_OVERRIDE

…24563)

## Context

`getWorldState` backs most node RPC tree/witness queries, and had three
related problems:

- World-state sync failures were swallowed (logged and ignored), so
queries were served from whatever state the node had — stale reads with
no signal to the caller.
- For tag and number queries, the target block was resolved *after*
syncing, so an archiver tip advancing mid-request (routine during
catch-up) made valid queries fail spuriously with "not yet synced".
- Resolution pinned only a block *height*, never a *fork*: a reorg
replacing the block at that height between resolution, sync, and
snapshot read was served silently — e.g. a `proven` query could be
answered with a same-height block from a new fork that is not proven.

## Approach

- Every query variant (number, hash, archive, tag) now resolves up front
to a concrete (block number, block hash) via the archiver's
`getBlockData`, and the sync is driven to that exact block and hash so
the synchronizer barriers on it and detects fork mismatches.
- A new `WorldStateSynchronizer.getVerifiedSnapshot(blockNumber,
blockHash)` verifies the snapshot's own archive view matches the
requested fork before handing it out (block 0 is checked against the
initial header hash, since its snapshot predates the genesis archive
leaf), closing the remaining window between sync and snapshot read.
- Sync and fork-verification failures surface as
`WorldStateSynchronizerError` and are retried a few times with the query
re-resolved each time, so prunes and fork flips heal onto the current
chain instead of failing or serving wrong-fork state. Hash and archive
resolution misses stay terminal with a clear reorg error; tag and number
misses are treated as transient.
- `proposed`/`latest` queries keep their "latest on the current fork"
semantics: plain sync plus the committed db, unverified.

## API changes

- `WorldStateSynchronizer` gains `getVerifiedSnapshot(blockNumber,
blockHash)`, implemented by `ServerWorldStateSynchronizer`
(fork-verified) and `TXESynchronizer` (passthrough, no reorgs). Other
`getSnapshot` callers are unchanged.
- Node RPC queries at a block now fail instead of silently returning
stale state when world state cannot sync, and the error messages for
unknown or unsyncable blocks have changed accordingly.

Fixes A-1339
spalladino and others added 6 commits July 7, 2026 10:34
…ndle pg pool errors (A-1313, A-1314) (#24556)

Hardens the validator HA signing path against an honest-validator
double-sign and a Postgres-induced crash. Both issues live entirely in
the HA signer.

## Context

**A-1313 (stuck-duty cleanup race → slashable double-sign).**
`signWithProtection` discarded the boolean from `recordSuccess` and
returned the signature regardless. If the background cleanup loop
deleted our `SIGNING` protection row while the (remote) signer was slow,
the signature was broadcast with no protection record in place — so a
later proposal for the same slot with different data could sign freely,
i.e. slashable equivocation by an honest validator. The race is live
today: `SequencerClient.start()` starts the validator client, which
starts the cleanup loop. (The issue text claimed this was masked by
#1111 never starting the cleanup loop; that is stale and no longer
true.)

**A-1314 (pg.Pool without an 'error' listener crashes HA validators).**
`createHASigner` built a `new Pool(...)` with no `pool.on('error')`. pg
re-emits idle-client errors (e.g. a Postgres restart severing an idle
connection) on the pool; with no listener Node escalates to
`uncaughtException` and crashes the process — taking down every HA
replica sharing the DB at once.

## Approach

Four defense-in-depth fixes for A-1313 (part 1 alone closes the
slashable broadcast; parts 2-3 remove the ways the race arises) plus the
independent A-1314 liveness fix:

- Treat `recordSuccess === false` as a signing failure: throw a new
`SigningLockLostError` and never return/broadcast the signature. The
duty is not deleted, since we no longer own the row. This is the hard
safety backstop: even under pathological timer behavior the worst case
is a failed duty, never a broadcast double-sign.
- Bound each signer call with a hard timeout (`executeTimeout`),
configured via `signerCallTimeoutMs` (default 30s) and clamped at
construction to `maxStuckDutiesAgeMs / 2` (default 144s / 2). Since
`signWithProtection` is the only writer of `SIGNING` rows and every path
through it is bounded by this clamped timeout, an in-flight signing
always times out and releases its row well before stuck-duty cleanup
could reclaim it — so cleanup can never race a live signing. A timeout
takes the existing failure path (release the lock, then throw), and the
orphaned signer promise is discarded.
- Add a request timeout (`AbortSignal.timeout`) to both Web3Signer
key-store fetches, surfaced as a clear timeout error instead of hanging.
The keystore is only constructed directly in tests today (production
uses node-keystore's remote signer), so the timeout is a constructor
option defaulting to 30s.
- Register a `pool.on('error', ...)` structured-log handler in
`createHASigner` (covering both the created and injected pool), and add
the same handler to the e2e HA fixture pools. Only message/code is
logged, never the raw error.

## API changes

- New env var `VALIDATOR_SIGNER_CALL_TIMEOUT_MS` (default 30000)
controls the per-call signing timeout (effective value clamped to
`maxStuckDutiesAgeMs / 2`), exposed as config field
`signerCallTimeoutMs`.
- The confusable existing config field `signingTimeoutMs` (how long to
wait for a *peer* node's in-progress signing, default 3s) is renamed to
`peerSigningTimeoutMs` so each name says whose wait it bounds. Its env
var `VALIDATOR_HA_SIGNING_TIMEOUT_MS` is already shipped and is kept
unchanged; the derived CLI flag follows the field rename
(`--sequencer.signingTimeoutMs` → `--sequencer.peerSigningTimeoutMs`).

Each fix follows red/green with unit tests in `validator-ha-signer`
(errors, signer, slashing-protection service, LMDB + Postgres backends,
factory) and `validator-client` (Web3Signer key store).

Fixes A-1313
Fixes A-1314
## Context

`readMessage` on the requesting side accumulated **all** response chunks
into memory before the snappy `maxSizeKb` validation ran (that guard
lives in `SnappyTransform.inboundTransformData`, which only executes
after the full compressed stream has been buffered). A peer we dialed
could send a valid `SUCCESS` status chunk and then stream data up to the
request timeout, forcing the requesting node to buffer arbitrarily much
(~1.2 GB/response at 1 Gbps within the 10s timeout) → remote-triggerable
OOM, amplified by concurrent in-flight requests.

## Approach

- Track a running byte total in the `readMessage` loop and abort as soon
as it exceeds a bound derived from `maxSizeKb`, before buffering more
chunks. The bound is `maxSizeKb * 1024 * 2`: snappy can expand
incompressible input to ~1.17x, so 2x sits comfortably above the worst
case (a legitimate max-size response is never rejected) while capping
buffered memory at twice the permitted post-decompression size.
- Uses `chunk.byteLength` rather than `chunk.subarray().length` —
calling `subarray()` on a `Uint8ArrayList` consolidates its backing
buffers into a copy, which would materialize the very chunk we are
trying to reject.
- Throws a dedicated `ResponseSizeLimitExceededError`, penalized as a
`LowToleranceError` via the existing
`categorizeResponseError`/`handleResponseError` path, so the offending
peer's stream is torn down and it is scored down.
- The existing decompressed-size preamble check in
`inboundTransformData` still guards the decompression-bomb variant; this
closes the residual reception-side buffering gap. Per-request bound only
— aggregate in-flight amplification across peers is tracked separately.

Fixes A-1399
…24554)

A reqresp request payload must fit in a single muxer frame: yamux splits
writes larger than 64KiB (minus the 12-byte frame header) into multiple
frames, each of which arrives at the responder as a separate chunk, and
the responder never reassembles a request from multiple chunks. A
request type growing past that limit would surface as a confusing
decoding error on the responder instead of failing at the sender.

- Assert the payload size in `sendRequestToPeer` before dialing,
throwing a descriptive `OversizedReqRespRequestError` locally. The check
runs before the generic error handling so the remote peer is not
penalized for a local bug.
- Pin `maxMessageSize` on the yamux muxer to its library default (64KiB)
so a dependency upgrade cannot silently change the limit the assertion
relies on.
- Tests: an oversized payload is rejected without dialing the peer and
without penalizing it; a payload at exactly the limit round-trips
successfully.

Related to #24552.

## No current subprotocol can exceed the limit

The limit is 65,524 bytes (64KiB yamux frame minus the 12-byte header).
**No reqresp subprotocol today can produce a request anywhere near it**,
so this assertion cannot fire on legitimate traffic:

- `GOODBYE`: 1 byte (the reason code).
- `PING`: a few bytes.
- `STATUS`: a `StatusMessage` (component versions string, block numbers,
block hash) — tens of bytes.
- `AUTH`: an `AuthRequest` (`StatusMessage` plus a 32-byte challenge) —
tens of bytes.
- `TX`: the request type is `TxHashArray` (4 + 32·n bytes), but this
subprotocol currently has no sender — only the server-side handler
remains.
- `BLOCK_TXS`: the only request that scales with anything. Serialized as
`archiveRoot` (32 bytes) + tx-indices `BitVector` (4 + ⌈N/8⌉ bytes, N =
tx count of the proposal) + tx-hashes commitment (32 bytes) + optional
full-hash vector (4 + 32·H bytes). Pinned-peer and smart-peer requests
send indices only (H = 0). Dumb-peer requests include full hashes but
chunk them to `txBatchSize`, which is always the default 8 in production
(`BatchTxRequester` is constructed without opts, and the option is not
wired to any config). N is capped at deserialization by
`MAX_TXS_PER_BLOCK = 2^16`, so even a maliciously large proposal yields
a worst-case request of ~8.5KB — about 13% of the limit. A realistic
32-tx block produces requests of a few hundred bytes.

Breaching the limit would take a proposal with ~460k txs (which
`BitVector.fromBuffer` rejects at 2^16 anyway) or a `txBatchSize` over
~1,800 with full hashes enabled — neither is reachable today. The
assertion exists so that if a future change makes a request type scale
past the limit, it fails loudly at the sender instead of as a decoding
error at the responder.
#24552)

## Context

The reqresp rate limiter is consulted once per inbound stream
(`streamHandler`), but the sub-protocol handler was invoked once per
chunk read from that stream (`processStream`). Req/resp is
one-request-one-response and an honest sender writes a single payload
before half-closing, but a malicious peer can open one stream (costing a
single rate-limit token) and then push many request frames on it — each
frame arrives as its own chunk and drives a full handler invocation
(mempool / block / tree lookups). Per-peer and global rate limits are
bypassed by the fan-out factor.

## Approach

Make `processStream` handle exactly one request per stream: after
emitting the response for the first chunk, the pipeline generator
returns instead of looping over the rest of the source. Extra frames a
peer queued on the same stream are discarded when the stream closes, so
work is bounded to one request per token. This does not regress
legitimate traffic — no code path pipelines multiple requests on a
single stream, and the one-chunk-per-request framing is already in
force.

The alternative (per-invocation rate checks inside the loop) was
rejected: nothing pipelines, and mid-stream status signalling is broken
on the requester side, which parses the first chunk as status and
concatenates the rest as data.

Adds a unit test that drives `processStream` with three frames on one
stream and asserts the handler runs once and the sink receives a single
SUCCESS + response pair.

Fixes A-1324
…-1315, A-1317, A-1318) (#24565)

## Context

A store that must never silently become empty — the single-node
signing-protection LMDB, and (for the ordering fix) world-state — could
be wiped or bypassed by the `DatabaseVersionManager` / store-creation
path.

- **A-1315**: `writeVersion()` ran *before* `onOpen()`, and was a plain
non-atomic `writeFile`. A crash in between left a "valid" marker over an
empty/partial data dir; on restart the self-healing reset was skipped
forever (a stable wedge for world-state, which opens several native
stores in `onOpen`).
- **A-1318**: any non-ENOENT read/parse failure of the version file
(EACCES, EIO, truncation) fell back to `DatabaseVersion.empty()`, which
unconditionally triggered a reset — so a transient permissions/disk
error at startup rm-rf'd the signing-protection DB. The
`schemaVersionMismatchPolicy: 'throw'` added by A-1029 only guarded the
numeric-mismatch branches, not this one.
- **A-1317**: a missing `dataDirectory` silently selected a fresh
ephemeral tmp store on every start, giving no double-signing protection
across restarts, with only a `debug` log.

## Approach

- **A-1315**: `DatabaseVersionManager.open()` now opens the database
*before* writing the version marker, making the marker a post-commit
record — a crash before a durable open leaves no marker, so the next
start re-runs the reset. `writeVersion` is now an atomic durable write
(temp file → fsync → rename → best-effort directory fsync). The marker
is only (re)written when it would actually change (first boot, reset,
upgrade), which also avoids leaking a freshly opened DB if the write
fails and drops the per-boot fsync.
- **A-1318**: new `versionFileReadFailurePolicy` (`'reset'` default,
preserving existing behavior for archiver/p2p/world-state; `'throw'` for
signing protection). On `'throw'`, an unreadable version file fails
startup with an operator-actionable error and leaves data untouched.
Threaded through the kv-store `createStore` options.
- **A-1317**: `createLocalSignerWithProtection` now throws when no data
directory is configured, unless `allowEphemeralSigningProtection` (env
`VALIDATOR_ALLOW_EPHEMERAL_SIGNING_PROTECTION`, default false) is set,
in which case it warns loudly. The local network sets the flag by
default; the production default is strict fail-fast.

The rollup-address-change reset is intentionally left as-is: the LMDB
slashing DB relies on it (see the `cleanupOutdatedRollupDuties` no-op).

Reviewed by Codex and a second model; both confirmed the atomic-write
mechanics, ENOENT branching, migration idempotency, and the
schema-composition decision (a cross-field Zod refine was avoided
because it would break the downstream `.merge()`/`.extend()` chain, so
the invariant is enforced at the factory).

Operator-facing changes are documented in the v5 changelog.

Fixes A-1315
Fixes A-1317
Fixes A-1318
…#24329)

## What

Adds `keepFinalizedTxsForSlots` to the v2 tx pool. Instead of deleting a
finalized tx's data at the finalized tip, the pool keeps it for a
configurable number of slots behind finality. Default is `0` (current
behaviour). Prover nodes raise it automatically.

## Why

A prover node fetches a checkpoint's txs from its tx pool (`TxProvider`
→ pool first, reqresp fallback) and re-reads them for failure upload.

## How

- New `keepFinalizedTxsForSlots` config (`P2PConfig` + env
`P2P_KEEP_FINALIZED_TXS_FOR_SLOTS`, default `0`).
- `handleFinalizedBlock` resolves the slot margin to a block cutoff:
target slot = `finalizedSlot − margin`, find the checkpoint at or before
it, and use that checkpoint's last block as the deletion cutoff. Applied
to both the active-pool deletion and the `DeletedPool` finalize path.
Rounds to a checkpoint boundary, so it can retain slightly more than the
configured margin but never less, and never deletes past the finalized
block. A margin of `0` short-circuits to the previous behaviour.
- The "checkpoint at or before the target slot" lookup is a single
reverse range query, not a slot-by-slot scan: a new `{ fromSlot, limit,
reverse }` variant on `CheckpointsQuery` (backed by
`BlockStore.getCheckpointsBySlot`, a one-pass walk of the existing slot
index) returns the nearest checkpoint at or before the slot with `limit:
1, reverse: true`. All `CheckpointsQuery` resolution now lives in
`getCheckpointsData`.
- Prover nodes: `createAztecNodeService` floors
`keepFinalizedTxsForSlots` at `(proofSubmissionEpochs + 1) ×
epochDuration` (read from the rollup), taking the max with any
operator-configured value and warning when it raises it. This matches
the prover-node catch-up window.

## Testing

- New unit tests in `tx_pool_v2.test.ts`: the retain/delete boundary and
the checkpoint resolution when the target slot falls in a gap.
- New `getCheckpointsBySlot` tests in `block_store.test.ts`: reverse
exact-hit, gap walk-back, before-genesis, multi nearest-first, and the
forward direction.
- `fromSlot` schema round-trip added to the archiver and aztec-node
interface tests.
- Full `tx_pool_v2` (254), `block_store` (159), and interface (120)
suites pass.

Closes A-1274.

@ludamad ludamad left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Auto-approved

@AztecBot AztecBot added this pull request to the merge queue Jul 7, 2026
@AztecBot

AztecBot commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

🤖 Auto-merge enabled after 4 hours of inactivity. This PR will be merged automatically once all checks pass.

Merged via the queue into v5-next with commit 42eea48 Jul 7, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants