fix(world-state): verify archive root in sync_block to reject divergent state - #24229
Merged
PhilWindle merged 1 commit intoJun 23, 2026
Merged
Conversation
…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
approved these changes
Jun 23, 2026
PhilWindle
enabled auto-merge
June 23, 2026 08:14
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes A-1235
Short version
The A-1235 symptom was a mainnet fisherman rejecting every proposal with
ReExInitialStateMismatchError. The proposal/canonicallastArchive.rootwas correct; the localworld-state archive root was wrong.
The important distinction for review is:
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 theblock state reference.
Neither check verifies the archive root against canonical block data:
lastArchive.root;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:
F.F.F - 1.F, so the orphan archive leaf remains.F + 1on top of the orphan leaf.Fonward is off-chain.The v4 block-stream/cache race
The original explainer says roughly:
getL2Tipshad processed the reorg, while the archiver had notyet swapped orphan
Ffor canonicalF.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
L2TipsCachecan publish a tipcomputed 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 onestore.transactionAsync(...).L2TipsCache.refresh()assigns#tipsPromise = this.loadFromStore()before the writer transactioncommits.
loadFromStore()can see the post-reorg uncommitted view.archiver.getL2Tips()can receive that cached future/post-reorg tip.getBlockHeader(F)call is outside the writer context, so it opens anormal committed read transaction and can still see the pre-reorg orphan at
F.The v4
L2BlockStreamperforms exactly this mixed read pattern:areBlockHashesEqualAt()then asks the source for a per-height hash viagetBlockHeader(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 walkcan falsely conclude
Fis the common ancestor. Pruning toFkeeps blockF; the correct targetwas
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,
L2TipsCachesays refresh should happen after the writer transaction has committed, andArchiverDataStoreUpdaterdoes exactly that: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 snapshotv5 moves chain-tip construction into
BlockStore.getL2TipsData(genesisBlockHash)and wraps it in asingle
db.transactionAsync(...). It also validates the resulting tier ordering:That is materially stronger than v4's
L2TipsCache.loadFromStore(), which assembled tip numbers andblock data through several independent store calls.
3. The block stream fails closed on incoherent source reads
v5's
L2BlockStreamno longer uses the old checkpoint-prefetch path in the same way. It drives froma source tips snapshot, compares per-height hashes via
getBlockData({ number }), and adds guards: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_blockcan still commit a divergent archive tree if any future path presentsit with a self-consistent block whose non-archive state matches:
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_blockverify the archive tree at the source of truth: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
expectedPreviousArchiveRoot = l2Block.header.lastArchive.rootexpectedArchiveRoot = l2Block.archive.rootsync_blockchecks the previous root beforeadd_value.sync_blockchecks the resulting root beforecommit.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.