Skip to content

fix(world-state): verify archive root in sync_block to reject divergent state - #24229

Merged
PhilWindle merged 1 commit into
merge-train/spartan-v5from
spl/a-1235-harden-world-state-sync-block
Jun 23, 2026
Merged

fix(world-state): verify archive root in sync_block to reject divergent state#24229
PhilWindle merged 1 commit into
merge-train/spartan-v5from
spl/a-1235-harden-world-state-sync-block

Conversation

@spalladino

@spalladino spalladino commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Fixes A-1235

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:

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:

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:

// 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.

…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.
@PhilWindle
PhilWindle enabled auto-merge June 23, 2026 08:14
@PhilWindle
PhilWindle merged commit 4cde55f into merge-train/spartan-v5 Jun 23, 2026
25 checks passed
@PhilWindle
PhilWindle deleted the spl/a-1235-harden-world-state-sync-block branch June 23, 2026 08:30
PhilWindle pushed a commit that referenced this pull request Jun 24, 2026
## Summary
- backport #24229 archive-root verification in native world-state
sync_block
- forward canonical previous/resulting archive roots through the native
world-state message path
- adapt the C++ regression constants to v4 tree roots and keep the
v4-only transport surface

## Tests
- yarn workspace @aztec/world-state build
- ../barretenberg/cpp/bootstrap.sh build_preset clang20 --target
nodejs_module
- yarn build:native (from barretenberg/ts)
- yarn workspace @aztec/world-state test
src/native/native_world_state.test.ts
- ../barretenberg/cpp/bootstrap.sh build_preset clang20 --target
world_state_tests
- ../barretenberg/cpp/build/bin/world_state_tests
--gtest_filter=WorldStateTest.SyncBlockRejectsDivergentArchiveRoot
rangozd pushed a commit to rangozd/aztec-packages that referenced this pull request Aug 5, 2026
RAW cherry-pick of public merge 7a013ad (-m 1). Conflicts committed as git produced them; does
not build. Conflicted (modify/delete, next removed in wsdb restructure):
nodejs_module/world_state/{world_state.cpp,world_state_message.hpp},
wsdb/{wsdb_commands.hpp,wsdb_execute.cpp}. Conflicted (content):
world-state/src/native/{ipc_world_state_instance.ts,message.ts,native_world_state.ts}.
Resolution in fix(port) commit: archive-root change re-lands as ludamad's AztecProtocol#24229 port.
rangozd pushed a commit to rangozd/aztec-packages that referenced this pull request Aug 5, 2026
…hive-root form; next-align world-state

next's wsdb restructure removed the command surfaces AztecProtocol#24256's archive-root check was written
against. Dropped the 4 next-deleted C++ files; took next's version of the 3 native TS files and the
3 core world_state C++ files. The archive-root divergence check itself re-lands in the next commit
as ludamad's AztecProtocol#24229 port, implemented against next's wsdb_handlers structure.
rangozd pushed a commit to rangozd/aztec-packages that referenced this pull request Aug 5, 2026
…nt state (AztecProtocol#24229)

Ports A-1235's archive-root divergence check forward to next. The merge-train RAW
pick (AztecProtocol#24256) brought the TS tests for this feature but block 31 dropped the
implementation as "superseded" because next's world-state restructure had removed
it and the C++ auto-merge hung a WorldStateTest. The feature is not superseded —
it exists only on v5-next, and next is the vehicle bringing it in — so this ports
the implementation properly to next's wsdb (schema/handler) architecture rather
than v5-next's codegen-command one.

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. sync_block only
checked the four non-archive trees, so a self-consistent orphan empty block could
silently fork the archive root from canonical. Verify the archive root against
canonical both before appending (committed root must equal the block's lastArchive)
and after (resulting root must equal the block's archive), failing before commit.

- world_state.cpp/hpp: optional expected_archive_root / expected_previous_archive_root
  params + the before/after checks (verbatim from AztecProtocol#24229).
- wsdb_schema.jsonc + wsdb_handlers.cpp: thread the two optional Fr roots through
  next's SyncBlock wire/handler (re-expressed for the schema-driven IPC; v5-next used
  wsdb_commands.hpp/wsdb_execute.cpp).
- TS native_world_state.ts forwards the block's archive roots; ipc/native instance
  syncBlock input types carry them.
- world_state.test.cpp: 3 native tests (all pass locally); the TS "Archive root
  divergence" suite is the one AztecProtocol#24256 brought.

The napi transport files from AztecProtocol#24229 are intentionally omitted: next has no
nodejs_module world-state binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants