Skip to content

chore: add writing-e2e-tests skill#24597

Draft
AztecBot wants to merge 493 commits into
merge-train/spartan-v5from
cb/writing-e2e-tests-skill
Draft

chore: add writing-e2e-tests skill#24597
AztecBot wants to merge 493 commits into
merge-train/spartan-v5from
cb/writing-e2e-tests-skill

Conversation

@AztecBot

@AztecBot AztecBot commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Adds yarn-project/.claude/skills/writing-e2e-tests/SKILL.md: a skill that gives an agent everything it needs to place, structure, and write a robust e2e test with little guidance. Written against the v5-next e2e layout (category directories with per-category READMEs and context classes).

What it covers

  • Step 0 decision ladder: unit test → new expectation in an existing test → new it in an existing suite → new file on an existing context/harness → new standalone test.
  • Where to place the test: recap of the category directories (automine/ via AutomineTestContext, single-node/ via setupWithProver/setupBlockProducer, multi-node/ via MultiNodeTestContext on the mock-gossip bus, p2p/ via P2PNetworkTest on real libp2p, plus composed/, infra/, spartan/, bench/), the multi-node-vs-p2p decision rule, pointers to the per-category READMEs as the authoritative reference, file naming (describe matches path, header comment), automatic CI registration through bootstrap.sh globs, .parallel.test.ts semantics, and jest/bash timeout sync.
  • Setup reuse: the three layers (category contexts → domain harnesses → root SetupOptions), the standard suite file shape, one-environment-per-file, preset spread order, guarded teardown.
  • Readability: intent-only test bodies, the named-waiter surface (fixtures/wait_helpers.ts, context waiters, ChainMonitor), shared helpers and co-located *_test_helpers.ts, simulators with afterEach checks, shared error constants.
  • Speed: themes distilled from the recent test(e2e)/perf(e2e)/chore(e2e) speedup PRs and their tracking issues — avoid unneeded setup, genesis seeding over setup txs, BatchCall batching over Promise.all overlap (with the PXE-serialization caveat), warping dead waits with markProvenAndWarp/warpWithSequencersPaused ("an honest wait beats a flaky warp"), named timing profiles over ad-hoc cadences, and measuring with the testSpan instrumentation + track-e2e-times skill before optimizing.
  • Flakiness: twelve golden rules synthesized from six months of deflake PRs and the accumulated flaky-test gotchas (poll-don't-sleep via named waiters, receipt-anchored assertions, mined ≠ checkpointed ≠ proven, syncChainTip tag awareness, pipelining timing margins + findSlotsWithProposers, invariant-not-exact-value assertions, L1 account/nonce and port hygiene, freeze-L1-across-restarts, gossip-mesh readiness via runGossipScenario, fee padding, determinism, honest timeouts), plus deflaker.sh validation and .test_patterns.yml as a last resort.
  • Final pre-ship checklist.

All helper/API names cited were verified to exist on merge-train/spartan-v5 (AutomineTestContext.setup, setupWithProver/setupBlockProducer, MOCK_GOSSIP_MULTI_VALIDATOR_OPTS, wait_helpers.ts waiters, warpWithSequencersPaused, markProvenAndWarp, testSpan/TEST_TIMING_FILE, runGossipScenario, waitForP2PMeshConnectivity, findSlotsWithProposers, getPaddedMaxFeesPerGas, etc.). Genesis-prefill techniques that only exist in still-open speedup PRs are described as themes to look for rather than as concrete APIs.

PhilWindle and others added 30 commits June 22, 2026 15:15
No longer used after removing fastMsgIdFn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes
[F-669](https://linear.app/aztec-labs/issue/F-669/aztec-nr-calculate-secret-and-index-helper-for-constrained-delivery)
Fixes
[F-670](https://linear.app/aztec-labs/issue/F-670/aztec-nr-send-constrained-msg-helper-that-emits-a-constrained)

## The change

- Adds constrained-delivery helpers that resolve or bootstrap the
app-siloed handshake secret, seed same-tx index reuse, validate index
`0` against the standard HandshakeRegistry, and constrain `index > 0`
through the previous chain nullifier.
- Wires constrained private message delivery to derive constrained log
tags from the resolved `(secret, index)` pair and emit the current
constrained-message nullifier.
- Keeps constrained logs from being squashed with note hashes so
recipient discovery can advance the per-secret index chain.
- Authorizes the standard HandshakeRegistry utility reads needed by the
flow.
- Adds TXE, snapshot, and e2e coverage for bootstrap, reuse, index
advancement, missing prior-nullifier failure, invalid API combinations,
and standard-registry constrained delivery.

Large portion of the diff is tests. 


[F-741](https://linear.app/aztec-labs/issue/F-741/getsharedsecrets-throws-no-public-key-during-recipient-discovery-for-a)
follow-up for the test skipped in
[fd07996](fd07996).

## Concurrency / batching constraint (worth a look)

Pinning the e2e tests surfaced a sequencing constraint in constrained
delivery that reviewers should sanity-check:

- **Parallel sends on one `(sender, recipient, secret)` chain collide.**
Each send is keyed on an incrementing index, so two sends fired as
separate parallel txs read the same index and one tx is rejected.
Distinct recipients are distinct chains and parallelize fine. Pinned as
`it.failing` (documents the limit; flips green if parallel sends ever
become supported).
- **Same-chain sends _can_ be batched into one tx**, but only onto an
already-committed handshake. A later send discharges its predecessor
check against a same-tx pending nullifier. Works both via a single
contract call (`emit_two_events`) and client-side `BatchCall`.
- **Batching onto a brand-new chain does not reuse, it re-handshakes.**
The reuse-vs-bootstrap decision in
`get_or_create_app_siloed_handshake_secret` is a utility call reading
_committed_ state, so a bootstrap earlier in the same tx is invisible
and the later send mints a fresh secret on a separate chain (next index
lands at 1, not 2). A new recipient therefore needs one landed tx to
establish the chain before sends can be batched onto it. This is why the
batched tests seed the handshake first.

All four behaviors are pinned in `e2e_constrained_delivery.test.ts`; the
rationale lives in the module doc and on
`get_or_create_app_siloed_handshake_secret`. Follow-up: allowing utility
functions to execute against combined committed + pending transaction
state is tracked in
[F-238](https://linear.app/aztec-labs/issue/F-238/allow-execution-of-utility-functions-touched-by-pending-transactions).

---------

Co-authored-by: AztecBot <tech@aztec-labs.com>
Co-authored-by: Nicolas Chamo <nicolas@chamo.com.ar>
… EpochSession (A-1212) (#24216)

Fixes A-1212.

## Problem

`prover_getJobs` exposes each `EpochSession`'s state, but `EpochSession`
only ever assigned `initialized`, `awaiting-checkpoints`, and the
terminal states. The intermediate phases that exist to describe active
work were never set, so a session read `awaiting-checkpoints`
continuously while it was actually (a) proving the top tree or (b)
publishing to L1. Observed on staging-internal: every `startProof`
session sat in `awaiting-checkpoints` even with logs showing `Starting
top-tree prove…`. This is a regression vs the old `EpochProvingJob`,
which advanced through distinct phases.

## Fix

- Add a new **non-terminal** `awaiting-root` state to
`EpochProvingJobState` (the zod schema is derived from the same array,
so it updates automatically). Not added to
`EpochProvingJobTerminalState`.
- Set `awaiting-root` when the top-tree (root) prove begins, via the
`TopTreeJob` `beforeProve` hook — which fires once the sub-tree
(checkpoint block) proofs are ready and the root prove is about to
start. `toTopTreeHooks()` now always wires this transition and layers
any test-provided hooks on top (previously it returned `undefined` when
no test hooks were set).
- Set `publishing-proof` at the top of `submitProof()`, before the L1
submit.

Both transitions are guarded by `isTerminal()` so a concurrent
`cancel()` still wins (the post-submit `isTerminal()` check relies on
this).

Phase progression is now: `initialized → awaiting-checkpoints →
awaiting-root → publishing-proof → terminal`.

## Tests

- `epoch-session.test.ts`: new "state reporting" test asserts
`getState()` is `awaiting-root` during the prove and `publishing-proof`
during the submit, then `completed`. Existing hook-ordering test
(`before`/`prove`/`after`) still holds.
- `prover-node.test.ts`: the JSON-RPC schema round-trip mock now
includes an `awaiting-root` job.

Targets the v5 line — the
`EpochSession`/`TopTreeJob`/`awaiting-checkpoints` code (post-#23552)
exists only on `merge-train/spartan-v5`.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves conflict in e2e_nested_utility_calls.test.ts (keeps the train's
doc comment above the 'denies...private function' test; the msg_sender
test from v5-next is already present). Real merge commit so v5-next
becomes an ancestor of the train.
…empotent

The archiver preloads bundled protocol contract classes and instances at
synthetic block 0. When a bundled protocol class id (or instance address) was
later published on chain, the contract stores threw "already exists" while
re-adding the already-present key, stalling L1 sync.

Make the stores idempotent for protocol-preloaded entries:
- addContractClass / addContractInstance now skip (no-op) when the key already
  exists and it belongs to a bundled protocol contract, keeping the existing
  block-0 entry untouched. Genuine non-protocol redefinitions still throw.
- deleteContractClass / deleteContractInstance never delete protocol entries,
  so they survive reorgs of the publishing block.

Adds isProtocolContractClass to protocol-contracts as a sibling of
isProtocolContract, plus store unit tests and an integration test that
publishes a bundled protocol class id through ArchiverDataStoreUpdater.

Fixes A-1257
…ting (A-1258) (#24213)

Fixes A-1258.

## Problem

`DataTxValidator` decodes contract-class publication logs **before**
proof validation (data validation runs ahead of proof validation on the
req/resp, block-proposal, RPC, and gossip paths). The decode trusts the
declared public-bytecode length field:

- `bufferFromFields` (`stdlib/src/abi/buffer.ts`) reads the declared
`byteLength` and, when the payload is shorter, does
`Buffer.alloc(byteLength)` — trusting the declared length over the bytes
actually present.
- `ContractClassPublishedEvent.fromLog` decodes the bytecode via
`bufferFromFields`, and `DataTxValidator.#hasCorrectContractClassIds`
calls `fromLog` before any class-id / proof rejection.

A contract-class log is a fixed `CONTRACT_CLASS_LOG_SIZE_IN_FIELDS`
array, so the real packed bytecode is capped at ~93 KiB. But the
declared length is unbounded: a ~96 KiB log declaring 16 MiB forces a 16
MiB allocation on every node that validates the tx, before the proof
check rejects it — a memory-pressure / validation-amplification path.

## Fix

- Add an optional `maxByteLength` parameter to `bufferFromFields`; when
provided, it throws (before allocating) if the declared length exceeds
it. Callers that don't pass it keep the documented blob-reconstruction
padding behaviour unchanged.
- `fromLog` passes the fixed log's payload capacity (`payloadFields ×
31`), so an over-declared length is rejected before allocation.
`DataTxValidator` already wraps `fromLog` in try/catch, so the throw
becomes a clean pre-proof `TX_ERROR_MALFORMED_CONTRACT_CLASS_LOG`
rejection.

## Tests

- `data_validator.test.ts`: a log declaring a 16 MiB length is rejected,
and `Buffer.alloc` is never called with the declared size (test time
drops from ~1.2s to ~40ms — no large allocation). Run against the
pre-fix compiled code, the allocation still happened (red); green after
the fix.
- Existing `contract_class_published_event.test.ts` "fits a max-size
public bytecode" and the `buffer.test.ts` padding tests confirm
legitimate max-size bytecode and the padding contract are unaffected.
…24214)

Fixes A-1256.

## Problem

The gossipsub full message id was `SHA256(topic || data)[0..20]` with
**no framing** between the topic string and the message bytes, and
`LibP2PService` set no `allowedTopics`. Raw concatenation isn't
injective: for a real message `(T, D)`, a peer can craft `T' = T +
D[0]`, `D' = D[1:]` so `T' || D'` is byte-identical to `T || D` → same
msgId. ChainSafe gossipsub transforms the arbitrary-topic message and
inserts that id into `seenCache` **before** the subscription check (it
isn't delivered to us, since we're not subscribed to `T'`). When the
genuine proposal/attestation arrives on `T`, gossipsub drops it as a
**duplicate** before application validation, peer scoring, or handling —
suppressing a time-sensitive consensus message within the slot.

## Fix (defense in depth — either alone breaks the attack)

1. **Frame the msgId input** as `uint32be(topicLen) || topic || data`
(`encoding.ts`). The topic length pins the `(topic, data)` boundary, so
a boundary-shifted pair no longer collides. (`getMsgIdFn`'s parameter is
narrowed to `Pick<Message, 'topic' | 'data'>` — the only fields it reads
— which stays assignable to gossipsub's `msgIdFn` slot.)
2. **Set exact `allowedTopics`** (`libp2p_service.ts`) to the subscribed
Aztec topic strings. Verified against the installed gossipsub: the
allowlist is enforced in `handleReceivedRpc` *before*
`handleReceivedMessage`/`seenCache.put`, so an unsubscribed-topic
message is dropped before transform / msgId / seenCache.

## Test

`encoding.test.ts`: builds a real `P2PMessage.toMessageData()` buffer
(confirming `data[0] === 0x00`), constructs the shifted `(T', D')`, and
asserts the two msg ids now differ (they collided before the framing
change); plus a determinism check.

## Compatibility

`msgId` is computed locally for dedup; changing the function doesn't
change any on-wire format. During a rolling upgrade, mixed nodes briefly
compute different ids for the same message (minor IHAVE/IWANT
inefficiency), with no correctness impact.
## Merge `v5-next` into `merge-train/spartan-v5` — MUST merge as a merge
commit (do not squash)

### Why the conflicts came back
The previous stack (#24221/#24222) resolved the conflict correctly, but
#24221 was **squash-merged** onto the train. A squash creates a
brand-new single commit and does **not** record `v5-next` as a parent,
so git still sees the train and `v5-next` as diverged (`git merge-base
--is-ancestor origin/v5-next origin/merge-train/spartan-v5` → false).
The merge-train auto-pull then re-attempts `v5-next → train` and hits
the same conflict again.

### What this PR does
This is a real **merge commit** of `v5-next` into the train (two
parents: train tip `eb7d64d` + `v5-next` `4df7243`), with the one
conflict resolved. The resulting tree is **identical to the current
train** (0 file changes) because the content already landed via the
earlier squash — this PR exists purely to record `v5-next` as an
ancestor and advance the merge base.

After this lands, `v5-next` is an ancestor of the train and the
long-lived `merge-train/spartan-v5` → `v5-next` PR is conflict-free.

### Conflict resolved (1)
`yarn-project/end-to-end/src/e2e_nested_utility_calls.test.ts` — kept
the train's doc comment above the `denies…private function` test; the
`msg_sender` test from `v5-next` is already present.

### ⚠️ Merge method
**Merge this with "Create a merge commit"** (or `merge_pr
merge_method=merge`). A squash or rebase merge will recreate the same
divergence and the conflicts will return.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/51d528a3438f2849) ·
group: `slackbot`*
…255) (#24215)

Fixes A-1255.

## Problem

A discovery peer can advertise a signed ENR with valid `ip`/`udp`
contact data and the expected Aztec ENR version key, but a **malformed**
`tcp` field (e.g. 3 bytes instead of a 2-byte port).
`DiscV5Service.validateEnr` only checked the aztec key + version, so
upstream discv5 accepted the session and emitted `enrAdded`. The
unguarded async `onEnrAdded` listener then awaited
`enr.getFullMultiaddr('tcp')`, which throws `RangeError` on the
malformed value; as an async EventEmitter listener the rejection is
unhandled and **the node process exits**. One malformed,
UDP-contactable, same-version ENR crashes any p2p-enabled node.

## Audit

A sweep of every `getFullMultiaddr` / `getLocationMultiaddr` call in
`p2p/src` found the same unguarded-parse pattern in more than one place:

| Site | ENR source | Crash today | Action |
|------|-----------|-------------|--------|
| `discV5_service.ts` `onEnrAdded` | remote | yes | guard + drop |
| `bootstrap.ts` `discovered` listener | remote | yes (crashes the
**bootnode**) | guard + drop |
| `peer_manager.ts` `handleDiscoveredPeer` | remote | no (caught by
wrapper) | covered by `validateEnr` rejecting malformed-addr ENRs |
| `peer_manager.ts` / `libp2p_service.ts` `preferredPeers` | config |
aborts setup | skip + log |

## Fix

- Guard `onEnrAdded` and the bootnode `discovered` listener so a parser
exception is caught, logged, and the ENR dropped — no unhandled
rejection.
- `validateEnr` rejects an ENR whose TCP multiaddr won't parse, so it's
never emitted as a full peer (`PeerEvent.DISCOVERED`) or re-parsed by
`handleDiscoveredPeer`. A *missing* TCP addr stays allowed — those peers
are simply skipped at dial time; only an *unparseable* one is rejected.
- `preferredPeers` ENR parsing skips and logs a malformed configured ENR
instead of aborting node / preferred-peer setup.

The self-ENR log line (`discV5_service.ts:199-200`) is intentionally
left unguarded — it reads our own ENR, which we build and which can't be
attacker-malformed.

## Tests

`discv5_service.test.ts`: builds a same-version ENR with valid
`ip`/`udp` and a 3-byte `tcp` value, and asserts (a) `validateEnr`
rejects it while a valid control built from the same identity passes —
isolating the TCP check — and (b) `onEnrAdded` resolves (no unhandled
rejection, previously a `RangeError`) and never emits `DISCOVERED`.

Note: `discV5_service.ts` overlapped with the already-merged PR #24169;
this branch is off the post-merge `merge-train/spartan-v5`, so no
conflict.
…nt state

The archive tree is an append-only accumulator of block-header hashes, so a single
bad leaf (e.g. from a mishandled reorg) is never self-corrected: every later root
stays noncanonical while the other state trees can re-converge from block effects.
sync_block only checked the four non-archive trees, so a self-consistent orphan
block — commonly an empty one — could silently fork the archive root from canonical.

Verify the archive root against canonical both before appending (the committed root
must equal the block's lastArchive) and after (the resulting root must equal the
block's archive), failing before commit so the divergence is never persisted. The
checks are optional std::optional parameters; the napi and wsdb transports forward
the canonical roots from the block being synced.
Fixes
[F-664](https://linear.app/aztec-labs/issue/F-664/e2e-test-constrained-delivery-via-non-interactive-handshake)

Add e2e tests for discovering events and notes sent with constrained
delivery.

---------

Co-authored-by: Nicolas Chamo <nicolas@chamo.com.ar>
BEGIN_COMMIT_OVERRIDE
fix(p2p): re-seed discovery from persisted peer ENRs after restart
(#24169)
docs(e2e): annotate e2e tests with setup/category notes (#24191)
chore: merge v5-next into merge-train/spartan-v5 (raw, conflict markers)
(#24221)
fix(archiver): index zero-field logs under empty tag instead of throwing
(A-1253) (#24212)
fix(prover-node): report awaiting-root and publishing-proof phases in
EpochSession (A-1212) (#24216)
fix(p2p): bound declared contract-class bytecode length before
allocating (A-1258) (#24213)
fix(p2p): frame gossipsub msgId and restrict allowedTopics (A-1256)
(#24214)
chore: merge v5-next into merge-train/spartan-v5 (#24226)
fix(p2p): guard ENR address parsing against malformed TCP fields
(A-1255) (#24215)
END_COMMIT_OVERRIDE
…nt state (#24229)

Fixes A-1235

# A-1235 reviewer notes: archive-root divergence and the v4/v5
block-stream race

## Short version

The A-1235 symptom was a mainnet fisherman rejecting every proposal with
`ReExInitialStateMismatchError`. The proposal/canonical
`lastArchive.root` was correct; the local
world-state archive root was wrong.

The important distinction for review is:

- v4.3.0 appears vulnerable to a specific block-stream/cache race that
can seed this state.
- v5 has already hardened that specific race.
- v5 still lacks the world-state archive-root invariant, so if any other
path ever writes a bad
  archive leaf, the corruption can still be committed silently.

This change is therefore defense in depth at the only layer that can
conclusively reject a bad
archive tree before it is persisted: `WorldState::sync_block`.

## What went wrong

World state maintains the archive tree as an append-only accumulator of
block-header hashes. During
`sync_block`, v4.3.0 appended the new block header hash and then
checked:

- `is_archive_tip`: the just-appended header hash is the tip leaf.
- `is_same_state_reference`: nullifier, note hash, public data, and
L1-to-L2 message trees match the
  block state reference.

Neither check verifies the archive root against canonical block data:

- before append: local archive root must equal the block's
`lastArchive.root`;
- after append: computed archive root must equal the block's
`archive.root`.

That means an orphan block can be internally self-consistent and still
poison the archive tree.
If the orphan and canonical block at the same height have the same
effects, commonly because both
are empty, the four non-archive trees can reconverge while the archive
tree remains permanently
forked.

The resulting sequence is:

1. World state syncs an orphan block at height `F`.
2. The archiver later canonicalizes a different block at height `F`.
3. The block stream should prune world state to `F - 1`.
4. In the suspected v4 race, it prunes only to `F`, so the orphan
archive leaf remains.
5. World state syncs canonical descendants starting at `F + 1` on top of
the orphan leaf.
6. The four-tree state-reference check passes, but every archive root
from `F` onward is off-chain.

## The v4 block-stream/cache race

The original explainer says roughly: `getL2Tips` had processed the
reorg, while the archiver had not
yet swapped orphan `F` for canonical `F`.

More precisely, this is not about committed DB state being partially
updated. The block/checkpoint
mutation is one LMDB writer transaction. The race is that v4's
`L2TipsCache` can publish a tip
computed from the uncommitted writer view, while separate block/header
reads still use committed
read transactions.

In v4.3.0:

- `ArchiverDataStoreUpdater.addCheckpoints()` wraps prune, checkpoint
insertion, logs, contract data,
  and `l2TipsCache.refresh()` in one `store.transactionAsync(...)`.
- `L2TipsCache.refresh()` assigns `#tipsPromise = this.loadFromStore()`
before the writer transaction
  commits.
- The LMDB wrapper reuses the active write transaction for reads inside
that callback, so
  `loadFromStore()` can see the post-reorg uncommitted view.
- A concurrent consumer calling `archiver.getL2Tips()` can receive that
cached future/post-reorg tip.
- The same consumer's later `getBlockHeader(F)` call is outside the
writer context, so it opens a
normal committed read transaction and can still see the pre-reorg orphan
at `F`.

The v4 `L2BlockStream` performs exactly this mixed read pattern:

```ts
const sourceTips = await this.l2BlockSource.getL2Tips();
const localTips = await this.localData.getL2Tips();

let latestBlockNumber = localTips.proposed.number;
const sourceCache = new BlockHashCache([sourceTips.proposed]);
while (!(await this.areBlockHashesEqualAt(latestBlockNumber, { sourceCache }))) {
  latestBlockNumber--;
}
```

`areBlockHashesEqualAt()` then asks the source for a per-height hash via
`getBlockHeader(blockNumber)`.
So a single pass can be planned from a future/post-reorg tip but compare
old committed per-height
headers. If both local world state and the committed archiver still have
the orphan at `F`, the walk
can falsely conclude `F` is the common ancestor. Pruning to `F` keeps
block `F`; the correct target
was `F - 1`.

That is the suspected seed path for A-1235. The live evidence proves the
archive tree forked; the
historical interleaving predates retained logs, so treat this as the
best source-grounded mechanism,
not as directly logged fact.

## What v5 changes in this area

v5 has multiple changes that make the specific v4 race much less
plausible.

### 1. Tip cache refresh is post-commit

In v5, `L2TipsCache` says refresh should happen after the writer
transaction has committed, and
`ArchiverDataStoreUpdater` does exactly that:

```ts
const result = await this.stores.db.transactionAsync(async () => {
  // mutate blocks/checkpoints/logs/etc.
  return ...;
});
await this.l2TipsCache?.refresh();
return result;
```

So the cache is loaded from committed DB state, and an aborted writer
cannot replace the cache with
a future view.

### 2. `getL2TipsData()` is one DB snapshot

v5 moves chain-tip construction into
`BlockStore.getL2TipsData(genesisBlockHash)` and wraps it in a
single `db.transactionAsync(...)`. It also validates the resulting tier
ordering:

- finalized <= proven <= checkpointed <= proposed;
- checkpointed block <= proposed block.

That is materially stronger than v4's `L2TipsCache.loadFromStore()`,
which assembled tip numbers and
block data through several independent store calls.

### 3. The block stream fails closed on incoherent source reads

v5's `L2BlockStream` no longer uses the old checkpoint-prefetch path in
the same way. It drives from
a source tips snapshot, compares per-height hashes via `getBlockData({
number })`, and adds guards:

- missing local hash compares unequal rather than accidentally stopping
the walk;
- missing source data at or below the advertised source proposed tip
aborts the pass;
- source tips are re-read after a prune before downstream
reconciliation;
- the download pass verifies the delivered proposed block hash matches
the snapshot's proposed hash;
- tier advancement is skipped if the block download plan did not
complete.

These are all aimed at preventing a stale or mixed source snapshot from
becoming an under-deep prune.
Over-deep or skipped reconciliation is recoverable; under-deep pruning
is dangerous because it can
leave the losing fork's block at the divergence height.

## Why this change still matters on v5

The v5 block stream fixes the known/suspected seed path, but it does not
add the missing invariant to
world state.

Without this patch, `sync_block` can still commit a divergent archive
tree if any future path presents
it with a self-consistent block whose non-archive state matches:

- a different reorg bug;
- a cache/snapshot bug elsewhere;
- operator/datadir corruption;
- a future refactor that bypasses one of the v5 block-stream
protections.

Once a bad archive leaf is committed, appending more canonical leaves
does not repair it. The archive
root remains noncanonical forever while the four non-archive trees can
look healthy.

The fix makes `sync_block` verify the archive tree at the source of
truth:

```cpp
// Before append: committed local root must be the block's parent archive root.
actual_previous_archive_root == expected_previous_archive_root

// After append, before commit: uncommitted computed root must be the block's archive root.
actual_archive_root == expected_archive_root
```

These checks turn silent permanent corruption into a loud sync failure
before commit. The node may
need a datadir resync, but it will not write and seal in the divergent
archive leaf.

## Reviewer checklist

- Confirm the TS/native message path passes both canonical roots:
  - `expectedPreviousArchiveRoot = l2Block.header.lastArchive.root`
  - `expectedArchiveRoot = l2Block.archive.root`
- Confirm native `sync_block` checks the previous root before
`add_value`.
- Confirm native `sync_block` checks the resulting root before `commit`.
- Confirm errors clearly tell operators that local world state diverged
and must be resynced.
- Confirm tests cover the archive-only divergence case, not just
non-archive state-reference mismatch.

## Expected operator behavior

This patch prevents recurrence; it does not repair an already-poisoned
archive tree. A node that
already has the bad historical leaf must wipe/resync its world-state
datadir.
…24202)

## Motivation

The sentinel is required for the network to reach consensus on slashing
offenses — it's a core duty of participating in consensus, not an
optional add-on. Yet `SENTINEL_ENABLED` defaulted to `false`, so a
validator could run without one and silently degrade the network's
ability to enforce its own rules.

While fixing that, two adjacent issues surfaced:

- Whether the node runs the sentinel and the slashing-detection watchers
was gated on a *prover-only* flag (`enableProverNode &&
disableValidator`). That coupling is wrong: those subsystems should
depend on **validator status**, not on whether the prover is enabled.
- A non-validator (e.g. an RPC or full node) had no way to collect and
inspect offenses, because the only component that persists them lives
inside the slasher client, which was created only for validators.

## Approach

- **Force the sentinel for validators.** `createSentinel` now
self-gates: if the node runs a validator it always creates the sentinel
(ignoring `SENTINEL_ENABLED`); otherwise it respects `SENTINEL_ENABLED`
(default `false`). It logs which case applies so operators understand
why a `SENTINEL_ENABLED=false` is being overridden.
- **Decouple subsystem gating from the prover.** Removed the
`proverOnly` guard. The slashing-detection watchers and the slasher are
now gated on `collectOffenses = !disableValidator ||
enableOffenseCollection`.
- **Opt-in offense collection for non-validators.** A new
`OFFENSE_COLLECTION_ENABLED` flag lets a non-validator run the watchers
plus a read-only slasher client (it never writes to L1 —
voting/execution flow through the sequencer publisher, which a
non-validator lacks) to collect and serve offenses over the existing
`getSlashOffenses` admin RPC. The spartan deploy enables it by default,
mirroring `SENTINEL_ENABLED`.
- **Make the attested-invalid-proposal watcher work without a
validator.** Lifted the invalid-proposal / equivocation slot tracking
out of `ValidatorClient` into `ProposalHandler`, which now implements
`InvalidProposalSlotSource`. The handler populates this from its
all-nodes proposal handlers, so any node that re-executes proposals (the
default) can feed the watcher.

## API changes

- New node config `enableOffenseCollection` (env
`OFFENSE_COLLECTION_ENABLED`, default `false`). Named distinctly from
the existing L1-deploy `AZTEC_SLASHER_ENABLED` to avoid confusion.
- Validator nodes now always run the sentinel regardless of
`SENTINEL_ENABLED`.

## Changes

- **aztec-node**: `createSentinel` forces the sentinel for validators
and logs the reason; `server.ts` removes `proverOnly`, gates watchers +
a split-out read-only slasher on `collectOffenses`, and wires the
attested-invalid-proposal watcher to the proposal handler.
- **aztec-node (config) / foundation**: new `enableOffenseCollection` /
`OFFENSE_COLLECTION_ENABLED` flag and env-var entry.
- **validator-client**: `ProposalHandler` now owns the invalid-proposal
/ equivocation slot tracking and implements `InvalidProposalSlotSource`;
`ValidatorClient` delegates to it, preserving validator behavior.
- **spartan**: `OFFENSE_COLLECTION_ENABLED` wired through the chart
value, pod template, network defaults, terraform variable, and deploy
script, defaulting on (mirrors `SENTINEL_ENABLED`).

Fixes A-1242
…empotent (#24227)

## Motivation

The archiver preloads every bundled protocol contract class (and its
canonical instance) into its local store at synthetic block 0, before L1
sync. World-state genesis, however, seeds no registration nullifiers for
those classes/instances. As a result a *first* on-chain
`ContractClassRegistry.publish` of a bundled protocol class id is
protocol-valid (fresh class-id nullifier + `ContractClassPublished`
log). On replay the archiver recomputes the same class id and
unconditionally re-inserts it, but the store throws on the pre-existing
block-0 key (`Contract class <id> already exists, cannot add again`).
Because that insert runs inside the block/checkpoint store transaction,
the throw aborts persistence, and L1 sync retries the same valid
checkpoint indefinitely — a sync stall.

## Approach

Make the archiver treat protocol-preloaded entries as idempotent and
immutable, guarded at the store layer (the single chokepoint for both
the add-throw and the block-gated delete):

- `addContractClass` / `addContractInstance`: when the key already
exists and it is a protocol class id / magic protocol address, treat the
(re-)publish as a no-op and keep the existing block-0 entry — crucially
**without** bumping its recorded block number. Genuine non-protocol
duplicates still throw unchanged.
- `deleteContractClass` / `deleteContractInstance`: skip deletion for
protocol entries, so a reorg of the publishing block can never roll out
the preload.

Keeping the preload at block 0 is deliberate: `deleteContractClass` only
deletes when the stored `l2BlockNumber >= blockNumber`, so block 0 makes
protocol entries survive any reorg; bumping to the publish block would
let a deep reorg delete them.

The instance-side guards are defensive only: on-chain
`publish_for_public_execution` always emits the *derived* address, never
a magic address, so the instance store is not reached with a magic
address via the on-chain replay path today — the guards exist for
symmetry and to protect future code paths.

This is a non-breaking, node-local resilience fix. A follow-up PR (PR2)
seeds the protocol registration nullifiers into world-state genesis so
the on-chain re-publish is rejected at the protocol level — the
root-cause fix for the class path.

## Changes

- **protocol-contracts**: add `isProtocolContractClass(classId)`
(sibling of the existing `isProtocolContract(address)`), backed by a set
of the generated `ProtocolContractClassId` values.
- **archiver**: idempotent add + protected delete for protocol classes
and instances in `ContractClassStore` / `ContractInstanceStore`.
- **archiver (tests)**: store unit tests (idempotent re-add stays
queryable with block-0 / bytecode-commitment preserved, protected
delete, non-protocol duplicate still throws — for both classes and
instances) and an A-1257 integration test that preloads protocol
contracts, builds an `L2Block` carrying a `ContractClassPublished` log
for a bundled class id, and asserts `addProposedBlock` commits and the
class stays queryable.

Fixes A-1257
…24065)

Fixes a race in `e2e_token_bridge_tutorial_test` where L1 setup
transactions were submitted but not awaited before dependent bridge
calls.

- Waits for the `TestERC20.addMinter` transaction receipt before minting
through the handler.
- Waits for the `TokenPortal.initialize` transaction receipt before
simulating `depositToAztecPublic`.
- Prevents intermittent `SafeERC20FailedOperation(address token) (0x0)`
when the portal deposit simulation runs before `underlying` is
initialized.

See http://ci.aztec-labs.com/e100f59864b9c18c for sample failed run.
Backports the source-doc corrections from two already-merged `next` docs
PRs to the `v5-next` release line, where the same pages were stale:

- #24005 — `fix(docs): fable review` (merged Jun 22)
- #23830 — `docs: complete and correct the proving historic state page`
(merged Jun 22)

`v5-next` has no `version-v5.0.0-rc.1` versioned snapshot (its versioned
docs are v4.3.0-era), so this touches **source docs only** — the
authored pages under `docs/docs-developers/` and `docs/docs-operate/`.

## What's included (12 files)

Each fix was re-verified against `v5-next` source, not blindly copied
from `next`/v6:

**From #23830**
- `aztec-nr/.../advanced/how_to_prove_history.md` — rewrite. Every
history fn it documents (`assert_note_existed_by`,
`assert_note_was_nullified_by`, `assert_note_was_valid_by`,
`assert_contract_bytecode_was_published_by`,
`assert_contract_was_initialized_by`, `public_storage_historical_read`)
exists verbatim in v5-next's `aztec/src/history/`.

**From #24005**
- `aztec-nr/.../how_to_retrieve_filter_notes.md`, `state_variables.md` —
`RetrievedNote`/`HintedNote` → `ConfirmedNote` (type confirmed present
on v5-next).
- `aztec-nr/.../events_and_logs.md` — nonexistent
`self.context.emit_public_log(...)` → `emit_public_log_unsafe`
(confirmed at `public_context.nr:110` on v5-next).
- `aztec-nr/.../contract_structure.md` — storage struct must be named
`Storage` (version-agnostic).
- `foundational-topics/contract_creation.md` —
`aztec::history::contract_inclusion` → `aztec::history::deployment` (the
`deployment` module exists on v5-next).
- `foundational-topics/.../outbox.md` — checkpoint-based `IOutbox`
(`getRootData(Epoch, uint256)` confirmed in v5-next's `IOutbox.sol`).
- `cli/aztec_cli_reference.md` — fills the empty `aztec start` section;
all documented module flags (`--network`, `--node`, `--sequencer`,
`--prover-node`, `--prover-broker`, `--prover-agent`, `--p2p-bootstrap`,
`--txe`, `--bot`, `--admin-port`) exist in v5-next's
`aztec_start_options.ts`.
- `cli/aztec_wallet_cli_reference.md` — removes generator-machine
leakage in default values (`/home/josh/.aztec/wallet` →
`~/.aztec/wallet`, `host.docker.internal` → `localhost`).
- `operators/.../governance-participation.md`, `useful-commands.md` —
stale governance calls fixed: `M()`→`ROUND_SIZE()`,
`N()`→`QUORUM_SIZE()`, `yeaCount(...)`→`signalCount(...)`,
`proposals(uint256)`→`getProposal(uint256)`. Confirmed: v5-next
`Governance.sol` exposes `getProposal`, `EmpireBase.sol` exposes
`ROUND_SIZE`/`QUORUM_SIZE`/`signalCount`; old names are gone.
- `tutorials/js_tutorials/token_bridge.md` — Hardhat artifact-path /
run-command corrections; the `@aztec/l1-contracts` version note uses the
`#include_aztec_version` macro, so it resolves to the v5 version
automatically.

## Deliberately excluded — needs a v5-specific numeric pass

- **`slashing-configuration.md`** — #24005's numbers are
`next`/mainnet-framed and **do not match the live v5 testnet**. The v5
upgrade deploy (`DeployRollupForUpgradeV5.s.sol`, Sepolia branch) sets
`localEjectionThreshold = 199,000e18` and slash amounts **100k / 250k /
250k** (AZIP-16 full-stake), versus the doc's 190k + 2,000/5,000. Slot
duration (72s) and round size (128 slots) do check out for v5 (confirmed
via live testnet block spacing of 72s). This page should be rewritten
with verified v5-testnet values in a follow-up rather than copied.

Source PRs:
[#24005](#24005),
[#23830](#23830). No
tracked issue is closed by this backport.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/59cd7d98b38b9d90) ·
group: `slackbot`*
Backports the counter-tutorial half of #23517 (`docs: align counter
tutorial example with the aztec init template and add TXE tests`, merged
to `next` Jun 22) to `v5-next`.

## Why this is the right scope

#23517's core intent was to **align the counter tutorial example with
what `aztec init` generates**. On `v5-next` the scaffold template is
*already* updated —
`yarn-project/aztec/scripts/templates/counter/contract/src/main.nr`
already defines `fn constructor(initial_value: u128, ...)`. But the
tutorial prose and the inlined example contract are still on the old
`initialize(headstart)` naming, so the v5 tutorial currently contradicts
the code `aztec init` produces. This backport closes that gap.

## Files (2)

-
`docs/docs-developers/docs/tutorials/contract_tutorials/counter_contract.md`
— `initialize`→`constructor`, `headstart`→`initial_value`, intro
reworded (encrypted private state), heading casing.
- `docs/examples/contracts/counter_contract/src/main.nr` — same rename,
so the tutorial's `#include_code constructor` block matches both the
prose and the v5 scaffold template.

## Deliberately excluded

- **TXE test harness** (`counter_contract_test`, the
`docs/examples/bootstrap.sh test-contracts` step, `Nargo.toml` member,
`logging_example_test` tweak) — that's CI tooling, not docs, and
`counter_contract_test` doesn't exist on `v5-next`.
- **Scaffold template** — already correct on `v5-next`; no change
needed.

## Safety check

`recursive_verification_contract` also uses a `headstart` parameter, but
it's an independent contract with its own `constructor(headstart: Field,
...)` (untouched by #23517 even on `next`), so the counter rename
doesn't affect it.

Source PR:
[#23517](#23517). No
tracked issue is closed by this backport.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/59cd7d98b38b9d90) ·
group: `slackbot`*
spalladino and others added 24 commits July 6, 2026 17:32
)

## Human-written summary

Invalid numeric values in config were silently ignored and replaced with
the default. Also, config entries defined with `numberConfigHelper`
accepted non-integer values but silently truncated them.

## Context

The numeric config helpers mishandled bad input in two ways:

- `numberConfigHelper` / `optionalNumberConfigHelper` parsed with
`parseInt`, which silently truncated decimals — an integer config set to
`0.8` became `0` with no warning. In the worst case (A-1312) that turned
a fractional retention window into `0`, i.e. "delete immediately".
- `numberConfigHelper`, `floatConfigHelper` and `percentageConfigHelper`
swallowed any unparseable value and returned the config default (the
JSDoc even documented "...or is invalid"), so a typo'd env var ran
silently with an unexpected value.

## Approach

The config default is for an **unset** env var only — empty/unset is
already resolved to the default before `parseEnv` runs, so the only
thing `parseEnv` returning a default achieved was hiding bad input. The
helpers now parse strictly and throw on any set-but-invalid value:

- `numberConfigHelper` / `optionalNumberConfigHelper`: parse with
`parseFloat` and require a safe integer (no more silent truncation).
- `floatConfigHelper` / `percentageConfigHelper`: require a finite
number; percentage additionally enforces 0–1.

`bigint`, `enum` and the secret helpers already threw on invalid input.
`booleanConfigHelper` is intentionally unchanged: `parseBooleanEnv` is a
total function (unrecognized tokens map to `false`), it never
substitutes the configured default.

Auditing all ~150 `numberConfigHelper` call sites then surfaced options
that legitimately take fractional values and would now wrongly reject
them; these were moved to `floatConfigHelper`:

- p2p gossipsub tx scoring — topic weight, invalid-message-delivery
weight, and decay (default `0.5`), which feed libp2p's float
`TopicScoreParams`.
- sequencer `perBlockDAAllocationMultiplier` (default `1.5`); its
sibling `perBlockAllocationMultiplier` already used `floatConfigHelper`.
- L1 tx fee percentages — `gasLimitBufferPercentage`,
`priorityFeeBumpPercentage`, `priorityFeeRetryBumpPercentage`.
- bot `minFeePadding`, consumed as the fractional overpay factor `1 +
padding`; its zod schema no longer pins it to an integer.

## Impact

A node whose numeric config env var (or CLI flag) is set to a
non-numeric, fractional (for integer options), or out-of-range value now
fails at startup with a clear error instead of silently running with a
truncated or default value. Operators relying on the old lenient
behavior must correct the value.

Fixes A-1398
…(A-1401) (#24537)

## Context

A-1351 (#24515) fixed the honest-proposer and relay path for
non-canonical yParity attestation signatures, but a malicious selected
proposer can still hand-craft `propose()` calldata carrying a
non-proposer signature slot with `v ∈ {0,1}` (or an all-zero `(r,s,v)`
in a bitmap-marked signature slot). L1 accepts this at propose time (it
only recovers the proposer's own slot) and stores the attestations hash
over the raw bytes. Afterwards, either:

- the slot recovers to the correct member, so honest nodes deem the
checkpoint valid and nobody invalidates it — but epoch proving reverts
at `ECDSA.recover` (which rejects `v ∉ {27,28}`), a silent stall; or
- the slot is detected as invalid, but the invalidation repack via
`packAttestations` is not byte-faithful, so the recomputed hash diverges
from the stored one and `InvalidateLib` reverts — wedging the chain
until prune.

This is the malicious-proposer sibling of A-1351; it survives that fix.
Tracks aztec-claude#650.

## Approach

TS-only — no L1 changes, since this targets a fresh deployment. Two
parts:

- **Detection** — `getAttestationInfoFromPayload` no longer passes
`allowYParityAsV: true`, so TS signature validity matches L1's
`ECDSA.recover`. A non-canonical slot is now classified as an invalid
attestation, so honest nodes detect the bad checkpoint instead of
silently accepting it.
- **Byte-faithful invalidation** — the raw packed
`CommitteeAttestations` tuple from the checkpoint calldata is threaded
verbatim (as `verbatimAttestations`) through the negative
`ValidateCheckpointResult` into `buildInvalidateCheckpointRequest`,
which submits those exact bytes rather than repacking. With the original
bytes, `invalidateBadAttestation` reproduces the stored hash and L1's
`tryRecover` returns `address(0) ≠ member`, so invalidation succeeds.

`verbatimAttestations` is a required field on the negative result and is
serialized unconditionally — a repack fallback is intentionally absent,
since silently repacking would re-introduce the wedge.

The gossip sender-recovery path (`recoverCoordinationSigner`)
deliberately stays lenient (`allowYParityAsV`) so an honest node can
still attribute a yParity-encoded message and canonicalize it on
ingress; only the on-chain checkpoint validation is made strict. A
comment documents the split so it is not accidentally unified.

## Tests

An e2e regression under the invalidation suite
(`invalidate_block.parallel.test.ts`, "proposer invalidates checkpoint
with a yParity attestation slot") demonstrates the attack red→green: a
malicious proposer lands a checkpoint whose non-proposer attestation
slots carry raw yParity bytes, and the honest next proposer invalidates
it — which only succeeds when the invalidation submits the verbatim
calldata bytes (a repack diverges from the on-chain `attestationsHash`
and reverts). Plus unit coverage for the strict attestation-info
classification, the byte-faithful passthrough, and the result
round-trip.

An attester-side variant (a committee member emitting yParity
attestations) is not separately tested: the honest proposer's
`orderAttestations` normalization (A-1351) canonicalizes the byte before
the L1 bundle, so it never reaches L1 and produces the same on-chain
outcome as — and is subsumed by — the malicious-proposer case above.

Fixes A-1401
Closes F-771

---------

Co-authored-by: Nicolas Chamo <nicolas@chamo.com.ar>
## Problem

`l1_tx_utils.test.ts` flaked in CI
([log](http://ci.aztec-labs.com/390c881d64e094b6), seen on
[#24454](#24454 (comment)),
which is unrelated to the failure). The failing test was `monitors all
sent txs`:

```
● L1TxUtils › L1TxUtils with blobs › monitors all sent txs
  TimeoutError: L1 transaction 0x82cf...aa3a timed out
      at TestL1TxUtils.monitorTransaction (l1_tx_utils/l1_tx_utils.ts:601:11)
```

## Root cause

The test creates the monitor promise and only attaches its rejection
expectation after several intervening awaits:

```ts
const monitorPromise = gasUtils.monitorTransaction(state);
await sleep(100);
await cheatCodes.mineEmptyBlock();
await expect(monitorPromise).rejects.toThrow('timed out'); // handler attached here
```

Anvil timestamps have 1s granularity, so when the wall clock crosses a
second boundary between the send and the monitor's timeout check, the
monitor (with `txTimeoutMs: 200`, `checkIntervalMs: 100`) can observe
the L1 timestamp already 1s past `sentAt` and reject **before**
`mineEmptyBlock()` completes. In the failing run the timeout fired at
`16:41:20.355`, before the empty block was mined at `.356` and before
the `.rejects` handler attached at `.357`. Node emitted
`unhandledRejection` in that window and jest 30 reported it as the test
failure — which is why the failure stack has no test-file frame and the
test's own assertions all passed in the log.

The same file already uses the safe idiom in a dozen newer tests: attach
the handler synchronously with `.catch(err => err)` and assert via
`resolves.toBeInstanceOf(TimeoutError)`.

## Fix

Apply that established pattern to the three remaining tests that expect
a monitor timeout but attach the handler only after intervening awaits:

- `stops trying after timeout once block is mined`
- `attempts to cancel timed out transactions`
- `monitors all sent txs` (the observed flake)

Test-only change; no product code touched. `TimeoutError` is exactly
what `monitorTransaction` throws on timeout, so the assertions are
equivalent-or-stricter than the previous message matching.

Evidence: CI log http://ci.aztec-labs.com/390c881d64e094b6 (search for
`monitors all sent txs`).

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/0959caa9f03f59fa) ·
group: `slackbot`*
…24546)

## Problem

`e2e_ha_full.parallel.test.ts` ("should produce blocks with HA
coordination and attestations") failed in CI on an unrelated PR (#24492)
before the test even started: Docker Hub returned a transient 502 while
`docker compose up` pulled `postgres:16-alpine` for the HA compose
stack.

CI log: http://ci.aztec-labs.com/cac25b9251c99496

```
postgres Error unknown: failed to resolve reference "docker.io/library/postgres:16-alpine":
unexpected status from HEAD request to https://registry-1.docker.io/v2/library/postgres/manifests/16-alpine: 502 Bad Gateway
```

This can hit any compose-based test (`run_compose_test` is shared by the
e2e compose/web3signer/ha suites and docs examples) whenever a required
image is not in the local Docker cache and the registry blips.

## Fix

Wrap the `docker compose up -d --force-recreate` in
`ci3/run_compose_test` with the existing `ci3/retry -p <regex>` helper,
retrying only failures that match transient registry/network errors
(5xx, HEAD-request failures, TLS/connection timeouts, rate limiting).
This reuses the same pattern-gated retry convention already used for
network flakes in `l1-contracts/bootstrap.sh`,
`barretenberg/sol/bootstrap.sh`, and
`noir-projects/aztec-nr/bootstrap.sh`, so genuine failures (e.g. an
image tag that doesn't exist) still fail immediately without retrying.

Retrying `up -d --force-recreate` is idempotent: a retry recreates any
containers from a partially failed attempt.

Verified the exact error message from the CI log matches the retry
pattern (retried, then succeeds), while a `manifest not found` failure
exits immediately without retries.

No tracked issue exists for this flake.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/0959caa9f03f59fa) ·
group: `slackbot`*

---------

Co-authored-by: Santiago Palladino <santiago@aztec-labs.com>
…uplicate_proposal test (#24557)

## Problem

`multi-node/slashing/duplicate_proposal › slashes validator who sends
duplicate proposals`
(`yarn-project/end-to-end/src/multi-node/slashing/equivocation_offenses.test.ts`)
flaked in CI on the unrelated PR #24556 with:

```
Target proposer 0x90f79bf6eb2c4f870365e785982e1f101e93b906 not found in any slot after 20 epoch attempts
```

Evidence: http://ci.aztec-labs.com/eb0b4e658e28440c (the sibling
`duplicate_attestation` case in the same file passed in the same run).

## Root cause

Pure statistics — no logic bug. `advanceToEpochBeforeProposer`
(`yarn-project/end-to-end/src/multi-node/slashing/setup.ts` on this
branch) scans upcoming epochs for one where the target validator is the
proposer, giving up after `maxAttempts = 20` epochs. The test calls it
with the default.

This suite uses a 2-slot epoch with `warmupSlots = 1`, so each epoch
attempt inspects exactly **one** candidate slot. The proposer for a slot
is `keccak256(abi.encode(epoch, slot, seed)) % committeeSize`, and with
the suite's 4-member committee (`COMMITTEE_SIZE = NUM_VALIDATORS = 4` in
`setup.ts`) each attempt is an independent 1/4 draw. Miss probability
over 20 attempts: `(3/4)^20 ≈ 0.32%`, i.e. roughly 1 in 315 runs —
matching the observed occasional flake.

## Fix

Raise the default `maxAttempts` from 20 to 50, bounding the miss
probability at `(3/4)^50 ≈ 5.7e-7` (~1 in 1.8M). Each attempt is only a
committee query plus an anvil timestamp warp, so the expected attempt
count stays ~4 and the extra headroom costs essentially nothing. The
raised default also covers this helper's other callers with the same
exposure (`broadcasted_invalid_block_proposal_slash`,
`broadcasted_invalid_checkpoint_proposal_slash`), none of which override
`maxAttempts`.

## Relationship to PR #24548

This is the same failure mode fixed by #24548 for the
`duplicate_attestation` flake, but #24548 does **not** cover this call
site: its patch touches `yarn-project/end-to-end/src/e2e_p2p/shared.ts`,
a file that does not exist on `merge-train/spartan-v5` (on this branch
the helper lives in `multi-node/slashing/setup.ts`, and there is no
`e2e_p2p/` directory). #24548's head branch was cut from a `next`-layout
tree while targeting `merge-train/spartan-v5`, which is why GitHub
reports it as dirty with ~4k changed files — it needs a rebase onto the
branch it targets, at which point its fix would land in this same file.
This PR applies the fix directly to the `merge-train/spartan-v5` copy of
the helper.

No tracked issue exists for this flake.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/0959caa9f03f59fa) ·
group: `slackbot`*
…test (#24543)

## Flake

`multi-node/block-production/multi_validator_node.parallel.test.ts`,
case "should build blocks & attest with multiple validator keys", failed
on [#24537](#24537)
(unrelated PR) with:

```
TypeError: Cannot read properties of undefined (reading 'checkpoint')
    at checkpoint (multi-node/block-production/multi_validator_node.parallel.test.ts:92:73)
```

CI log: http://ci.aztec-labs.com/0f321ffb9fa923af

## Root cause

`deployContractAndGetAttestedCheckpoint` reads the published checkpoint
from the archiver immediately after the deploy tx receipt turns mined:

```ts
const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 });
const payload = ConsensusPayload.fromCheckpoint(publishedCheckpoint.checkpoint, ...); // undefined here
```

The receipt turns mined as soon as the block is built locally, but the
archiver only stores the *published* checkpoint (with its attestations,
which come from the L1 `propose` calldata) once its L1 sync downloads
that tx. In the window between the two, `getCheckpoints` returns `[]`
and `publishedCheckpoint` is `undefined`.

The CI log shows the race directly: the test node published checkpoint 1
to L1 at `15:17:57.694` (`sequencer:publisher ... Published checkpoint 1
at slot 14`), and its archiver logged `Downloaded checkpoint 1` at
`15:17:57.795` — by which point the test had already failed and teardown
was running.

This is a test-side race, not a production bug: the archiver API is
behaving as documented for a not-yet-synced checkpoint.

## Fix

Poll for the checkpoint with `retryUntil` instead of reading it once,
mirroring the existing pattern in
`single-node/cross-chain/cross_chain_messaging_test.ts`
(`advanceToEpochProven`), which wraps the same lookup in
`retryUntil(..., 'archiver indexes checkpoint N', 120, 0.5)`.

Note: the `next`-line variant of this test
(`e2e_multi_validator/e2e_multi_validator_node.test.ts`) has the same
unguarded lookup in two places and could flake the same way; that file
is on a different base branch, so it is not touched here.

Based on `merge-train/spartan-v5` because the flaking file only exists
on the v5 line (it was consolidated into `multi-node/` there), and the
failing run was on a PR targeting that branch.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/0959caa9f03f59fa) ·
group: `slackbot`*
Closes #5837

This PR makes it so we no longer derive signing keys from the master
privacy key. If there's no user-supplied privacy key seed, we instead
derive it _from_ the signing key, which is a safe derivation: PXE never
sees the signing key nor any value it is derived from.

This has a large knock-on effect on wallets, CLI, tutorials, etc., but
there's ultimately no large decisions being made there, it is mostly
mechanical changes. In some cases I reworked some internals of wallets
to make this distinction clearer.
…during shutdown (v5 line, partial) (#24551)

## Flake

`multi-node/slashing/sentinel_status_slash.parallel.test.ts` ("slashes
an attestor that gets stopped after the network is running") flaked on
PR #24507 (unrelated change). CI log:
http://ci.aztec-labs.com/9bb09d2f904e290a

## What actually failed

The test itself **passed**. Immediately after, the node process crashed
with an **unhandled rejection** (`ECONNREFUSED` on anvil after teardown)
— something was still polling L1 after shutdown. Root cause:
`L1TxUtils.monitorTransaction`'s loop calls `getL1Timestamp()` at the
top of its loop, outside the loop's own try/catch, and nothing waited
for in-flight monitor iterations to actually stop during shutdown.

## Scope of this PR (rebased onto `merge-train/spartan-v5`)

This PR was originally written against `next` and touched 4 files. After
rebasing onto `merge-train/spartan-v5`, 2 of those files cherry-picked
cleanly and are included here:

- `yarn-project/ethereum/src/l1_tx_utils/l1_tx_utils.ts` — guards the
loop-top `getL1Timestamp()` call to break quietly when interrupted.
- `yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts` —
adds `.catch` to a bare fire-and-forget `backupDroppedInSim(...)` call.

The other 2 files (`publisher_manager.ts`, `checkpoint_proposal_job.ts`)
have diverged in structure on this branch and needed their own
investigation rather than a blind cherry-pick — that's covered
separately in
[#24560](#24560),
which found `publisher_manager.ts` had the same defect (fixed there) and
`checkpoint_proposal_job.ts` did not (this branch already routes
fire-and-forget L1 requests through a `RequestsTracker` that attaches
its own rejection handler).

No tracked issue exists for this flake; reference only.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/0959caa9f03f59fa) ·
group: `slackbot`*
…ts during shutdown (v5 line) (#24560)

Ports the remaining part of the `sentinel_status_slash` flake fix to the
v5 line. The flake crashed the jest process after all tests passed: an
in-flight `eth_getBlockByNumber` against the torn-down anvil surfaced as
an unhandled `L1RpcError` (`ECONNREFUSED`). CI log:
http://ci.aztec-labs.com/9bb09d2f904e290a

This complements #24551, which (after retargeting to
`merge-train/spartan-v5`) now carries only the two files that
cherry-picked cleanly from `next` — `l1_tx_utils.ts` (guard the loop-top
`getL1Timestamp()` in `monitorTransaction`) and `sequencer-publisher.ts`
(`.catch` on the fire-and-forget `backupDroppedInSim`). The other two
files had diverged on this branch and are addressed here; #24551's
title/body should be updated to reflect that it covers 2 of the original
4 files, with this PR covering the rest.

### `yarn-project/ethereum/src/publisher_manager.ts` — fixed

This branch's `stop()` (which, unlike `next`, also clears the `started`
flag and supports restart via `start()`) interrupted all publishers and
the funder but returned immediately, so an in-flight tx monitor loop
could still be issuing L1 RPC calls after test teardown. Applied the
equivalent of the `next` fix, fitted to this branch's structure:
`stop()` now awaits `waitMonitoringStopped()` on every publisher and the
funder before returning. `L1TxUtils.waitMonitoringStopped(timeoutSeconds
= 10)` already exists on this branch, is self-bounded, and swallows its
own timeout with a warning, so shutdown cannot hang.

###
`yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts`
— no change needed

On `next`, the votes-only path assigned a bare `pendingL1Submission =
publisher.sendRequestsAt(...)` with no rejection handler, and only the
last job's submission was awaited at shutdown, so an orphaned job's
rejection could crash the process. On this branch that field no longer
exists: both fire-and-forget paths go through
`this.pendingRequests.trackRequest(promise, () => this.interrupt())`,
backed by the sequencer's shared `RequestsTracker`
(`requests_tracker.ts`). `trackRequest` attaches a rejection handler
synchronously at track time (`promise.then(delete, delete)`), so a
tracked promise can never surface as an unhandled rejection regardless
of whether shutdown awaits it. Additionally, `Sequencer.stop()` calls
`pendingRequests.interruptRequests()` followed by `awaitRequests()`, so
all tracked requests — not just the last one — are interrupted and
drained at shutdown. The `next` bug does not exist here, so no change
was forced. (Minor observability note, not a bug: a rejection on the
votes-only path is swallowed silently by the tracker rather than
logged.)

Verified `publisher_manager.ts` parses cleanly; the full workspace
type-check runs in CI (this container lacks the bootstrapped
bb.js/noir/l1-artifacts toolchain).

No tracked issue.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/0959caa9f03f59fa) ·
group: `slackbot`*
BEGIN_COMMIT_OVERRIDE
fix(sqlite3mc-wasm): restore bundler-visible wasm resolution (#24529)
refactor(aztec-nr): rename get_handshakes to non_interactive variant
(#24511)
fix(pxe): UNFINALIZED_TAGGING_INDEXES_WINDOW_LEN should be
MAX_PRIVATE_LOGS_PER_TX + headroom, not exactly MAX_PRIVATE_LOGS_PER_TX
(#24437)
refactor(aztec-nr): keep handshake secrets internal to TagSecretSource
(#24508)
test(txe): extend oracle roundtrip coverage to more scalar oracles
(#24550)
refactor!: reimplement partial notes on FactStore (#24369)
 feat!: stop deriving signing key from privacy keys  (#24439)
END_COMMIT_OVERRIDE
BEGIN_COMMIT_OVERRIDE
perf(e2e): warp proven-checkpoint waits, tighten in-process polling, and
instrument setup spans (#24452)
fix: reject aztecSlotDuration not a multiple of ethereumSlotDuration
(#24481)
fix(config): fail loudly on invalid or fractional numeric config
(#24536)
fix(sequencer): invalidate malicious yParity attestation checkpoints
(A-1401) (#24537)
fix(ethereum): deflake monitor timeout tests in l1_tx_utils (#24545)
fix(ci): retry docker compose up on transient registry pull failures
(#24546)
fix(e2e): resolve flaky proposer-not-found in equivocation_offenses
duplicate_proposal test (#24557)
fix(e2e): resolve flaky checkpoint TypeError in multi_validator_node
test (#24543)
fix(ethereum): prevent unhandled L1 rejections from tx monitor loops
during shutdown (v5 line, partial) (#24551)
fix(sequencer): prevent unhandled rejections from in-flight L1 requests
during shutdown (v5 line) (#24560)
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
…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.
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
…24593)

`CheckpointSubTreeOrchestrator.addTxs` logs `Provided no txs to addTxs.`
at **warn** level, but this path is expected control flow, not an
anomaly: `checkpoint-prover.ts` calls `this.subTree.addTxs(processed)`
unconditionally for every block in the checkpoint, so every empty block
hits this early return (the empty block is then finalized via
`setBlockCompleted`). On low-traffic networks this fires constantly —
~100 times in 4h on staging-internal — polluting warn-level log reviews
with noise.

Demote it to `verbose`. No behavior change; no tests assert on this
message.

Replaces #24591 and #24592 (closed), per request to land this as a
single PR via the spartan-v5 merge train.

Spotted during a staging-internal log review requested by @spalladino in
Slack.

---
*Created by
[claudebox](https://claudebox.work/v2/sessions/ac050bae673edac4) ·
group: `slackbot`*
Adds a new `THREAT_MODEL.md` doc. This document describes the threat
model of the Aztec L2 network: how transactions flow from users into the
proven chain, what each participant can and cannot do, and the
properties the implementation must uphold. It is intended as a guideline
for the security of the node implementation (`yarn-project`) and its
interaction with the L1 rollup contracts (`l1-contracts`).

**In scope**: transaction dissemination and the mempool, the p2p layer,
block and checkpoint production, committee attestation, L1 checkpoint
submission and sync, epoch proving, slashing, and the escape hatch.

**Out of scope**: client-side private execution and proving (PXE,
wallets), hardening of a node's public RPC interface, the cryptographic
soundness of the proving system itself (treated as an assumption below),
and L1 governance internals.
@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 Jul 7, 2026
@AztecBot AztecBot changed the base branch from next to merge-train/spartan-v5 July 7, 2026 21:31
@AztecBot AztecBot force-pushed the cb/writing-e2e-tests-skill branch from 6057fdb to 1dcf5bc Compare July 7, 2026 21:31
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.