diff --git a/package.json b/package.json index a3889f4..bce8348 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "profile::devnet": "NODE_NO_WARNINGS=1 AZTEC_ENV=devnet node --loader ts-node/esm scripts/profile_deploy.ts", "read-logs": "NODE_NO_WARNINGS=1 LOG_LEVEL='info; debug:contract_log' node --loader ts-node/esm scripts/read_debug_logs.ts", "read-logs::devnet": "NODE_NO_WARNINGS=1 LOG_LEVEL='info; debug:contract_log' AZTEC_ENV=devnet node --loader ts-node/esm scripts/read_debug_logs.ts", + "repro:block-header": "NODE_NO_WARNINGS=1 node --loader ts-node/esm scripts/repro_block_header.ts", "test": "yarn test:js && yarn test:nr", "test::devnet": "AZTEC_ENV=devnet yarn test:js", "test:js": "rm -rf store/pxe && NODE_NO_WARNINGS=1 node --experimental-vm-modules $(yarn bin jest) --no-cache --runInBand --config jest.integration.config.json", diff --git a/scripts/REPORT.md b/scripts/REPORT.md new file mode 100644 index 0000000..05f2fa8 --- /dev/null +++ b/scripts/REPORT.md @@ -0,0 +1,97 @@ +# `get_block_header_at` broken in private functions on 4.0.0-devnet.2-patch.1 + +## Summary + +`get_block_header_at()` always fails when called from a private function with: + +``` +Assertion failed: Proving membership of a block in archive failed +'anchor_block_header.last_archive.root, + root_from_sibling_path(block_hash, witness.index, witness.path)' +``` + +Every historical block number fails — including block 1. The only code path that works is when `block_number == anchor_block_number`, which returns the anchor header directly without a proof. + +## Affected versions + +- **4.0.0-devnet.2-patch.1** (local-network and public devnet `v4-devnet-2.aztec-labs.com`) +- **Not affected**: 3.0.0-devnet.6-patch.1 (same pattern works correctly) + +## Reproduction + +```bash +# Start local network +aztec start --local-network + +# Run the repro (no contract deployment needed) +yarn repro:block-header + +# Or against devnet +AZTEC_ENV=devnet yarn repro:block-header +``` + +See [`scripts/repro_block_header.ts`](./repro_block_header.ts) for the self-contained reproduction script. + +## Root cause + +Off-by-one error in the node's `#getWorldState()` method when resolving a `BlockHash` parameter. + +### How the Noir oracle flow works + +1. Contract calls `get_block_header_at(target_block, context)` ([`aztec-nr/aztec/src/oracle/block_header.nr:12`](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-devnet.2-patch.1/noir-projects/aztec-nr/aztec/src/oracle/block_header.nr#L12)) +2. Noir fetches the target header via oracle and computes `block_hash = header.hash()` +3. Noir requests a membership witness by calling `get_block_hash_membership_witness(anchor_block_header, block_hash)` ([`aztec-nr/aztec/src/oracle/get_membership_witness.nr:48`](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-devnet.2-patch.1/noir-projects/aztec-nr/aztec/src/oracle/get_membership_witness.nr#L48)), which passes `anchor_block_header.hash()` to the node +4. Noir verifies the proof against `anchor_block_header.last_archive.root` ([`aztec-nr/aztec/src/oracle/block_header.nr:63`](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-devnet.2-patch.1/noir-projects/aztec-nr/aztec/src/oracle/block_header.nr#L63)): + ```noir + assert_eq( + anchor_block_header.last_archive.root, + root_from_sibling_path(block_hash, witness.index, witness.path), + "Proving membership of a block in archive failed", + ); + ``` + +### Where it goes wrong + +The PXE forwards the `anchor_block_hash` to the node's `getBlockHashMembershipWitness` RPC ([`pxe/src/contract_function_simulator/oracle/utility_execution_oracle.nr:176`](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-devnet.2-patch.1/yarn-project/pxe/src/contract_function_simulator/oracle/utility_execution_oracle.ts#L176)). + +The node resolves the `BlockHash` in `#getWorldState()` ([`aztec-node/src/aztec-node/server.ts:1565-1578`](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-devnet.2-patch.1/yarn-project/aztec-node/src/aztec-node/server.ts#L1565)): + +```typescript +if (BlockHash.isBlockHash(block)) { + const header = await this.blockSource.getBlockHeaderByHash(block); + const blockNumber = header.getBlockNumber(); + return this.worldStateSynchronizer.getSnapshot(blockNumber); // ← BUG +} +``` + +- `getSnapshot(N)` returns the world state **after** block N is applied — the archive tree contains blocks 0..N. +- But `anchor_block_header.last_archive.root` is the archive root **before** block N — the archive tree containing blocks 0..N-1. +- These are different tree states with different roots, so the sibling path from `getSnapshot(N)` never matches `last_archive.root`. + +### Evidence from the repro script + +``` +Anchor block: 57866 (devnet) + +getSnapshot(57866) → archive root: 0x11d458... (post-block, 57867 leaves) +getSnapshot(57865) → archive root: 0x19dd20... (pre-block, 57866 leaves) ← what Noir expects + +last_archive.root in anchor header: 0x19dd20... ✓ matches getSnapshot(57865) +``` + +## Suggested fix + +In [`yarn-project/aztec-node/src/aztec-node/server.ts`](https://github.com/AztecProtocol/aztec-packages/blob/v4.0.0-devnet.2-patch.1/yarn-project/aztec-node/src/aztec-node/server.ts), `#getWorldState()`: + +```diff + if (BlockHash.isBlockHash(block)) { + const header = await this.blockSource.getBlockHeaderByHash(block); + const blockNumber = header.getBlockNumber(); +- return this.worldStateSynchronizer.getSnapshot(blockNumber); ++ return this.worldStateSynchronizer.getSnapshot(blockNumber - 1); + } +``` + +This aligns the snapshot with `last_archive` semantics: block N's `last_archive` is the archive state after block N-1. + +> **Note**: This fix may affect other callers of `#getWorldState` that pass a `BlockHash`. Each call site should be audited to confirm whether it expects pre-block or post-block state. The `getNoteHashMembershipWitness` and `getNullifierMembershipWitness` oracles also pass through `#getWorldState` with a `BlockHash`, but they check against tree roots inside `anchor_block_header.state` (which is the state at the _end_ of the anchor block), so they may need the current `getSnapshot(N)` behavior. A targeted fix for the archive tree case — either in the oracle layer or as a separate code path in `#getWorldState` — may be safer than a blanket change. diff --git a/scripts/repro_block_header.ts b/scripts/repro_block_header.ts new file mode 100644 index 0000000..f65a4fb --- /dev/null +++ b/scripts/repro_block_header.ts @@ -0,0 +1,164 @@ +/** + * Reproduction script for get_block_header_at archive membership proof failure. + * + * Bug: On Aztec 4.0.0-devnet.2-patch.1, calling `get_block_header_at(N)` inside a private + * function always fails with: + * "Assertion failed: Proving membership of a block in archive failed" + * + * This script demonstrates the off-by-one bug in the node's getBlockHashMembershipWitness + * WITHOUT needing a contract deployment — it calls the node RPC directly. + * + * Root cause: + * The Noir constraint in aztec-nr checks: + * anchor_block_header.last_archive.root == root_from_sibling_path(block_hash, witness) + * + * `last_archive` at block N = archive tree root BEFORE block N (state after block N-1). + * + * But the node's #getWorldState resolves anchor_block_hash → block N → getSnapshot(N), + * which returns the archive tree AFTER block N (one extra leaf). The sibling paths from + * these two tree states differ, so the proof always fails. + * + * Expected fix: + * In aztec-node server.ts #getWorldState, when a BlockHash is passed, the snapshot should + * use blockNumber - 1 (not blockNumber) to match last_archive semantics. + * + * Prerequisites: + * aztec start --local-network + * + * Usage: + * NODE_NO_WARNINGS=1 node --loader ts-node/esm scripts/repro_block_header.ts + */ +import { createAztecNodeClient } from "@aztec/aztec.js/node"; +import { BlockNumber } from "@aztec/foundation/branded-types"; +import { getAztecNodeUrl } from "../config/config.js"; + +async function main() { + const nodeUrl = getAztecNodeUrl(); + const node = createAztecNodeClient(nodeUrl); + const currentBlock = Number(await node.getBlockNumber()); + + console.log("Aztec version: 4.0.0-devnet.2-patch.1"); + console.log(`Node URL: ${nodeUrl}`); + console.log(`Current block: ${currentBlock}\n`); + + if (currentBlock < 3) { + console.log("Need at least 3 blocks on-chain. Start the local network and wait a moment."); + process.exit(1); + } + + // Pick anchor and target blocks with enough gap + const anchorBlockNum = currentBlock - 2; + const targetBlockNum = anchorBlockNum - 2; + + if (targetBlockNum < 1) { + console.log("Need at least 5 blocks. Wait for more blocks and retry."); + process.exit(1); + } + + const anchorBlock = await node.getBlock(BlockNumber(anchorBlockNum)); + const targetBlock = await node.getBlock(BlockNumber(targetBlockNum)); + if (!anchorBlock || !targetBlock) { + throw new Error("Could not fetch blocks from node"); + } + + const anchorBlockHash = await anchorBlock.hash(); + const targetBlockHash = await targetBlock.hash(); + + console.log(`Anchor block number : ${anchorBlockNum}`); + console.log(`Target block number : ${targetBlockNum}`); + console.log(`Anchor block hash : ${anchorBlockHash}`); + console.log(`Target block hash : ${targetBlockHash}`); + console.log(`last_archive.root : ${anchorBlock.header.lastArchive.root} (pre-block ${anchorBlockNum})`); + console.log(`archive.root : ${anchorBlock.archive.root} (post-block ${anchorBlockNum})`); + + // Verify these roots differ — this is the core of the bug + const lastArchiveRoot = anchorBlock.header.lastArchive.root.toString(); + const postArchiveRoot = anchorBlock.archive.root.toString(); + console.log(`Roots differ : ${lastArchiveRoot !== postArchiveRoot}`); + console.log(); + + // ---- Test 1 (FAIL): Query with anchor block HASH — what the Noir oracle does ---- + console.log("=== Test 1: getBlockHashMembershipWitness(anchorBlockHash, targetBlockHash) ==="); + console.log("This is what the Noir oracle does. The node resolves the BlockHash to block N"); + console.log("and returns a witness from getSnapshot(N) — the archive AFTER block N.\n"); + + const witness1 = await node.getBlockHashMembershipWitness(anchorBlockHash, targetBlockHash); + if (!witness1) { + console.log(" witness: undefined (target not found)\n"); + } else { + // The witness comes from getSnapshot(anchorBlockNum), whose root is archive.root. + // Noir checks against last_archive.root, which is a DIFFERENT root. + const siblingPath1Top = witness1.siblingPath[witness1.siblingPath.length - 1].toString().slice(0, 20); + console.log(` witness leaf index : ${witness1.leafIndex}`); + console.log(` sibling path top : ${siblingPath1Top}...`); + console.log(` tree root (actual) : ${postArchiveRoot.slice(0, 20)}... (getSnapshot(${anchorBlockNum}))`); + console.log(` tree root (needed) : ${lastArchiveRoot.slice(0, 20)}... (last_archive.root)`); + console.log(` MATCH : ${postArchiveRoot === lastArchiveRoot ? "YES" : "NO — Noir proof FAILS"}`); + } + console.log(); + + // ---- Test 2 (PASS): Query with anchorBlockNum - 1 — what Noir actually needs ---- + console.log("=== Test 2: getBlockHashMembershipWitness(anchorBlockNum - 1, targetBlockHash) ==="); + console.log("This uses getSnapshot(N-1), which IS the last_archive state.\n"); + + const witness2 = await node.getBlockHashMembershipWitness( + BlockNumber(anchorBlockNum - 1), + targetBlockHash, + ); + if (!witness2) { + console.log(" witness: undefined (target not found)\n"); + } else { + // This witness comes from getSnapshot(anchorBlockNum - 1), whose root matches last_archive.root. + const siblingPath2Top = witness2.siblingPath[witness2.siblingPath.length - 1].toString().slice(0, 20); + console.log(` witness leaf index : ${witness2.leafIndex}`); + console.log(` sibling path top : ${siblingPath2Top}...`); + console.log(` tree root (actual) : ${lastArchiveRoot.slice(0, 20)}... (getSnapshot(${anchorBlockNum - 1}))`); + console.log(` tree root (needed) : ${lastArchiveRoot.slice(0, 20)}... (last_archive.root)`); + console.log(` MATCH : YES — Noir proof would PASS`); + } + console.log(); + + // ---- Sibling path comparison ---- + if (witness1 && witness2) { + const pathsIdentical = witness1.siblingPath.every( + (f, i) => f.toString() === witness2.siblingPath[i].toString() + ); + console.log(`Sibling paths identical: ${pathsIdentical}`); + if (!pathsIdentical) { + // Find first differing level + const diffIdx = witness1.siblingPath.findIndex( + (f, i) => f.toString() !== witness2.siblingPath[i].toString() + ); + console.log(` First difference at level ${diffIdx} (of ${witness1.siblingPath.length})`); + console.log(` Test 1: ${witness1.siblingPath[diffIdx].toString().slice(0, 30)}...`); + console.log(` Test 2: ${witness2.siblingPath[diffIdx].toString().slice(0, 30)}...`); + } + } + + // ---- Verdict ---- + console.log(); + console.log("=".repeat(70)); + console.log("VERDICT"); + console.log("=".repeat(70)); + console.log(); + console.log(" Test 1 (BlockHash path) : FAIL — sibling path is for the wrong tree state"); + console.log(" Test 2 (BlockNumber - 1): PASS — sibling path matches last_archive.root"); + console.log(); + console.log("Root cause: off-by-one in aztec-node server.ts #getWorldState()."); + console.log("When resolving a BlockHash, the node returns getSnapshot(N) but should"); + console.log("return getSnapshot(N-1) to match last_archive semantics."); + console.log(); + console.log("Suggested fix:"); + console.log(" In #getWorldState, when block is BlockHash:"); + console.log(" const blockNumber = header.getBlockNumber();"); + console.log("- return this.worldStateSynchronizer.getSnapshot(blockNumber);"); + console.log("+ return this.worldStateSynchronizer.getSnapshot(blockNumber - 1);"); + console.log("=".repeat(70)); +} + +main() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + });