Skip to content

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

Merged
spalladino merged 5 commits into
merge-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 into
merge-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

@mverzilli mverzilli left a comment

Copy link
Copy Markdown
Contributor

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

AztecBot commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

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

AztecBot commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

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

AztecBot added a commit that referenced this pull request Apr 27, 2026
BEGIN_COMMIT_OVERRIDE
feat(aztec)!: add counter template for aztec init (#22751)
cherry-pick: fix(pxe): restrict setSenderForTags override to current
call (F-564) (#22672) (with conflicts)
fix: backport restrict setSenderForTags override to current call
(#22672) (#22767)
fix(pxe): backport anchor header threading from #22679 to v4-next
(#22705)
END_COMMIT_OVERRIDE
chrismarino pushed a commit to chrismarino/aztec-packages that referenced this pull request May 5, 2026
BEGIN_COMMIT_OVERRIDE
fix(kv-store): ensure LMDB cursor is closed on iteration abort (AztecProtocol#22509)
fix(telemetry-client): use appropriate histogram buckets for L1 gas
prices (AztecProtocol#22512)
fix(telemetry-client): log warning when BatchSpanProcessor drops spans
(AztecProtocol#22511)
fix(stdlib): wrap HA signer databaseUrl in SecretValue (AztecProtocol#22510)
fix(prover-client): don't mark in-progress epoch N jobs as stale when
epoch N+1 starts (AztecProtocol#22508)
chore: (A-730) graceful shutdown for services in node startup failure
path (AztecProtocol#22112)
fix(prover-client): reject stale job promises and count timeouts toward
retry limit (AztecProtocol#21842)
feat(archiver): validate historical L1 log availability at startup
(AztecProtocol#22644)
fix(archiver): do not query MessageSent events by blockhash (AztecProtocol#22641)
refactor(e2e): skip initial sequencer in p2p and epochs tests (AztecProtocol#22535)
fix: handle missing L1 finalized block on devnets (AztecProtocol#22663)
fix(world-state): treat historical block 0 queries as historical, not
latest (AztecProtocol#22679)
fix(sequencer): re-check parent checkpoint validity before pipelined L1
submission (AztecProtocol#22586)
fix(world-state): make block 0 a first-class historical block (AztecProtocol#22711)
chore: show all running versions (AztecProtocol#22376)
chore: fix prettier inside worktrees (AztecProtocol#22557)
feat: use optimized verifier for rollup (AztecProtocol#21840)
fix(kv-store): skip pool creation on ephemeral deleteDb to unstick
browser tests (AztecProtocol#22693)
chore: rm claude lockfile (AztecProtocol#22718)
fix(e2e): wait for first checkpoint in fee_asset_price_oracle_gossip
test (AztecProtocol#22719)
chore(prover-node): track estimated L1 fee when proof publishing is
disabled (AztecProtocol#22691)
fix(ci): rerun squashed PR check on base branch change (AztecProtocol#22713)
feat(archiver): decouple calldata from blob fetching in L1 synchronizer
(AztecProtocol#22716)
refactor(e2e): enable pipelining in e2e_epochs tests (AztecProtocol#22544)
feat(p2p): reject and evict txs with insufficient max fee per gas
(AztecProtocol#22118)
refactor(world-state): always index block 0 regardless of initial tree
size (AztecProtocol#22724)
fix(e2e): fix redistribution test (AztecProtocol#22729)
END_COMMIT_OVERRIDE
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