// FINDING 4 (low): AereHistoryStateRootAnchor.proveAgainstAnchor ran the full // SP1 Groth16 verification TWICE on identical inputs (once via VERIFIER.verify // to read the block number, then again inside submitProofWithExpectedRoot). // // This suite: // 1. Reproduces the redundant verification against the ORIGINAL V1 contract // using a verifier whose verify() reverts. V1's proveAgainstAnchor still // calls verify(), so the call reverts, witnessing the redundant path. // 2. Proves the corrected V2 contract derives the block number locally and // calls ONLY submitProofWithExpectedRoot, so exactly ONE verification runs // (submitCalls == 1) while behaviour is unchanged: a valid proof records and // tampered / invalid inputs revert. // // The anchored root is REAL: the AERE genesis header (chain 2800) is // reconstructed and its real state root is anchored, then proven against. const { expect } = require("chai"); const { ethers } = require("hardhat"); const { encodeHeader } = require("./header-rlp"); const genesis = require("./fixtures/aere-genesis-header.json"); const ZERO32 = "0x" + "00".repeat(32); const BLOCK = 5000n; // any block number; the mock injects its canonical hash const BLOCK2 = 6000n; // a SECOND anchored block with a DIFFERENT state root // abi.encode(chainId, blockNumber, stateRoot, account, slot, value) -> 192 bytes function publicValues({ chainId = 2800n, blockNumber, stateRoot, account, slot, value }) { return ethers.AbiCoder.defaultAbiCoder().encode( ["uint256", "uint256", "bytes32", "address", "bytes32", "bytes32"], [chainId, blockNumber, stateRoot, account, slot, value] ); } describe("AereHistoryStateRootAnchor FINDING 4 (double SP1 verify)", function () { const account = ethers.getAddress("0x00000000000000000000000000000000000000a1"); const slot = "0x" + "00".repeat(31) + "05"; const value = "0x" + "00".repeat(31) + "2a"; let verifier; async function deployVerifier() { const V = await ethers.getContractFactory("CountingStorageProofVerifier"); const v = await V.deploy(); await v.waitForDeployment(); return v; } async function deployAnchor(factoryName, verifierAddr) { const F = await ethers.getContractFactory(factoryName); const a = await F.deploy(verifierAddr); await a.waitForDeployment(); return a; } // Anchor the REAL genesis state root at BLOCK on the given anchor. async function anchorGenesis(anchor) { const header = encodeHeader(genesis); await anchor.setHistoryBlockHash(BLOCK, genesis.hash); await anchor.anchorHistoricalHeader(BLOCK, header); expect(await anchor.anchoredStateRoot(BLOCK)).to.equal(genesis.stateRoot); } // Anchor a SECOND, distinct canonical block at BLOCK2 whose state root differs // from genesis. We reuse the real genesis header but overwrite only its // stateRoot field, so the header re-encodes to a NEW keccak (a new canonical // hash the mock injects) and anchors a different-but-well-formed root. This // gives us two anchored blocks with different roots to test cross-block // confusion. const OTHER_ROOT = "0x1111111111111111111111111111111111111111111111111111111111111111"; async function anchorSecondBlock(anchor) { const header2 = encodeHeader({ ...genesis, stateRoot: OTHER_ROOT }); const hash2 = ethers.keccak256(header2); await anchor.setHistoryBlockHash(BLOCK2, hash2); await anchor.anchorHistoricalHeader(BLOCK2, header2); expect(await anchor.anchoredStateRoot(BLOCK2)).to.equal(OTHER_ROOT); } beforeEach(async function () { verifier = await deployVerifier(); }); describe("reproduction: V1 makes the redundant verify() call", function () { it("proveAgainstAnchor reverts because V1 still calls verify() (double verification)", async function () { const anchor = await deployAnchor("MockHistoryAnchorV1", await verifier.getAddress()); await anchorGenesis(anchor); const pv = publicValues({ blockNumber: BLOCK, stateRoot: genesis.stateRoot, account, slot, value, }); // The counting verifier rejects any verify() call. V1 calls verify() // first, so the whole tx reverts: this is the redundant verification the // finding describes. On a real verifier the call would instead succeed and // burn a full extra Groth16 pairing. await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWithCustomError( verifier, "RedundantVerifyCall" ); // No verification ever reached the recording path. expect(await verifier.submitCalls()).to.equal(0n); }); }); describe("fix: V2 verifies exactly once", function () { let anchor; beforeEach(async function () { anchor = await deployAnchor("MockHistoryAnchorV2", await verifier.getAddress()); await anchorGenesis(anchor); }); it("records a valid proof running submitProofWithExpectedRoot ONCE and never verify()", async function () { const pv = publicValues({ blockNumber: BLOCK, stateRoot: genesis.stateRoot, account, slot, value, }); await expect(anchor.proveAgainstAnchor(pv, "0x")) .to.emit(anchor, "ProvenAgainstAnchor") .withArgs(BLOCK, genesis.stateRoot, account, slot, value); // Exactly one verification, via the recording path only. expect(await verifier.submitCalls()).to.equal(1n); expect(await verifier.recorded(ethers.keccak256(pv))).to.equal(true); }); it("reverts when the committed root is tampered to differ from the anchored root", async function () { const pv = publicValues({ blockNumber: BLOCK, stateRoot: ZERO32, // not the anchored genesis root account, slot, value, }); // submit binds committed root to anchored root; mismatch reverts. await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWith("StateRootMismatch"); expect(await verifier.submitCalls()).to.equal(0n); }); it("reverts NotAnchored when the proof's block number was never anchored", async function () { const pv = publicValues({ blockNumber: 999999n, stateRoot: genesis.stateRoot, account, slot, value, }); await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWithCustomError( anchor, "NotAnchored" ); // Reverts before any verification. expect(await verifier.submitCalls()).to.equal(0n); }); it("reverts BadPublicValuesLength for a non-192-byte payload", async function () { const short = "0x" + "11".repeat(160); // 160 bytes, one word short await expect(anchor.proveAgainstAnchor(short, "0x")).to.be.revertedWithCustomError( anchor, "BadPublicValuesLength" ); }); it("reverts when the underlying SP1 proof is invalid (verification still gates recording)", async function () { await verifier.setValidate(false); const pv = publicValues({ blockNumber: BLOCK, stateRoot: genesis.stateRoot, account, slot, value, }); await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.reverted; expect(await verifier.submitCalls()).to.equal(0n); }); it("decodes block number, account, and slot locally from the 192-byte layout", async function () { const account2 = ethers.getAddress("0x00000000000000000000000000000000000000b2"); const slot2 = "0x" + "00".repeat(31) + "09"; const value2 = "0x" + "00".repeat(30) + "0100"; const pv = publicValues({ blockNumber: BLOCK, stateRoot: genesis.stateRoot, account: account2, slot: slot2, value: value2, }); await expect(anchor.proveAgainstAnchor(pv, "0x")) .to.emit(anchor, "ProvenAgainstAnchor") .withArgs(BLOCK, genesis.stateRoot, account2, slot2, value2); expect(await verifier.submitCalls()).to.equal(1n); }); }); // RESIDUAL ATTACK VARIANT (cross-block root confusion). // // The V2 fix selects the anchored root using the blockNumber it decodes // LOCALLY from public-values word 1, and no longer cross-checks any block // number returned by VERIFIER.verify (that call is gone). So the sharpest // residual question is: can an attacker take a proof that is genuinely valid // for anchored block A and redirect it against a DIFFERENT anchored block B // (word 1 = B) to get an off-root value recorded? It must NOT: the single // submitProofWithExpectedRoot binds the proof's committed root (word 2) to // anchored[B]'s root, so the tampered block number reverts, and the exactly- // once property must hold (submitCalls stays 0 on the failed attack, and a // later honest proof still records exactly once with no verify() call). describe("residual attack variant: cross-block root confusion", function () { it("V1 (reproduction) still runs the redundant verify() on the cross-block attempt", async function () { const anchor = await deployAnchor("MockHistoryAnchorV1", await verifier.getAddress()); await anchorGenesis(anchor); await anchorSecondBlock(anchor); // Proof genuinely valid for BLOCK (root = genesis root) but word 1 tampered // to BLOCK2 (anchored to OTHER_ROOT). const pv = publicValues({ blockNumber: BLOCK2, stateRoot: genesis.stateRoot, account, slot, value, }); // V1 calls verify() FIRST, so it reverts on the redundant path rather than // through the single submit binding. This witnesses the double-verify the // finding removes. await expect(anchor.proveAgainstAnchor(pv, "0x")).to.be.revertedWithCustomError( verifier, "RedundantVerifyCall" ); expect(await verifier.submitCalls()).to.equal(0n); }); it("V2 (closed) rejects the cross-block attempt through the single submit binding, no verify(), no record", async function () { const anchor = await deployAnchor("MockHistoryAnchorV2", await verifier.getAddress()); await anchorGenesis(anchor); await anchorSecondBlock(anchor); const attackPv = publicValues({ blockNumber: BLOCK2, // redirect a genesis-root proof at the OTHER_ROOT block stateRoot: genesis.stateRoot, account, slot, value, }); // Anchored[BLOCK2] = OTHER_ROOT, committed root = genesis root: submit binds // them and reverts. No fallback to verify(), and nothing recorded. await expect(anchor.proveAgainstAnchor(attackPv, "0x")).to.be.revertedWith( "StateRootMismatch" ); expect(await verifier.submitCalls()).to.equal(0n); expect(await verifier.recorded(ethers.keccak256(attackPv))).to.equal(false); // The failed attack corrupts nothing: an honest proof for BLOCK2's real // root still records, and does so with EXACTLY ONE verification. const honestPv = publicValues({ blockNumber: BLOCK2, stateRoot: OTHER_ROOT, account, slot, value, }); await expect(anchor.proveAgainstAnchor(honestPv, "0x")) .to.emit(anchor, "ProvenAgainstAnchor") .withArgs(BLOCK2, OTHER_ROOT, account, slot, value); expect(await verifier.submitCalls()).to.equal(1n); expect(await verifier.recorded(ethers.keccak256(honestPv))).to.equal(true); }); }); });