Skip to content

fix(world-state): treat historical block 0 queries as historical, not latest#22679

Merged
spalladino merged 5 commits intomerge-train/spartanfrom
palla/fix-world-state-block-zero-sentinel
Apr 21, 2026
Merged

fix(world-state): treat historical block 0 queries as historical, not latest#22679
spalladino merged 5 commits intomerge-train/spartanfrom
palla/fix-world-state-block-zero-sentinel

Conversation

@spalladino
Copy link
Copy Markdown
Contributor

Summary

  • The C++ world state overloaded WorldStateRevision.blockNumber == 0 as "use latest committed state" via if (revision.blockNumber) checks, rather than pinning to block 0. This silently returned the current tip instead of the genesis tree for any genesis-anchored query, once the node advanced past genesis.
  • AztecNodeService.getWorldState had a short-circuit that mapped initial-header queries directly to getSnapshot(BlockNumber.ZERO) and bypassed the archive-root double-check that would otherwise catch the mismatch.
  • Any PXE holding its anchor at the initial header (e.g., syncChainTip: 'checkpointed' before the first checkpoint commits) produced private-kernel proofs that failed with Proving public value inclusion failed: the public-data-tree witness came from the node's advanced tip while the circuit validated it against the initial header's root.

How PXE ends up querying block zero

  1. PXE seeds its anchor from the initial header. On first run, BlockSynchronizer.doSync (pxe/src/block_synchronizer/block_synchronizer.ts:178-181) sees no stored anchor and calls node.getBlockHeader(BlockNumber.ZERO). It stores the resulting header — whose hash is the initial-header hash and whose tree roots are the genesis roots.
  2. syncChainTip: 'checkpointed' keeps it pinned. handleBlockStreamEvent (block_synchronizer.ts:60-74) only advances the anchor on chain-checkpointed events; blocks-added is ignored. Until a checkpoint commits on L1 — which takes seconds or longer after sequencers start — the PXE's anchor stays at the initial header.
  3. proveTx hands that anchor to the kernel oracle. After the sync at the top of proveTx, the PXE reads the anchor and passes its hash into new PrivateKernelOracle(..., anchorBlockHash). Every oracle call (getPublicDataWitness, getPublicStorageAt, etc.) uses that hash.
  4. The kernel oracle hits the node with the initial-header hash. PrivateKernelOracle.getUpdatedClassIdHints (pxe/src/private_kernel/private_kernel_oracle.ts:121-150) issues node.getPublicDataWitness(initialHeaderHash, hashLeafSlot) plus a matching getPublicStorageAt read, both pinned to the same hash.
  5. The node short-circuits the initial header. AztecNodeService.getWorldState (aztec-node/src/aztec-node/server.ts:1714-1719, pre-fix) recognised the initial-header hash and returned worldStateSynchronizer.getSnapshot(BlockNumber.ZERO) directly, skipping the archive-tree reorg check below.
  6. getSnapshot(0) builds a revision with blockNumber = 0. NativeWorldState.getSnapshot (world-state/src/native/native_world_state.ts:157-163) constructed new WorldStateRevision(forkId=0, blockNumber=0, includeUncommitted=false) and handed it to the MerkleTreesFacade, which forwards it unchanged on every native call.
  7. Native C++ treated blockNumber == 0 as "latest". In barretenberg/cpp/src/barretenberg/world_state/world_state.cpp every tree op (get_meta_data, get_sibling_path, find_low_leaf, etc.) checked if (revision.blockNumber) / if (revision.blockNumber != 0U) — zero is falsy, so the code fell into the "no block pin, use latest committed" branch. The returned sibling path, low-leaf preimage, next-index and next-slot were all taken against the current tip.
  8. The circuit mismatched. Back in Noir (noir-protocol-circuits/crates/types/src/data/storage_read.nr:41 via delayed_public_mutable/with_hash.nr), the membership hash is compared against historical_header.state.partial.public_data_tree.root — the genesis root from the PXE's anchor. The oracle-supplied witness was computed against the advanced tip's root. Pre-sequencer the tip happens to equal genesis so it "works"; once block 1 lands they diverge and assert(is_leaf_in_tree, ...) fires.

Fix

  • Add an explicit WorldStateRevision::LATEST sentinel (std::numeric_limits<uint32_t>::max() in C++; mirrored in TS) and an is_historical() helper. Replace every if (revision.blockNumber) call site in world_state.cpp with if (revision.is_historical()). Zero now correctly means "pin to block 0".
  • Update TS WorldStateRevision.empty() and NativeWorldState.fork() to pass LATEST where the old "0 means latest" semantics were relied on. getSnapshot(blockNumber) passes the number through unchanged, so getSnapshot(0) now genuinely pins to block 0.
  • In AztecNodeService.getWorldState, replace the initial-header early return with a block-number resolution to BlockNumber.ZERO that falls through to the standard snapshot + archive-root double-check. The archive tree at index 0 stores the initial-header hash (per the assertion in native_world_state.ts:143), so the check works uniformly for block 0 too.
  • Defensively capture the anchor header once per proveTx / simulateTx / profileTx in pxe/src/pxe.ts and thread it through to both #executePrivate and #prove, rather than re-reading anchorBlockStore independently in each call. This cannot drift today (both live inside the same job-queue slot), but makes the invariant explicit and type-checked going forward.

Test plan

  • yarn build from yarn-project.
  • CI runs full suite, including world-state and e2e tests that exercise syncChainTip: 'checkpointed'.
  • Confirm e2e_epochs/epochs_mbps_redistribution second test stops emitting Proving public value inclusion failed errors once the full stack (including rebuilt bb) is deployed.

… latest

`WorldStateRevision.blockNumber == 0` was overloaded on the C++ side as a
sentinel meaning "use latest committed state" (via `if (revision.blockNumber)`
checks), rather than pinning to block 0. Combined with a short-circuit in
`AztecNodeService.getWorldState` that mapped initial-header queries directly
to `getSnapshot(BlockNumber.ZERO)` and bypassed the archive-root double-check,
this caused witnesses returned for genesis-anchored queries to silently use
the current tip once the node advanced past genesis.

PXEs with `syncChainTip: 'checkpointed'` stay pinned to the initial header
until the first checkpoint commits; any private-kernel proof built in that
window would fail with `Proving public value inclusion failed` because the
public-data-tree witness was taken against the advanced tip while the circuit
validated it against the initial header's root.

Fix:
- Add an explicit `WorldStateRevision::LATEST` sentinel (max uint32) on both
  the C++ and TS sides. Use `revision.is_historical()` at the C++ call sites
  so `blockNumber == 0` now means "pin to block 0". Adjust `getCommitted()`
  and `fork()` to pass `LATEST` where the previous "0 means latest" semantics
  were relied on.
- Route the initial-header case in `getWorldState` through the normal
  snapshot + archive-root double-check path rather than returning early, so
  any future tree-state mismatch at block 0 is caught.
- Defensively capture the anchor header once per `proveTx`/`simulateTx`/
  `profileTx` and pass it through to both `#executePrivate` and `#prove`,
  rather than re-reading `anchorBlockStore` independently in each. This
  cannot drift today (both sit inside the same job-queue slot), but makes
  the invariant explicit and type-checked going forward.
The header file has template methods (get_leaf, get_indexed_leaf,
find_leaf_sibling_paths, find_leaves_indexes) that were still using
the old if (rev.blockNumber) check. With the new LATEST sentinel,
that check always evaluates true and routed committed/latest reads
through the historical path with an invalid block number, causing
WorldStateTest.GetInitialTreeInfoForAllTrees to get nullopt from
get_leaf. Switch all remaining sites to is_historical().
@spalladino spalladino changed the base branch from next to merge-train/spartan April 21, 2026 01:04
Copy link
Copy Markdown
Contributor

@mverzilli mverzilli left a comment

Choose a reason for hiding this comment

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

LGTM on PXE side!

@mverzilli
Copy link
Copy Markdown
Contributor

@spalladino it would be good to port at least the PXE side of this change to v4-next

@spalladino
Copy link
Copy Markdown
Contributor Author

/claudebox create a PR to backport ONLY the pxe changes from this PR to branch v4-next

@AztecBot
Copy link
Copy Markdown
Collaborator

AztecBot commented Apr 21, 2026

Run #1 — Session completed (4m)
Live status

You've hit your limit · resets Apr 23, 8pm (UTC)

spalladino and others added 3 commits April 21, 2026 09:42
After introducing LATEST as the "not historical" sentinel, the internal
revisions still set `.blockNumber = 0` which is now interpreted as a
real historical block query and fails with "Unable to get meta data for
block 0". Remove the explicit `.blockNumber = 0` so the default LATEST
sentinel is used instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Include the block hash actually stored in world state and the genesis
header hash in the "Block hash not found in world state" error, to help
diagnose whether the mismatch is against the initial header specifically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…is hash

Block 0 has no historical snapshot in world state — the genesis header
state only lives in the committed/uncommitted view (via the tree's
initial values). A historical snapshot at block 0 fails with "Unable to
get leaf at block 0" in the native tree, which surfaces as
"Block hash <genesis> not found in world state at block number 0" when
the PXE queries with the genesis hash as anchor.

Since the anchor hash matching the known genesis hash means there is no
reorg risk, short-circuit and return the committed db directly. Updates
the existing unit test, which already reproduced the failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@spalladino spalladino enabled auto-merge (squash) April 21, 2026 16:46
@spalladino spalladino disabled auto-merge April 21, 2026 17:16
@spalladino spalladino merged commit c5ecda7 into merge-train/spartan Apr 21, 2026
12 checks passed
@spalladino spalladino deleted the palla/fix-world-state-block-zero-sentinel branch April 21, 2026 17:16
@spalladino
Copy link
Copy Markdown
Contributor Author

/claudebox Try again to create a PR to backport ONLY the pxe changes from this PR to branch v4-next

@AztecBot
Copy link
Copy Markdown
Collaborator

AztecBot commented Apr 21, 2026

Run #1 — Session completed (6m)
Live status

Backported PXE-only portion of #22679 to v4-next. Patch applied cleanly to pxe.ts (39+/12-, 1 file) — anchor header threading refactor, no other files included. Details: https://gist.github.com/AztecBot/74d69fdc7cd2c83761b4bb1e4aa24637

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.

4 participants