// AereStorageProofVerifierV2 — regression tests for CANONICAL RECORD POISONING. // // KNOWN AND ALREADY DOCUMENTED (not the bug): AereStorageProofVerifier 0xF9a1A183…9362E proves // a value against a GIVEN, prover-supplied state root and cannot itself show that root is // canonical. That is the honest, intended scope of a storage-proof primitive. // // THE FINDING (2026-07-17 sweep): V1 wrote EVERY submission — anchored or not — into ONE shared // mapping keyed only by (blockNumber, account, slot). So a record established through the // trust-minimized path (AereStateRootAnchor.proveAgainstAnchor, which forces the proof's root to // equal a root recovered from the BLOCKHASH opcode) can be SILENTLY OVERWRITTEN by anyone // calling the unbound submitProof() with a proof against a state root of their OWN making for // the SAME key. // // NOT THEORETICAL: the live V1 holds exactly such a record — AereTreasury 0x687933…6119 slot 0 // (owner) == Foundation 0x0243A4…f3C3 at block 8,915,939, against canonical stateRoot // 0x515fce04…4a92f3 (verified live 2026-07-17, == eth_getBlockByNumber(8915939).stateRoot). // // THE FIX: anchored and unbound records live in SEPARATE mappings. Anchored is keyed by // (blockNumber, account, slot) and writable ONLY by the immutable CANONICAL_ANCHOR; unbound is // keyed by (SUBMITTER, blockNumber, account, slot) so every submitter has their own namespace. // getProven() — the V1-shaped read — returns the ANCHORED record only: fail-closed. // // Each attack test is PAIRED with a control on the REAL V1 contract showing the poisoning // SUCCEEDS there. Both sides use the REAL AereStateRootAnchor logic (via MockBlockhashAnchor, // the existing test subclass that injects a canonical hash so a REAL AERE header outside the // live 256-block window can be anchored) and the REAL AERE genesis header — the anchor's // keccak-match + RLP state-root decode is genuine. // // The SP1 verifier is MOCKED (MockSp1Verifier: reverts unless the exact (vkey, publicValues, // proof) triple was pre-registered — mirroring the real gateway). That is the CORRECT model of // this attack: the attacker's storage proof is CRYPTOGRAPHICALLY VALID — they really did build // a trie in which the slot holds their value, and the guest honestly proved it. The guest cannot // know which root is canonical; that is exactly its documented scope. The bug was never a broken // proof — it was letting an honestly-proven-but-uncanonical record land on a canonical key. const { expect } = require("chai"); const { ethers } = require("hardhat"); const { encodeHeader } = require("./header-rlp"); const genesis = require("./fixtures/aere-genesis-header.json"); const VKEY = "0x" + "ab".repeat(32); const PROOF = "0x4388a21c99"; const ATTACK_PROOF = "0x4388a21cAA"; // distinct bytes: the attacker's own valid proof // The REAL live poisoning target (chain 2800, read 2026-07-17). const TREASURY = "0x687933AE7ea4927867AC227F1b60d476003e6119"; const FOUNDATION = "0x0243A4f47D44b40b65D33f20329dE20D00c6f3C3"; const SLOT0 = ethers.ZeroHash; const word = (addr) => ethers.zeroPadValue(ethers.getAddress(addr), 32); function marker(vkey, pv, proof) { return ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof])); } describe("AereStorageProofVerifierV2 — canonical record-poisoning fix (2026-07-17)", function () { let mock, v2, v2Anchor, v1, v1Anchor, deployer, attacker, chainId; let CANONICAL_ROOT, GENESIS_HEADER; const BN = 0n; // the real AERE genesis block // abi.encode(chainId, blockNumber, stateRoot, account, slot, value) -> 192 bytes function pv({ blockNumber = BN, stateRoot, account = TREASURY, slot = SLOT0, value, cid = chainId }) { return ethers.AbiCoder.defaultAbiCoder().encode( ["uint256", "uint256", "bytes32", "address", "bytes32", "bytes32"], [cid, blockNumber, stateRoot, account, slot, value] ); } async function accept(publicValues, proof = PROOF) { await mock.setValid(marker(VKEY, publicValues, proof), true); } beforeEach(async function () { [deployer, attacker] = await ethers.getSigners(); chainId = (await ethers.provider.getNetwork()).chainId; GENESIS_HEADER = encodeHeader(genesis); CANONICAL_ROOT = genesis.stateRoot; mock = await (await ethers.getContractFactory("MockSp1Verifier")).deploy(); await mock.waitForDeployment(); const mockAddr = await mock.getAddress(); // --- V2 + its anchor. The two are mutually referential, so the verifier is deployed with // the anchor's PREDICTED CREATE address and both directions are asserted afterwards. const nonce = await ethers.provider.getTransactionCount(deployer.address); const predictedAnchor = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 1 }); v2 = await (await ethers.getContractFactory("AereStorageProofVerifierV2")).deploy(mockAddr, VKEY, predictedAnchor); await v2.waitForDeployment(); v2Anchor = await (await ethers.getContractFactory("MockBlockhashAnchor")).deploy(await v2.getAddress()); await v2Anchor.waitForDeployment(); expect(await v2Anchor.getAddress()).to.equal(predictedAnchor); // prediction held // --- V1 + its anchor (the control). v1 = await (await ethers.getContractFactory("AereStorageProofVerifier")).deploy(mockAddr, VKEY); await v1.waitForDeployment(); v1Anchor = await (await ethers.getContractFactory("MockBlockhashAnchor")).deploy(await v1.getAddress()); await v1Anchor.waitForDeployment(); // Anchor the REAL AERE genesis header on both (its real canonical hash is injected because // genesis is far outside the live 256-block BLOCKHASH window; the keccak match + RLP decode // that follow are the genuine production logic). for (const a of [v2Anchor, v1Anchor]) { await a.setBlockHash(BN, genesis.hash); await a.anchorRecentHeader(BN, GENESIS_HEADER); } }); /* ------------------------- fixture / wiring sanity ------------------------------ */ describe("wiring + real-data sanity", function () { it("the REAL AERE genesis header reconstructs its canonical hash and state root", async function () { expect(ethers.keccak256(GENESIS_HEADER)).to.equal(genesis.hash); const a = await v2Anchor.getAnchoredRoot(BN); expect(a[0]).to.equal(true); expect(a[1]).to.equal(CANONICAL_ROOT); // decoded from the real header by the anchor expect(a[2]).to.equal(genesis.hash); }); it("the anchor <-> verifier binding is immutable and verified BOTH directions", async function () { expect(await v2.CANONICAL_ANCHOR()).to.equal(await v2Anchor.getAddress()); expect(await v2Anchor.VERIFIER()).to.equal(await v2.getAddress()); expect(await v2.SP1_VERIFIER()).to.equal(await mock.getAddress()); expect(await v2.PROGRAM_VKEY()).to.equal(VKEY); }); it("rejects a zero anchor / zero vkey / code-less verifier at construction", async function () { const F = await ethers.getContractFactory("AereStorageProofVerifierV2"); const m = await mock.getAddress(); await expect(F.deploy(m, VKEY, ethers.ZeroAddress)).to.be.revertedWithCustomError(F, "ZeroAddress"); await expect(F.deploy(ethers.ZeroAddress, VKEY, deployer.address)).to.be.revertedWithCustomError(F, "ZeroAddress"); await expect(F.deploy(m, ethers.ZeroHash, deployer.address)).to.be.revertedWithCustomError(F, "ZeroVKey"); await expect( F.deploy("0x000000000000000000000000000000000000dEaD", VKEY, deployer.address) ).to.be.revertedWithCustomError(F, "VerifierHasNoCode"); }); }); /* ================================================================================ THE ATTACK: an unbound proof overwrites a canonically-anchored record. ================================================================================ */ describe("ATTACK — unbound submitProof() poisons a canonically-anchored record", function () { // The honest, anchored fact: Treasury slot0 == Foundation, proven against the CANONICAL root. const honestPv = () => pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) }); // The attacker's fact: same key, but proven against a root of THEIR OWN making, in which the // Treasury owner is the attacker. The proof is genuinely valid for that root. const ATTACKER_ROOT = "0x" + "de".repeat(32); const attackPv = (who) => pv({ stateRoot: ATTACKER_ROOT, value: word(who) }); async function anchorHonest(anchor) { const p = honestPv(); await accept(p); await anchor.proveAgainstAnchor(p, PROOF); return p; } it("PAIRED CONTROL: on V1 the attacker OVERWRITES the anchored record", async function () { await anchorHonest(v1Anchor); // the honest, anchored truth is on the books let rec = await v1.getProven(BN, TREASURY, SLOT0); expect(rec[0]).to.equal(true); expect(rec[1]).to.equal(CANONICAL_ROOT); expect(rec[2]).to.equal(word(FOUNDATION)); // ...until the attacker submits an unbound proof for the SAME key. const ap = attackPv(attacker.address); await accept(ap, ATTACK_PROOF); await v1.connect(attacker).submitProof(ap, ATTACK_PROOF); rec = await v1.getProven(BN, TREASURY, SLOT0); expect(rec[0]).to.equal(true); // still "exists" — nothing looks wrong expect(rec[1]).to.equal(ATTACKER_ROOT); // canonical root REPLACED expect(rec[2]).to.equal(word(attacker.address)); // <-- POISONED: attacker is now "the owner" expect(rec[2]).to.not.equal(word(FOUNDATION)); }); it("V2: the anchored record SURVIVES the identical attack — poisoning is closed", async function () { await anchorHonest(v2Anchor); let rec = await v2.getProven(BN, TREASURY, SLOT0); expect(rec[2]).to.equal(word(FOUNDATION)); // the attacker's unbound submission SUCCEEDS (the API is not dropped)... const ap = attackPv(attacker.address); await accept(ap, ATTACK_PROOF); await expect(v2.connect(attacker).submitProof(ap, ATTACK_PROOF)).to.emit(v2, "SlotProvenUnbound"); // ...but it lands in the ATTACKER'S OWN namespace and touches nothing canonical. rec = await v2.getProven(BN, TREASURY, SLOT0); expect(rec[0]).to.equal(true); expect(rec[1]).to.equal(CANONICAL_ROOT); // untouched expect(rec[2]).to.equal(word(FOUNDATION)); // <-- STILL the honest, anchored truth expect(await v2.anchoredValue(BN, TREASURY, SLOT0)).to.equal(word(FOUNDATION)); expect(await v2.provenanceOf(BN, TREASURY, SLOT0)).to.equal(2); // Provenance.Anchored // the attacker's record is readable, clearly non-canonical, and namespaced under them const u = await v2.getUnboundProven(attacker.address, BN, TREASURY, SLOT0); expect(u[0]).to.equal(true); expect(u[1]).to.equal(ATTACKER_ROOT); expect(u[2]).to.equal(word(attacker.address)); }); it("V2: submitProofWithExpectedRoot from a NON-anchor caller is ALSO non-canonical", async function () { // The expected-root check is NOT self-securing: the caller picks the root, so it passes // trivially. Provenance must therefore come from WHO calls, not from WHICH function. await anchorHonest(v2Anchor); const ap = attackPv(attacker.address); await accept(ap, ATTACK_PROOF); await v2.connect(attacker).submitProofWithExpectedRoot(ap, ATTACK_PROOF, ATTACKER_ROOT); // passes! // ...and still cannot reach the canonical domain. const rec = await v2.getProven(BN, TREASURY, SLOT0); expect(rec[1]).to.equal(CANONICAL_ROOT); expect(rec[2]).to.equal(word(FOUNDATION)); const u = await v2.getUnboundProven(attacker.address, BN, TREASURY, SLOT0); expect(u[2]).to.equal(word(attacker.address)); }); it("PAIRED CONTROL: on V1 submitProofWithExpectedRoot poisons it just as well", async function () { await anchorHonest(v1Anchor); const ap = attackPv(attacker.address); await accept(ap, ATTACK_PROOF); await v1.connect(attacker).submitProofWithExpectedRoot(ap, ATTACK_PROOF, ATTACKER_ROOT); const rec = await v1.getProven(BN, TREASURY, SLOT0); expect(rec[2]).to.equal(word(attacker.address)); // POISONED }); it("V2: two different attackers cannot overwrite EACH OTHER either", async function () { const a1 = attackPv(attacker.address); const a2 = pv({ stateRoot: "0x" + "cc".repeat(32), value: word(deployer.address) }); await accept(a1, ATTACK_PROOF); await accept(a2, PROOF); await v2.connect(attacker).submitProof(a1, ATTACK_PROOF); await v2.connect(deployer).submitProof(a2, PROOF); const u1 = await v2.getUnboundProven(attacker.address, BN, TREASURY, SLOT0); const u2 = await v2.getUnboundProven(deployer.address, BN, TREASURY, SLOT0); expect(u1[2]).to.equal(word(attacker.address)); // each keeps their own expect(u2[2]).to.equal(word(deployer.address)); }); it("V2: an unbound record is INVISIBLE to the canonical read when nothing is anchored", async function () { // fail-closed: no anchored record => getProven reports absent, even though an unbound // record for that key exists. V1 would have reported the unbound record as if canonical. const ap = attackPv(attacker.address); await accept(ap, ATTACK_PROOF); await v2.connect(attacker).submitProof(ap, ATTACK_PROOF); const fresh = await v2.getProven(12345n, TREASURY, SLOT0); expect(fresh[0]).to.equal(false); const rec = await v2.getProven(BN, TREASURY, SLOT0); // BN was never anchored in this test expect(rec[0]).to.equal(false); // <-- absent, NOT the attacker's value expect(await v2.isAnchoredProven(BN, TREASURY, SLOT0)).to.equal(false); await expect(v2.anchoredValue(BN, TREASURY, SLOT0)).to.be.revertedWithCustomError(v2, "NotAnchored"); }); it("PAIRED CONTROL: on V1 that same unbound record READS AS CANONICAL", async function () { const ap = attackPv(attacker.address); await accept(ap, ATTACK_PROOF); await v1.connect(attacker).submitProof(ap, ATTACK_PROOF); const rec = await v1.getProven(BN, TREASURY, SLOT0); expect(rec[0]).to.equal(true); // indistinguishable from an anchored record expect(rec[2]).to.equal(word(attacker.address)); }); }); /* ---------------- V2 does not over-reject: the anchored path works --------------- */ describe("V2 does not over-reject — the anchored path still works end to end", function () { it("proveAgainstAnchor records a canonical, trust-minimized fact", async function () { const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) }); await accept(p); await expect(v2Anchor.proveAgainstAnchor(p, PROOF)).to.emit(v2, "SlotProvenAnchored"); const d = await v2.getAnchoredProven(BN, TREASURY, SLOT0); expect(d[0]).to.equal(true); expect(d[1]).to.equal(CANONICAL_ROOT); expect(d[2]).to.equal(word(FOUNDATION)); expect(d[5]).to.equal(await v2Anchor.getAddress()); // submitter == the anchor expect(await v2.provenanceOf(BN, TREASURY, SLOT0)).to.equal(2); // Anchored }); it("the anchor REFUSES a proof whose root is not the canonical one", async function () { const bad = pv({ stateRoot: "0x" + "de".repeat(32), value: word(FOUNDATION) }); await accept(bad); await expect(v2Anchor.proveAgainstAnchor(bad, PROOF)).to.be.revertedWithCustomError( v2Anchor, "RootNotCanonical" ); expect((await v2.getProven(BN, TREASURY, SLOT0))[0]).to.equal(false); }); it("the anchor REFUSES a proof for a block it has not anchored", async function () { const p = pv({ blockNumber: 999n, stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) }); await accept(p); await expect(v2Anchor.proveAgainstAnchor(p, PROOF)).to.be.revertedWithCustomError(v2Anchor, "NotAnchored"); }); it("re-anchoring the same canonical fact is idempotent", async function () { const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) }); await accept(p); await v2Anchor.proveAgainstAnchor(p, PROOF); await v2Anchor.proveAgainstAnchor(p, PROOF); // no conflict: same canonical root expect((await v2.getProven(BN, TREASURY, SLOT0))[2]).to.equal(word(FOUNDATION)); }); }); /* ---------------- proof + chain integrity still enforced ------------------------- */ describe("proof + chain integrity (unchanged from V1)", function () { it("an invalid proof reverts InvalidProof on every entry point", async function () { const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) }); await expect(v2.submitProof(p, PROOF)).to.be.revertedWithCustomError(v2, "InvalidProof"); await expect(v2.submitProofWithExpectedRoot(p, PROOF, CANONICAL_ROOT)).to.be.revertedWithCustomError( v2, "InvalidProof" ); await expect(v2.verify(p, PROOF)).to.be.revertedWithCustomError(v2, "InvalidProof"); }); it("a wrong chainId reverts WrongChain", async function () { const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION), cid: 1n }); await accept(p); await expect(v2.submitProof(p, PROOF)).to.be.revertedWithCustomError(v2, "WrongChain").withArgs(1n, chainId); }); it("a wrong-length publicValues reverts BadPublicValuesLength", async function () { await expect(v2.submitProof("0x1234", PROOF)).to.be.revertedWithCustomError(v2, "BadPublicValuesLength").withArgs(2); }); it("submitProofWithExpectedRoot still enforces the expected root", async function () { const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) }); await accept(p); await expect(v2.submitProofWithExpectedRoot(p, PROOF, "0x" + "11".repeat(32))) .to.be.revertedWithCustomError(v2, "StateRootMismatch") .withArgs(CANONICAL_ROOT, "0x" + "11".repeat(32)); }); it("verify() is unchanged and still returns the decoded tuple (the anchor depends on it)", async function () { const p = pv({ stateRoot: CANONICAL_ROOT, value: word(FOUNDATION) }); await accept(p); const out = await v2.verify(p, PROOF); expect(out[0]).to.equal(BN); expect(out[1]).to.equal(CANONICAL_ROOT); expect(out[2]).to.equal(TREASURY); expect(out[4]).to.equal(word(FOUNDATION)); }); }); });