Skip to content

feat(pxe): hash-pinned node read cache (backport #24969) - #25114

Merged
nchamo merged 106 commits into
backport-to-v5-next-stagingfrom
cb/backport-24969-v5-next
Aug 5, 2026
Merged

nchamo merged 106 commits into
backport-to-v5-next-stagingfrom
cb/backport-24969-v5-next

Conversation

@AztecBot

@AztecBot AztecBot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Backport of #24969 (feat(pxe): hash-pinned node read cache) to v5-next, via the backport-to-v5-next-staging queue as per .claude/claudebox/backport.md.

Opened by hand after the automatic backport failed: run 31012053465.

Restructured to sync-first — the cherry-pick is now clean and there is no conflict resolution at all.

Why the automatic backport failed

The bot cherry-picks 1b345627e46031772edfe8edc383a9054f5fc097 onto backport-to-v5-next-staging, not onto v5-next itself. It hit exactly one conflict:

CONFLICT (modify/delete): yarn-project/pxe/src/tagging/recipient_sync/sync_tagged_private_logs.bench.test.ts
deleted in HEAD and modified in 1b345627e46

That is not a real code conflict — it is branch drift. #24969 edits that bench test, but the file did not exist on the staging branch: it was added to v5-next by #24275 (feat(pxe): constrained tag sync optimization and recipient logs sync benchmarks), which is in v5-next but not in staging. backport-to-v5-next-staging was 104 commits behind v5-next (merge base bb922d71857, with only #25089 unique to staging), so the cherry-pick was being applied to a tree that predated the file.

Commit structure

  1. chore: sync backport-to-v5-next-staging with v5-next — merge origin/v5-next into the staging branch. Clean merge, no conflicts.
  2. feat(pxe): hash-pinned node read cache (#24969)git cherry-pick -x of the merge commit. Applies cleanly: all 16 files, +1205 / −487, byte-identical to the upstream commit's diff.

There is no third commit: once the branch is synced with v5-next, the cause of the conflict is gone.

This also removes the follow-up hazard from the earlier version of this PR. sync_tagged_private_logs.bench.test.ts is present here and carries the #24969 update, so it points at node/benchmarked_node.js / withRecording rather than the deleted contract_function_simulator/benchmarked_node.js.

Note that merging this PR also brings v5-next into backport-to-v5-next-staging, which is the point of commit 1 — the staging branch stops being 104 commits stale.

Verification

  • Commit 2's diff compared against upstream 1b345627e46: identical content, differing only in blob hashes and hunk line offsets.
  • No dangling references to the deleted modules (contract_function_simulator/benchmarked_node, aztec_node_read_cache, BenchmarkedNodeFactory) anywhere in yarn-project.
  • Not built locally — this workspace is not bootstrapped (yarn-project/node_modules absent). Type-checking is left to CI.

Created by claudebox · group: slackbot · requested by Nico Chamo · Slack thread

AztecBot and others added 30 commits July 14, 2026 04:04
…bmission-window expiry

Decouple "a checkpoint prover failed" (a fact) from "the epoch failed" (a
decision). A proving or L1-submission fault now settles the EpochSession in the
non-declaring terminal 'stopped' instead of 'failed'; the reconciler rebuilds the
epoch over current canonical content each tick (retry-to-converge), cheap because
the broker reuses already-completed sub-proofs. An epoch is declared terminally
failed — with its post-mortem upload — only when its L1 proof-submission window
closes with the proven tip settled and the epoch still unproven, from
ProverNode.expireEpoch. This removes the racy, lagging-replica "was this a prune?"
classification entirely.

Deletes lastTickEpoch (the epoch-keyed anti-retry gate), the checkpointsMatch
upload-suppression in SessionManager.runSession, and the onSessionFailed callback.
The post-mortem upload moves to tryUploadEpochFailure(epoch, checkpoints), built
from the store's last-known canonical provers.
…fault path directly

Address review feedback on the retry-to-converge change:

- checkEpochExpiry was called from both handleBlockStreamEvent and the periodic
  ticker, so two sweeps could interleave and both upload a post-mortem for the same
  epoch before either advanced lastExpiredEpoch. Drop the inline block-stream call:
  the ticker (a RunningPromise, which never overlaps its own runs) is now the sole
  driver, so the high-water mark advances — and each epoch uploads — exactly once.
  Expiry is a background sweep keyed off the archiver's synced slot; it never needed
  to be on the event path. The A-1041 tips-unadvanced guard now covers only the
  registration/prune handling that genuinely needs it.

- Add a checkpoint-prover test for the actual data-plane race: dbProvider.fork
  rejecting mid-proof rejects whenBlockProofsReady(), which the EpochSession maps to
  'stopped'. Point the expiry unit tests at checkEpochExpiry directly rather than
  through a block-stream event.
The expiry sweep no longer runs from handleBlockStreamEvent. Rename "Per-event
expiry sweep" to "Periodic expiry sweep", redraw the diagram around the expiryTicker
(RunningPromise) as the sole driver, and fix the prose: the high-water mark advances
per sweep (not per event) and is seeded from resolveLastFullyProvenEpoch. Drop the
stale getCheckpointsData and computeStartupState references (expireEpoch uses
getBlocks; there is no computeStartupState).
… re-proved every tick

Retry-to-converge was naively per-tick: for an epoch that keeps failing, every tick
cleared the stopped session and re-created a fresh one, re-running proving work until
the deadline for no benefit. Key the retry off content instead, per the original
design: record the content key of a full session that ends in 'stopped', and have the
tick skip an epoch whose current canonical content matches an already-failed attempt.

Recovery is unaffected — it flows through the ungated checkpoint/prune triggers, which
fire on a genuine change (a re-add or reorg, including an identical-content re-add whose
world-state has resettled) and reopen the epoch regardless. The gate resets when the
epoch is proven, expires, or the proven frontier passes it.

Only full sessions are affected: partials are opened solely by an explicit startProof
and are never reopened by the tick or by events, so they never entered the re-spin loop.
…er mark

Replace the content-keyed retry gate (a per-epoch content-key map plus two helpers and
record/clear bookkeeping) with the monotonic lastTickEpoch high-water mark: the tick
opens an epoch once and does not re-create a session for it every tick. Recovery from a
genuine change still flows through the ungated checkpoint/prune triggers, so a
pruned-then-re-added epoch recovers exactly as before. The tradeoff — a transient failure
on an already-complete epoch waits for the deadline rather than being auto-retried by the
tick — is unchanged from the content-keyed version, at a fraction of the machinery.
…nt prover

Replace the lastTickEpoch high-water mark with a check at the point of construction:
a CheckpointProver whose block proofs rejected for a non-cancel reason (a sub-tree
fault or a prune-induced fork fault) now records isFailed(), and the SessionManager
refuses to open (or rebuild) an EpochSession over any set that contains a failed
prover. A stuck epoch is therefore skipped cheaply each tick — no session, no
re-proving — rather than being gated by per-epoch bookkeeping.

This keeps the resiliency and drops the tick gate: a pruned/re-added epoch recovers
because the re-add installs a fresh (non-failed) prover, and a session that stops with
healthy provers (a transient top-tree/submit error) is still retried by the next tick.
The failure lives on the prover, where it happened, with the store as the single source
of truth.
…upload eagerly, not at expiry

The expiry-time post-mortem upload could never fire for a persistently-failing epoch:
by the time its window closes, its checkpoint provers have been pruned, so there was
nothing to upload (the upload_failed_proof e2e test hung as a result).

Give EpochSession a genuine-failure state, told apart by the checkpoint provers'
isFailed() flag:
  - a fault while a prover under it failed → 'stopped' (maybe a prune): not uploaded,
    not retried over the failed prover, recovered on re-add.
  - the session's own top-tree/submit work failed while every prover was healthy →
    'failed' (hasFailed()): definitively not a prune, so it is race-free. The reconciler
    retains such a full session (so the tick doesn't re-prove a deterministic failure)
    and uploads a post-mortem once, eagerly, from the session's checkpoints.

Removes the fail-at-expiry upload (expireEpoch is back to chonk-release + reap only);
reinstates the onSessionFailed → tryUploadEpochFailure wiring on the genuine-failure
path. Reverts the e2e test to warp-to-epoch-1 + the eager upload trigger.
…st per failed session

A checkpoint prover that fails to produce its block proofs (a sub-tree fault or a
prune-induced fork fault) now fires an onFailed callback, and ProverNode uploads a
snapshot for that single checkpoint via tryUploadCheckpointFailure. This captures a
genuine checkpoint proving failure that ends its session in 'stopped' — which the
session-level upload (only on a session's own 'failed') deliberately does not cover.

The checkpoint upload fires for prune-induced faults too, on purpose: a prune-caused
checkpoint snapshot is harmless, and not trying to tell prune from genuine failure is
what keeps it race-free. A cancelled prover (control-plane prune / shutdown) is not a
failure and does not upload.
…kpoint upload

Add rerunCheckpointProvingJob: reuses the epoch rerun's offline setup (world state +
archiver snapshot, local broker/prover, replaying tx provider) but rebuilds just the one
checkpoint's sub-tree prover and awaits its block proofs — no epoch top-tree or L1
submit. Extract the shared setup into createRerunContext / buildCheckpointProver.

Add a test-only checkpointProveOverride hook (CheckpointProverDeps → CheckpointStore
setTestHooks → ProverNode.setCheckpointHooks) so a test can force a sub-tree failure,
mirroring the existing session topTreeProveOverride hook.

Extend upload_failed_proof.e2e with a second test: force a checkpoint sub-tree failure,
capture the eager per-checkpoint upload URL via tryUploadCheckpointFailure, download, and
re-prove that single checkpoint with rerunCheckpointProvingJob.
Merges the current `v5-next` tree (`c11bc68`) back into `v5` and bumps
the release-please manifest to `5.0.1` to cut the v5.0.1 patch release.

- `.release-please-manifest.json`: `5.0.0` → `5.0.1`
- Brings all commits on `v5-next` since `v5.0.0` onto `v5` (companion
change: `v5-next` manifest bumped to `5.1.0` in `ee3716277a`).

**Merge strategy:** this is a release-branch sync — merge so `v5`
remains a true superset of the history (merge commit or fast-forward). A
squash merge would collapse the whole tree into a single commit and
rewrite the SHAs, so avoid it here.

Tagging `v5.0.1` on the resulting release commit is a follow-up step.
…cle flags; drop public isCompleted

completed/failed/cancelled are three orthogonal facts, not a single status — a prover can
be completed+cancelled (routine teardown) or completed+failed (enqueued then the sub-tree
faulted); only failed+cancelled is excluded. Add a comment explaining why they aren't one
enum, with per-field docs.

isCompleted() had no callers outside tests (internally the `completed` field is used
directly), so remove the public getter and the two secondary test assertions that used it.
…ke-epoch-proving-robust-to-prune-induced-fork
…l/a-1418-prover-node-make-epoch-proving-robust-to-prune-induced-fork
…s executed

A mainnet sequencer kept signalling a governance payload whose proposal had
already been executed, wasting ~100k gas per slot on a signal the canonical
rollup rejected. Three fixes:

- Fix the `ProposalState` enum, which was missing `Droppable` (Solidity
  `IGovernance` has 9 states). The mismatch made Solidity `Expired` decode as
  out-of-range and throw in `asProposalState`, so the publisher failed open and
  signalled anyway. Add an explicit `LIVE_PROPOSAL_STATES` set for the sweep.

- Replace `hasActiveProposalWithPayload` (boolean) with `getPayloadProposalStatus`
  returning `'live' | 'executed' | 'none'`, matching a proposal by its stored
  payload directly or via its GSEPayload wrapper. The publisher now stops
  signalling an executed payload (memoized in-process, live takes precedence over
  executed) unless `GOVERNANCE_PROPOSER_FORCE_PAYLOAD_VOTE` is set, for payloads
  designed to be re-executed.

- Add a canonicality guard: resolve the canonical rollup instance once and skip
  the signal when the configured rollup is not canonical, reusing that instance
  for round accounting and the EIP-712 digest so the read cannot race a
  canonical-rollup change.
…ser API

Resolve the canonical rollup via getRollupAddress() instead of the getInstance()
wrapper, dropping the now-unused instance parameter threaded through
createSignalRequestWithSignature. Since the guard returns early when the configured
rollup is not canonical, round accounting and the EIP-712 digest can just use the
configured rollup address. Also drop the redundant sawExecuted flag from
getPayloadProposalStatus (the executed verdict is already memoized in the set).
getL2ToL1MembershipWitness assembled its witness from several
non-atomic archiver reads (getTxEffect, then the epoch blocks, target
block and checkpoint metadata read inside computeL2ToL1MembershipWitness).
A store commit landing between those reads could splice together two
different chain states and surface a spurious "message does not exist"
error even when the tx-effect index was healthy.

Wrap the witness assembly in a single store.transactionAsync so every
archiver read binds to one write transaction and observes a consistent
snapshot (the store serializes writers, so no commit can interleave).
The L1 Outbox roots fetch stays outside the transaction to avoid holding
the archiver's writer lock across a network round-trip, and the tx
effect is re-read inside the snapshot so the receipt's block number and
tx index stay consistent with the block data.

Add a kv-store test asserting reads inside a transaction see a
consistent snapshot while a concurrent write is queued behind it.
…ed elsewhere

BlockStore.removeBlocksAfter had two defects that corrupt the tx-effect
index (txHash -> block position) when the same tx exists in two stored
blocks, e.g. re-included after its original proposal expired:

- deleteBlock removed #txEffects entries blindly by txHash, destroying
  the entry of a tx whose index already points at another stored block,
  which makes that block unreadable.
- removeBlocksAfter skipped cleanup entirely for blocks it could not
  reconstruct, leaking their row, tx effects, and indices; a later
  insert at the same number then overwrites the row in place, leaving
  stale tx-effect entries pointing into the new chain.

A stale entry makes getL2ToL1MembershipWitness resolve the tx at wrong
coordinates and throw 'The L2ToL1Message you are trying to prove
inclusion of does not exist' for a message in a proven block, and lets
getTxReceipt report a proven position the chain no longer has.

Cleanup now works from the raw storage row (so unreadable blocks are
still fully released) and only deletes tx-effect entries still owned by
the block being removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oval

The ownership-checked delete silently skipped entries owned by another
block. That state (two stored blocks sharing a tx) should be unreachable
for honest chains, so skipping it silently hides direct evidence of an
upstream bug. Warn on both anomalies (foreign-owned entry and missing
entry) and correct the comment that attributed the duplication to
routine proposal expiry, which cannot produce it.
The lazy KZG singleton (getKzg) builds its precomputation tables
synchronously on first use, blocking the event loop for ~2s locally and
12-15s under production CPU limits. On an RPC node the first use is the
archiver reconstructing blobs or the proposal handler uploading them,
right after a checkpoint arrives, so the stalled loop overruns the
gossipsub mcache window and attestation forwarding is skipped.

Warm it in the node factory alongside the bb.js singleton, before any
subsystem runs, keeping the cost off the gossip path.
Move the trusted-setup load timing and logging out of the node factory
and into getKzg itself, gated on an optional logger argument, using
elapsedSync for the measurement. Keeps the factory call site to a plain
getKzg(log) and makes the timing available to any warm-up caller.
… jobId

uploadEpochProofFailure already prefixes the upload path with the epoch number, so
the epoch in the jobId was redundant — and the epoch-only string dropped the
per-upload uniqueness the original session.getId() UUID gave. Use each entity's own
id instead: the session's id for a session (epoch) failure, and the prover's
content-addressed id for a checkpoint failure. Drop the now-unused epoch param from
tryUploadEpochFailure.
spalladino and others added 15 commits July 29, 2026 14:42
…25028)

## Context

`SiblingPath.deserialize` read a 32-bit length prefix and then both
pre-allocated an array of that
size and looped that many times, with nothing tying the declared length
to the bytes actually
present. Its element reader was built on `Buffer.slice`, which clamps
past the end of a buffer
instead of throwing — so the loop never ran out of input and ran the
full declared count regardless
of how much data arrived.

A 4-byte field was therefore enough to build a million-element path, or
to walk a client's V8 heap
into a fatal out-of-memory abort. That abort is not catchable, so a
`try`/`catch` around RPC decoding
does not contain it. This matters because sibling paths reach clients as
RPC *outputs* (witness
lookups, which the PXE fetches automatically during private execution),
so it is the node that can
crash the client, not the other way round.

## Approach

Sibling paths now deserialize through `BufferReader.readVector`, which
bounds the declared length
three ways: an explicit `maxSize` (`MAX_SIBLING_PATH_LENGTH`, 128 — the
deepest protocol tree is 42
levels and the stacked path for L2-to-L1 message inclusion spans four
unbalanced trees, so real paths
are well under it), a new check that the size does not exceed the bytes
left after the prefix, and
`readBytes`' own per-element range check. The last of those also closes
the truncation half of the
problem, where a path declaring more elements than its payload holds
used to deserialize into
undersized buffers instead of failing.

The cap has to live in `foundation`, which cannot import
`@aztec/constants` since `constants` depends
on it, so a test in `stdlib` asserts the cap stays above every generated
`*_HEIGHT` constant.
Enumerating them rather than listing the deep ones means a future deeper
tree fails that test instead
of silently failing to deserialize.

`deserializeArrayFromVector`, the primitive behind the old code, had
exactly one caller and
duplicated `readVector` minus all of those checks, so it is deleted
rather than fixed. The
remaining-bytes bound lands in `readVector` itself, which covers its
other callers too: every element
consumes at least one byte, so any size beyond the bytes remaining is
unsatisfiable and can be
rejected before allocating or looping. It was not exploitable there in
the same way (its item
deserializers all range-check as they read), but the guard makes the
failure explicit and cheap
rather than dependent on every element reader.

That bound assumes a minimum of one byte per element, so I checked for
anything that deserializes a
count of sub-byte items: `BitVector` is the only bit-packed reader, and
it converts its bit count to
`ceil(length / 8)` bytes itself and reads through the range-checked
`readBytes`, so it never reaches
the bound with a bit-denominated length. Every other `readVector`
element reader in the tree advances
by whole bytes.

One existing `readVector` test changed: it asserted that a 32-byte
buffer declaring 65537 elements
yields 65537 elements, using a deserializer that consumed no bytes. That
amplification is what the
bound removes, so the happy path is now exercised against a well-formed
vector and the oversized case
asserts the throw.

Fixes A-1522
Two public node RPC inputs reached expensive work with no length cap.

`simulatePublicCalls` accepted an unbounded `overrides.publicStorage`
array (and an unbounded
`overrides.contracts` record). Each storage override becomes a leaf
inserted into a forked public
data tree before the simulated transaction runs, so a small request
could force a large amount of
Merkle work and memory that the transaction's gas limit does not meter.

`getCheckpointsData` range queries validated `limit` with only `min(1)`,
while the sibling
`getCheckpoints` capped it. The effective upper bound was the node's
contiguous checkpoint history
rather than a page size, so one request could ask the archiver to read
and serialize the whole chain.

## Approach

Both caps live in `stdlib/src/interfaces/api_limit.ts` next to the
existing `MAX_RPC_*` limits and are
enforced in the zod schemas, so they reject at the RPC boundary before
any handler runs:

- `MAX_RPC_PUBLIC_STORAGE_OVERRIDES_LEN` (200) and
`MAX_RPC_CONTRACT_OVERRIDES_LEN` (50) on
`SimulationOverrides.schema`. Internal callers of the override path use
a handful of entries at
most (`fastforwardContractUpdate` writes one delayed-public-mutable
value), so there is ample
  headroom.
- `MAX_RPC_CHECKPOINTS_DATA_LEN` (200) on both range variants of
`CheckpointsQuerySchema`
(`{ from, limit }` and `{ fromSlot, limit }`). Checkpoint data carries
no attestations or block
bodies, hence a larger page than a full checkpoint response. The widest
internal caller is the
prover node's catch-up fetch, bounded by `(proofSubmissionEpochs + 1) *
epochDuration`.

The existing `MAX_RPC_TXS_LEN`, `MAX_RPC_BLOCKS_LEN` and
`MAX_RPC_CHECKPOINTS_LEN` page caps stay at
50 and are unchanged. Two ideas from earlier revisions were dropped:
raising those caps to 100 while
adding a tighter `MAX_RPC_HEAVY_LEN` for reads whose `include*` options
attach a tx body or proof to
every entry, which needed a refine on four schemas plus a divergent
default for `getPendingTxs`; and
capping the per-slot attestation arrays at the committee size, which is
not actually an upper bound
since equivocation can produce more than one attestation per validator
per slot.

Tests cover each cap at the schema level (`SimulationOverrides`,
`CheckpointsQuerySchema`) and as
round-trips through the JSON-RPC client and server in
`aztec-node.test.ts`.

Fixes A-1524
Fixes A-1523
## Summary

- Backport the current `origin/next` docs baseline onto `v5-next`,
replacing the stale `v4.3.0` docs config/snapshots with the current
`v5.0.1` docs baseline.
- Bring over generated API docs, versioned developer/operator snapshots,
docs site tooling/assets, and source docs so a `v5.1.0` docs release
starts from the current docs tree instead of the old release-line state.
- Backport the `release-docs` and `release-network-docs` skill updates,
including the explicit post-cut reconcile step against `origin/next`.
- Rebase the PR onto the current `origin/v5-next` and backfill the docs
changes that landed on `origin/next` after the initial PR snapshot.

## Notes

- This intentionally does not change protocol/source files outside
`docs/` and the two release-docs skill files.
- The PR branch now matches current `origin/next` for `docs/`,
`.claude/skills/release-docs/SKILL.md`, and
`.claude/skills/release-network-docs/SKILL.md`, except for two
deliberate v5 compatibility fixes:
- `docs/examples/ts/tsconfig.template.json` uses
`yarn-project/l1-artifacts`, because `l1-contracts/l1-artifacts` does
not exist on `v5-next`.
- `docs/examples/ts/recursive_verification/config.yaml` uses
`barretenberg/ts`, because `barretenberg/ts/bb.js` does not exist on
`v5-next`.
- After this lands, the actual `v5.1.0` release still needs the normal
release-docs flow: cut the `v5.1.0` snapshot from the release tag, then
run the documented reconcile step for anything newer on `origin/next`.

## Testing

- `cd docs && yarn install --immutable`
- Completed in the original PR validation with non-fatal Yarn
peer/native warnings, including an optional `unix-dgram` native build
warning.
- `cd docs && yarn build`
  - Passed after the latest docs backfill.
- Docusaurus emitted the existing non-fatal SSG/broken-anchor warnings
for copied operator pages and generated CLI references; the build
completed successfully.
- Verified the effective docs/release-skill tree against `origin/next`:
only the two intentional v5 path compatibility fixes differ.
- Verified merge-tree against current `origin/v5-next`: no conflicts.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/08b91d8285e61921/jobs/2)
· updated by
[claudebox](https://claudebox.work/v2/sessions/08b91d8285e61921/jobs/5)
· group: `slackbot` · requested by Alejo Amiras · [Slack
thread](https://aztecfoundation.slack.com/archives/C0B24G1GFGB/p1785179401873069?thread_ts=1785179401.873069&cid=C0B24G1GFGB)*
BEGIN_COMMIT_OVERRIDE
perf(pxe): derive sender tagging finalization from log blocks (#25033) —
backport to merge-train/fairies-v5 (#25045)
fix(pxe): support contracts built with the v5.0.1 handshake registry
(#25032)
END_COMMIT_OVERRIDE
BEGIN_COMMIT_OVERRIDE
feat(prover-client): stop duplicating broker job inputs/results in
memory (A-1215) (#24990)
fix(prover-client): don't retain inline job inputs in the facade without
a failed-proof store (A-1517) (#25027)
feat(prover-node): tear down checkpoint sub-tree once block proofs are
ready (A-1213) (#24982)
fix(stdlib): cap the unbounded arrays in Tx and HashedValues schemas
(#25029)
fix(foundation): bound deserialized sibling path and vector lengths
(#25028)
fix(rpc): cap simulation overrides and checkpoint queries (#25026)
END_COMMIT_OVERRIDE
BEGIN_COMMIT_OVERRIDE
chore: update Noir to v1.0.0-beta.25 (#24907)
chore: update Noir to v1.0.0-beta.25 (backport #24907) (#24945)
chore(ci): route spartan/spartan-v5 merge-train notifications to
#team-alpha-ci (#24963)
feat: map pino log level to AWS severity (#25022)
END_COMMIT_OVERRIDE
…ures (#25031)

## Context

A selected proposer can attach signed txs to a block proposal that pass
the proposal-level checks (each tx hash is listed in `txHashes` and each
tx recomputes to its own hash) but fail minimum tx integrity validation
— bad proof, metadata, size, data, or contract instances. Tx collection
threw a bare `Error('Invalid tx detected')` out of the validator's
block-received callback before `handleBlockProposal` could return a
typed result, so `validateBlockProposal` never reached the branch that
emits `BROADCASTED_INVALID_BLOCK_PROPOSAL` and marks the slot invalid.
The proposer avoided invalid-block accountability, and observers missed
the invalid slot that the delayed attested-to-invalid-proposal watcher
relies on.

The same callback rejection also leaked tx-pool protections:
`processValidBlockProposal` protects the proposal's txs before awaiting
the callback but only unprotected them when the callback *returned*
false.

## Approach

- `validateTxsReceivedInBlockProposal` now throws a typed
`InvalidBlockProposalTxsError` carrying each offending tx hash and its
validation reasons, instead of a bare error.
- The proposal handler catches that error around tx collection (in a
`collectProposalTxs` helper) and returns a new `invalid_embedded_txs`
failure reason, which is in
`SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT`, so both the validator and
non-validator handlers emit the offense and mark the slot invalid —
still gated on the escape hatch being closed. Any other collection error
keeps propagating: only proposer misbehavior gets a typed, slashable
reason, so a local pool or network failure is not mistaken for an
invalid proposal. The error crosses a package boundary, so it is matched
with `isErrorClass` rather than `instanceof`, and sets its `name`
accordingly.
- `processValidBlockProposal` releases the protections it created when
the callback rejects, not just when it returns false. A
`tryBlockReceivedCallback` helper reports a throwing callback as a
rejection — the proposal is no more usable than one explicitly rejected,
and the error would otherwise escape with the protections still held —
leaving one release path for both outcomes. The outer gossip handler
only logged such errors anyway; one visible consequence is that an
unexpected error while processing a checkpoint's embedded block no
longer skips the checkpoint proposal itself.
- A proposal that lists the same tx hash twice is rejected with a new
slashable `duplicate_txs` reason. The repeated tx makes the block
unbuildable (it would emit nullifiers the first copy already emitted),
and tx collection reconciles a deduplicated hash set against the full
list, so a duplicate that is not available locally threw the invariant
error `Error collecting txs for proposal with N txs: found X and flagged
Y as missing` out of `getTxsForBlockProposal` — the same
unattributable-exception shape this PR removes. The check lives in the
proposal handler rather than in gossip validation: a duplicate hash is
proposer misbehavior, not a relaying-peer fault, so rejecting it at
ingress would penalize the forwarding peer and short-circuit before the
block-received callback, leaving the offense unrecorded. This mirrors
how an over-limit `indexWithinCheckpoint` is handled. The handler runs
the check before tx collection, so the invariant error is unreachable,
and the terminal block carried in a checkpoint proposal goes through the
same handler.

Slashing here is attributable to the proposer. The proposal is validated
for the right signing domain, slot window, and expected-proposer
signature before any tx is looked at, and `validateTxs` requires every
embedded tx to be listed in the signed `txHashes` with a self-consistent
hash. `BlockProposal.getSender()` additionally returns undefined unless
the embedded `SignedTxs` bundle shares the outer proposal's signature
context and recovers to the same signer. A relaying peer can strip txs
from the body, which surfaces as the non-slashable `txs_not_available`,
but cannot graft foreign txs into an honest proposer's proposal.

Two limitations worth noting, both pre-existing and left as-is:

- `TxProvider.extractFromProposal` only considers hashes missing from
the local pool, so a tx carried in the proposal whose hash is already
pooled is never integrity-checked. Detection is therefore
mempool-dependent. Closing that gap means proof-verifying every carried
tx on the attestation path, which is a separate performance call.
- `SLASHABLE_BLOCK_PROPOSAL_VALIDATION_RESULT`, `badProposalReasons` in
`validator.ts`, and the metric label seeds in `metrics.ts` are three
hand-maintained lists of the same taxonomy and have already drifted
(`global_variables_mismatch` is slashable but counted as a node issue).
The new reason is added to all three; consolidating them into one
exhaustive record would be a good follow-up.

Fixes A-1525
…targets (#25058)

Closes A-1443. Reworked version of #24923 targeting the v5 line,
addressing the review feedback there.

Operators currently get no signal when the network starts voting to
slash their validators — during the v5 inactivity-slashing incident,
operators found out only after stake was lost. This PR makes the node
detect when its own validators are targeted by onchain slashing, at two
points in the lifecycle:

- **Vote time (early warning)**: a new `OwnValidatorSlashMonitor`
(following the `SlashRoundMonitor`/`SlashOffensesCollector`
decomposition) subscribes to `VoteCast` events — only when the node runs
validators — and warns on every vote that names one of them, with the
running tally against the quorum: `Own validator 0x… targeted by
slashing vote (7 of 65 votes needed to slash)`. Quorum needs a majority
of a round's slots, so warnings start well inside the window an operator
has to react.
- **Round execution**: `handleRoundExecuted` filters the already-fetched
`Slashed` events to own validators and emits a WARN with the exact
slashed amount. No additional L1 reads.

Five node-level metrics (no per-validator labels, so one series per node
regardless of attester count):
`aztec.slasher.own_validator.targeted_count`, `.current_round_votes_max`
(highest tally against any committee position held by an own validator,
reset each round), `aztec.slasher.quorum_size` to compare it against,
and `.slashed_count` / `.slashed_amount` for executed slashes. The alert
is `current_round_votes_max` approaching `quorum_size`; the WARN
identifies which validator via a structured log field.

Design notes:

- **Votes are read by index, events are only triggers.** `VoteCast`
carries no vote index, so the monitor keeps a per-round cursor: on each
event it reads the round's `voteCount` and processes votes `[cursor,
voteCount)` via a new `getVoteAt(round, index)`. Duplicate or batched
deliveries are no-ops, backlogs are caught up by the next event, and a
failed read is retried because the cursor only advances after a vote is
successfully processed.
- **All processing is serialized** through a single queue, with the
round revalidated after every await, so concurrent event handlers cannot
double-count, drop votes into the wrong round, or emit out-of-order
tallies. `stop()` drains the queue and suppresses any late
warns/metrics.
- **Startup baseline instead of replay.** On start the monitor reads the
current `voteCount` (before subscribing) and skips everything before it:
replaying old votes would double-count cumulative counters across
restarts. Votes cast while the node is offline or starting up are not
counted, and L1 reorgs can cause small drift within a round — both
documented on the metrics.
- **The tally is per flattened committee position** because that is the
unit the contract tallies quorum by: a validator sitting in several of
the round's committees is named once per position by a single vote, each
position racing quorum independently. Warnings and `targeted_count`
remain per (vote, validator), reporting the validator at its highest
position tally.
- Own validator addresses were already passed to `createSlasherFacade`
for `slashValidatorsNever`; they are now also threaded as
`ownValidators` into `SlasherClient`, independent of `slashSelfAllowed`.
`getSlashingAmounts` is memoized (the amounts are Solidity `immutable`),
and `SlashVote` is renamed to the singular `SlashVoteTarget`.

Testing: 22 unit tests for the monitor (per-vote warning with pinned
message/context, per-position tallying, multi-validator nodes, round
rollover in both directions and quiet-round reset, closed-round and
foreign-validator votes, cursor semantics under duplicate/batched/missed
events, failed-read retry, drain serialization, mid-drain rollover,
startup baseline and its failure fallback, stop/restart lifecycle,
subscription gating), 3 client wiring tests, and an anvil-backed
`getVoteAt` decoding test in the ethereum package.
BEGIN_COMMIT_OVERRIDE
fix(validator): classify invalid proposal txs as slashable block
failures (#25031)
feat(slasher): warn and expose metrics when own validators are slash
targets (#25058)
END_COMMIT_OVERRIDE
@AztecBot AztecBot added ci-draft Run CI on draft PRs. ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure claudebox Owned by claudebox. it can push to this PR. labels Aug 5, 2026
AztecBot and others added 2 commits August 5, 2026 14:05
Merge origin/v5-next into the backport staging branch. The staging
branch was 104 commits behind v5-next, which is the sole reason the
automatic backport of #24969 failed.
@AztecBot
AztecBot force-pushed the cb/backport-24969-v5-next branch from ed6ca41 to f1dd556 Compare August 5, 2026 14:06
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​@​signalwire/​docusaurus-plugin-llms-txt@​1.2.28710010087100

View full report

@nchamo
nchamo marked this pull request as ready for review August 5, 2026 14:11
@nchamo
nchamo requested review from a team and charlielye as code owners August 5, 2026 14:11
@nchamo
nchamo enabled auto-merge August 5, 2026 14:12
@nchamo
nchamo merged commit 349e9de into backport-to-v5-next-staging Aug 5, 2026
14 of 18 checks passed
@nchamo
nchamo deleted the cb/backport-24969-v5-next branch August 5, 2026 14:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-draft Run CI on draft PRs. ci-no-fail-fast Sets NO_FAIL_FAST in the CI so the run is not aborted on the first failure claudebox Owned by claudebox. it can push to this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.