Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions scripts/REPORT.md
Original file line number Diff line number Diff line change
@@ -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.
164 changes: 164 additions & 0 deletions scripts/repro_block_header.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading