const { expect } = require("chai"); const { ethers } = require("hardhat"); // FINDING 3 (medium): EVM-validity canonical binding. // // V1 (AereEVMValidity / AereEVMValidityBatch) records an SP1 postStateRoot as a // block WITHOUT binding it to the real chain: no blockHash/parentHash in the // public values, no blockhash() check, no inter-record continuity. A genuine SP1 // proof of a FABRICATED execution can therefore be recorded as "canonical", and // first-write-wins dedup lets an attacker squat slot N. // // This suite (1) reproduces the attack on V1, then (2) proves V2 closes it: // - a fabricated proof with a wrong/absent committed blockHash is REJECTED, // - a real in-window proof with the correct committed blockHash is accepted, // - batch V2 enforces inter-record continuity. // // The SP1 verifier is mocked (MockSp1Verifier: verifyProof reverts unless the // exact (vkey, publicValues, proof) triple was pre-marked valid), and blockhash // binding is exercised against real hardhat block hashes. const coder = ethers.AbiCoder.defaultAbiCoder(); const VKEY = "0x" + "ab".repeat(32); const PROOF = "0x4388a21c" + "00".repeat(64); // dummy selector-prefixed bytes const ZERO32 = "0x" + "00".repeat(32); function marker(vkey, pv, proof) { return ethers.keccak256( coder.encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof]) ); } // V1 single-block public values: 7 words. function encodeV1(o) { return coder.encode( ["uint256", "uint256", "bytes32", "bytes32", "bytes32", "uint256", "uint256"], [o.chainId, o.blockNumber, o.prevStateRoot, o.postStateRoot, o.txRoot, o.gasUsed, o.numTxns] ); } // V2 single-block public values: 9 words (adds parentHash + blockHash). function encodeV2(o) { return coder.encode( ["uint256", "uint256", "bytes32", "bytes32", "bytes32", "bytes32", "bytes32", "uint256", "uint256"], [o.chainId, o.blockNumber, o.parentHash, o.blockHash, o.prevStateRoot, o.postStateRoot, o.txRoot, o.gasUsed, o.numTxns] ); } // V2 batch public values: 10 words. function encodeBatchV2(o) { return coder.encode( ["uint256", "uint256", "uint256", "uint256", "bytes32", "bytes32", "bytes32", "bytes32", "uint256", "uint256"], [o.chainId, o.firstBlock, o.lastBlock, o.numBlocks, o.firstParentHash, o.lastBlockHash, o.prevStateRoot, o.postStateRoot, o.totalGas, o.numTxns] ); } const FAKE_POST = "0x" + "fa".repeat(32); const PREV_ROOT = "0x" + "11".repeat(32); const TX_ROOT = "0x" + "22".repeat(32); describe("FINDING 3 - EVM-validity canonical binding", () => { let mock, chainId; beforeEach(async () => { const M = await ethers.getContractFactory("MockSp1Verifier"); mock = await M.deploy(); await mock.waitForDeployment(); chainId = (await ethers.provider.getNetwork()).chainId; // 31337 on hardhat }); // ------------------------------------------------------------------ V1 hole describe("V1 attack surface (documents the vulnerability)", () => { let v1; beforeEach(async () => { const F = await ethers.getContractFactory("AereEVMValidity"); v1 = await F.deploy(await mock.getAddress(), VKEY); await v1.waitForDeployment(); }); it("records a FABRICATED execution as canonical (no blockhash binding)", async () => { // A genuine-for-vkey proof whose committed postStateRoot is fabricated and // whose blockNumber never existed as claimed. V1 has no way to tell. const pv = encodeV1({ chainId, blockNumber: 999_000, prevStateRoot: PREV_ROOT, postStateRoot: FAKE_POST, txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1, }); await mock.setValid(marker(VKEY, pv, PROOF), true); await v1.recordBlock(pv, PROOF); // ATTACK SUCCEEDS: no revert expect(await v1.provenCount()).to.equal(1n); const rec = await v1.getRecord(0); expect(rec[2]).to.equal(FAKE_POST); // fabricated root anchored as canonical }); it("lets an attacker SQUAT slot N via first-write-wins", async () => { const pvFake = encodeV1({ chainId, blockNumber: 999_000, prevStateRoot: PREV_ROOT, postStateRoot: FAKE_POST, txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1, }); await mock.setValid(marker(VKEY, pvFake, PROOF), true); await v1.recordBlock(pvFake, PROOF); // A later, DIFFERENT (even honest) proof for the same height is locked out. const pvReal = encodeV1({ chainId, blockNumber: 999_000, prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "cc".repeat(32), txRoot: TX_ROOT, gasUsed: 42000, numTxns: 2, }); await mock.setValid(marker(VKEY, pvReal, PROOF), true); await expect(v1.recordBlock(pvReal, PROOF)).to.be.revertedWithCustomError( v1, "AlreadyProven" ); }); }); // ------------------------------------------------------------------ V2 fix describe("V2 single-block canonical binding", () => { let v2; beforeEach(async () => { const F = await ethers.getContractFactory("AereEVMValidityV2"); v2 = await F.deploy(await mock.getAddress(), VKEY); await v2.waitForDeployment(); }); it("REJECTS a fabricated in-window block whose committed blockHash is wrong", async () => { const bn = await ethers.provider.getBlockNumber(); const parent = await ethers.provider.getBlock(bn - 1); const pv = encodeV2({ chainId, blockNumber: bn, parentHash: parent.hash, blockHash: "0x" + "de".repeat(32), // WRONG: not the real hash of block bn prevStateRoot: PREV_ROOT, postStateRoot: FAKE_POST, txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1, }); await mock.setValid(marker(VKEY, pv, PROOF), true); await expect(v2.recordBlock(pv, PROOF)).to.be.revertedWithCustomError( v2, "BlockHashMismatch" ); }); it("REJECTS a block whose hash is unavailable (out of window / future)", async () => { const bn = await ethers.provider.getBlockNumber(); const pv = encodeV2({ chainId, blockNumber: bn + 5000, // future: blockhash() == 0 parentHash: ZERO32, blockHash: "0x" + "de".repeat(32), prevStateRoot: PREV_ROOT, postStateRoot: FAKE_POST, txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1, }); await mock.setValid(marker(VKEY, pv, PROOF), true); await expect(v2.recordBlock(pv, PROOF)).to.be.revertedWithCustomError( v2, "BlockHashUnavailable" ); }); it("ACCEPTS a real in-window proof with the correct committed blockHash", async () => { const bn = await ethers.provider.getBlockNumber(); const blk = await ethers.provider.getBlock(bn); const parent = await ethers.provider.getBlock(bn - 1); const pv = encodeV2({ chainId, blockNumber: bn, parentHash: parent.hash, blockHash: blk.hash, prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "77".repeat(32), txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1, }); await mock.setValid(marker(VKEY, pv, PROOF), true); // mines a block await v2.recordBlock(pv, PROOF); // bn still in the 256 window here expect(await v2.provenCount()).to.equal(1n); const rec = await v2.getRecord(0); expect(rec[0]).to.equal(BigInt(bn)); expect(rec[2]).to.equal(blk.hash); // blockHash stored (getRecord field #3) }); it("REJECTS a wrong committed parentHash even when blockHash is right", async () => { const bn = await ethers.provider.getBlockNumber(); const blk = await ethers.provider.getBlock(bn); const pv = encodeV2({ chainId, blockNumber: bn, parentHash: "0x" + "ee".repeat(32), // WRONG parent blockHash: blk.hash, prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "77".repeat(32), txRoot: TX_ROOT, gasUsed: 21000, numTxns: 1, }); await mock.setValid(marker(VKEY, pv, PROOF), true); await expect(v2.recordBlock(pv, PROOF)).to.be.revertedWithCustomError( v2, "ParentHashMismatch" ); }); }); describe("V2 batch continuity + tip binding", () => { let b2; beforeEach(async () => { const F = await ethers.getContractFactory("AereEVMValidityBatchV2"); b2 = await F.deploy(await mock.getAddress(), VKEY); await b2.waitForDeployment(); }); // Bind a batch's tip to a real recent block after mining a margin of blocks, // so logical firstBlock..lastBlock ranges never go negative. async function freshTip() { await ethers.provider.send("hardhat_mine", ["0x14"]); // mine 20 blocks const l = await ethers.provider.getBlockNumber(); const h = (await ethers.provider.getBlock(l)).hash; return { l, h }; } it("REJECTS a batch whose tip blockHash is wrong", async () => { const { l } = await freshTip(); const pv = encodeBatchV2({ chainId, firstBlock: l - 2, lastBlock: l, numBlocks: 3, firstParentHash: ZERO32, lastBlockHash: "0x" + "de".repeat(32), // wrong prevStateRoot: PREV_ROOT, postStateRoot: "0x" + "a1".repeat(32), totalGas: 63000, numTxns: 3, }); await mock.setValid(marker(VKEY, pv, PROOF), true); await expect(b2.recordBatch(pv, PROOF)).to.be.revertedWithCustomError( b2, "BlockHashMismatch" ); }); it("ACCEPTS a first batch, then ENFORCES continuity on the next", async () => { const R1 = "0x" + "a1".repeat(32); const R2 = "0x" + "b2".repeat(32); // First batch [la-2 .. la] anchors the chain (no continuity needed yet). const a = await freshTip(); const la = a.l; const pvA = encodeBatchV2({ chainId, firstBlock: la - 2, lastBlock: la, numBlocks: 3, firstParentHash: ZERO32, lastBlockHash: a.h, prevStateRoot: PREV_ROOT, postStateRoot: R1, totalGas: 63000, numTxns: 3, }); await mock.setValid(marker(VKEY, pvA, PROOF), true); await b2.recordBatch(pvA, PROOF); expect(await b2.provenCount()).to.equal(1n); // Discontinuous firstBlock (gap) is rejected. lb is far above la+5. const g = await freshTip(); const pvGap = encodeBatchV2({ chainId, firstBlock: la + 5, lastBlock: g.l, numBlocks: g.l - (la + 5) + 1, firstParentHash: ZERO32, lastBlockHash: g.h, prevStateRoot: R1, postStateRoot: R2, totalGas: 42000, numTxns: 2, }); await mock.setValid(marker(VKEY, pvGap, PROOF), true); await expect(b2.recordBatch(pvGap, PROOF)).to.be.revertedWithCustomError( b2, "Discontinuous" ); // Correct firstBlock but wrong prevStateRoot is rejected. const br = await freshTip(); const pvBadRoot = encodeBatchV2({ chainId, firstBlock: la + 1, lastBlock: br.l, numBlocks: br.l - (la + 1) + 1, firstParentHash: ZERO32, lastBlockHash: br.h, prevStateRoot: "0x" + "99".repeat(32), postStateRoot: R2, totalGas: 42000, numTxns: 2, }); await mock.setValid(marker(VKEY, pvBadRoot, PROOF), true); await expect(b2.recordBatch(pvBadRoot, PROOF)).to.be.revertedWithCustomError( b2, "Discontinuous" ); // Contiguous + matching prevStateRoot is accepted. const gd = await freshTip(); const pvGood = encodeBatchV2({ chainId, firstBlock: la + 1, lastBlock: gd.l, numBlocks: gd.l - (la + 1) + 1, firstParentHash: ZERO32, lastBlockHash: gd.h, prevStateRoot: R1, postStateRoot: R2, totalGas: 42000, numTxns: 2, }); await mock.setValid(marker(VKEY, pvGood, PROOF), true); await b2.recordBatch(pvGood, PROOF); expect(await b2.provenCount()).to.equal(2n); }); }); });