const { expect } = require("chai"); const { ethers } = require("hardhat"); /** * AereOutboundVerifierV2 — anchor-bind fix, with the V1 forgery as a PAIRED CONTROL. * * WHAT THIS FILE PROVES, AND WHAT IT DOES NOT * ─────────────────────────────────────────── * The forgery has two independent legs: * * LEG A (the zk layer) — "a valid SP1 proof of the forged statement exists". * PROVEN LIVE, NOT HERE. Real Groth16 proofs of the forged header (7 * attacker-generated keys named as the validator set, self-sealed 5-of-7, * attacker-built receiptsRoot) were generated on a dedicated prover and * submitted by eth_call to the REAL, LIVE SP1VerifierGateway 0x9ca479C8…70628: * * under the V1-design vkey 0x00123374…66f5 (256-byte, 8-word publicValues, * no validatorSetRoot) -> ACCEPTED; * * under the vkey the live V2 actually pins, 0x00a71f7d…08d0 -> ALSO ACCEPTED. * Tampering either the proof or the publicValues -> REJECTED, so the gateway is * genuinely verifying rather than rubber-stamping. The forged statement is * cryptographically VALID; only the anchor bind refuses it. * * LEG B (the contract layer) — "given that proof, the contract's gates accept it". * PROVEN HERE, with a mock verifier standing in for the (already-proven) zk leg. * * A and B together => V1's design would have delivered a forged bridge message. * The mock verifier is NOT a shortcut around the cryptography: leg A establishes the * real verifier's verdict on the real proof, and this file isolates the gate logic * that runs after it. We deliberately did NOT deploy a live forgeable verifier. * * HONEST SCOPE OF THE BYTES BELOW: these publicValues are built in the SAME layout * and shape as the real committed values, but re-derived for the LOCAL test chain id * (V1/V2 both require destChainId == block.chainid, and this chain is not 2800), and * with a placeholder blockHash/root. They exercise the GATE LOGIC, not the crypto. * The genuinely real committed values (chainId 2800, forged blockHash * 0xf210da56…b42b, attacker root 0x4f2c0cf7…23ee) were exercised live: the live V2 * reverts ValidatorSetMismatch(0x4f2c0cf7…23ee, 0x5fa93ba7…dd79) on them. See * zk-light-client/OUTBOUND-VERIFIER-FIX-2026-07-16.md for every tx hash. */ // ── Real artifacts from the 2026-07-16/17 prover run (see the report) ── const ANCHOR = "0x5fa93ba7730cdb599fd814c0497565f320f10d808b6ed485c3e02fb1a1e2dd79"; const OUTBOX = "0xd43FeacbbdDc5ff7cE4A72C726f3FBD204ef7936"; const MIN_SOURCE_BLOCK = 9312565; const VKEY_V2 = "0x00a71f7d74d3a503326a93c88b0f6a6d3bfe4609e79959cc5cad56a0d5f208d0"; const VKEY_V1_CONTROL = "0x0012337429546d116941b8ac9532008a66d438b0de859002d26d6b205d4766f5"; // REAL vkey of the rebuilt V1-design guest (the mock ignores it; the live gateway accepted this vkey + the forged proof) const AERE_CHAIN_ID = 2800n; const EVENT_SIG = ethers.id("AereCrossChainMessage(bytes32,uint256,uint256,address,address,bytes)"); const abi = ethers.AbiCoder.defaultAbiCoder(); function messageIdOf(destChainId, nonce, sender, handler, payload) { return ethers.keccak256( abi.encode( ["uint256", "uint256", "uint256", "address", "address", "bytes"], [AERE_CHAIN_ID, destChainId, nonce, sender, handler, payload] ) ); } function commitmentOf(blockHash, outbox, messageId, destChainId, nonce, sender, handler, payload) { const dataHash = ethers.keccak256( abi.encode(["uint256", "address", "address", "bytes"], [nonce, sender, handler, payload]) ); return ethers.keccak256( ethers.concat([ ethers.zeroPadValue(ethers.toBeHex(AERE_CHAIN_ID), 32), blockHash, ethers.zeroPadValue(outbox, 32), EVENT_SIG, messageId, ethers.zeroPadValue(ethers.toBeHex(destChainId), 32), dataHash, ]) ); } // V2 guest: 9 committed words, chainId from a constant, validatorSetRoot present. function encodeV2(chainId, blockNumber, blockHash, root, claimKind, commitment, vsr, finalized, falcon) { return abi.encode( ["uint64", "uint64", "bytes32", "bytes32", "uint8", "bytes32", "bytes32", "bool", "bool"], [chainId, blockNumber, blockHash, root, claimKind, commitment, vsr, finalized, falcon] ); } // V1 guest: 8 committed words, chainId ECHOED from prover input, NO validatorSetRoot. function encodeV1(chainId, blockNumber, blockHash, root, claimKind, commitment, finalized, falcon) { return abi.encode( ["uint64", "uint64", "bytes32", "bytes32", "uint8", "bytes32", "bool", "bool"], [chainId, blockNumber, blockHash, root, claimKind, commitment, finalized, falcon] ); } describe("AereOutboundVerifierV2 — validator-set anchor bind (the V1 forgery fix)", () => { let mock, recorder, v2, v1, deployer; const FORGED_BLOCK_HASH = "0x" + "ab".repeat(32); const FORGED_ROOT = "0x" + "cd".repeat(32); // keccak over an attacker's self-nominated set — any value that is not the anchor. const ATTACKER_VSR = ethers.keccak256("0x" + "de".repeat(140)); before(async () => { [deployer] = await ethers.getSigners(); const Mock = await ethers.getContractFactory("MockSP1VerifierAlwaysAccepts"); mock = await Mock.deploy(); await mock.waitForDeployment(); const Rec = await ethers.getContractFactory("AereOutboundDeliveryRecorder"); recorder = await Rec.deploy(); await recorder.waitForDeployment(); const V2 = await ethers.getContractFactory("AereOutboundVerifierV2"); v2 = await V2.deploy(await mock.getAddress(), VKEY_V2, ANCHOR, MIN_SOURCE_BLOCK, OUTBOX); await v2.waitForDeployment(); // The ORIGINAL V1 contract, wired to the same mock — the control. const V1 = await ethers.getContractFactory("AereOutboundVerifier"); v1 = await V1.deploy(await mock.getAddress(), VKEY_V1_CONTROL, OUTBOX); await v1.waitForDeployment(); }); it("CONTROL: the V1 design ACCEPTS the forgery and delivers a message nobody authorised", async () => { const handler = await recorder.getAddress(); const destChainId = (await ethers.provider.getNetwork()).chainId; const nonce = 999999n; const sender = ethers.getAddress("0x00000000000000000000000000000000deadbeef"); const payload = ethers.hexlify(ethers.toUtf8Bytes("FORGED")); const messageId = messageIdOf(destChainId, nonce, sender, handler, payload); const commitment = commitmentOf( FORGED_BLOCK_HASH, OUTBOX, messageId, destChainId, nonce, sender, handler, payload ); // The attacker simply declares chainId = 2800 — it was an echoed prover input. const pv = encodeV1(2800, 9999999, FORGED_BLOCK_HASH, FORGED_ROOT, 1, commitment, true, false); const before = await recorder.deliveryCount(); await v1.deliver(pv, "0x", destChainId, nonce, sender, handler, payload); const after = await recorder.deliveryCount(); // THE BUG, REPRODUCED: a message the real Outbox never emitted was delivered. expect(after - before).to.equal(1n); expect(await v1.delivered(messageId)).to.equal(true); const d = await recorder.deliveries(Number(after) - 1); expect(d.sender).to.equal(sender); expect(ethers.toUtf8String(await recorder.payloadAt(Number(after) - 1))).to.equal("FORGED"); }); it("FIX: V2 REJECTS the identical forgery — committed root != anchored root", async () => { const handler = await recorder.getAddress(); const destChainId = (await ethers.provider.getNetwork()).chainId; const nonce = 999999n; const sender = ethers.getAddress("0x00000000000000000000000000000000deadbeef"); const payload = ethers.hexlify(ethers.toUtf8Bytes("FORGED")); const messageId = messageIdOf(destChainId, nonce, sender, handler, payload); const commitment = commitmentOf( FORGED_BLOCK_HASH, OUTBOX, messageId, destChainId, nonce, sender, handler, payload ); // Same forgery. The V2 guest is FORCED to commit which set it used. const pv = encodeV2( 2800, 9999999, FORGED_BLOCK_HASH, FORGED_ROOT, 1, commitment, ATTACKER_VSR, // <- the attacker's own set true, false ); const before = await recorder.deliveryCount(); await expect(v2.deliver(pv, "0x", destChainId, nonce, sender, handler, payload)) .to.be.revertedWithCustomError(v2, "ValidatorSetMismatch") .withArgs(ATTACKER_VSR, ANCHOR); expect(await recorder.deliveryCount()).to.equal(before); // nothing delivered expect(await v2.delivered(messageId)).to.equal(false); }); it("FIX: V2 ACCEPTS a well-formed message that attests the ANCHORED set", async () => { const handler = await recorder.getAddress(); const destChainId = (await ethers.provider.getNetwork()).chainId; const nonce = 7n; const sender = deployer.address; const payload = ethers.hexlify(ethers.toUtf8Bytes("legit")); const messageId = messageIdOf(destChainId, nonce, sender, handler, payload); const commitment = commitmentOf( FORGED_BLOCK_HASH, OUTBOX, messageId, destChainId, nonce, sender, handler, payload ); const pv = encodeV2(2800, 9999999, FORGED_BLOCK_HASH, FORGED_ROOT, 1, commitment, ANCHOR, true, false); await expect(v2.deliver(pv, "0x", destChainId, nonce, sender, handler, payload)).to.emit(v2, "MessageDelivered"); expect(await v2.delivered(messageId)).to.equal(true); }); it("replay of the same delivered message reverts", async () => { const handler = await recorder.getAddress(); const destChainId = (await ethers.provider.getNetwork()).chainId; const nonce = 7n; const sender = deployer.address; const payload = ethers.hexlify(ethers.toUtf8Bytes("legit")); const messageId = messageIdOf(destChainId, nonce, sender, handler, payload); const commitment = commitmentOf( FORGED_BLOCK_HASH, OUTBOX, messageId, destChainId, nonce, sender, handler, payload ); const pv = encodeV2(2800, 9999999, FORGED_BLOCK_HASH, FORGED_ROOT, 1, commitment, ANCHOR, true, false); await expect(v2.deliver(pv, "0x", destChainId, nonce, sender, handler, payload)) .to.be.revertedWithCustomError(v2, "AlreadyDelivered") .withArgs(messageId); }); it("rejects a wrong-length publicValues blob instead of silently abi.decoding it", async () => { const handler = await recorder.getAddress(); const destChainId = (await ethers.provider.getNetwork()).chainId; const short = encodeV1(2800, 9999999, FORGED_BLOCK_HASH, FORGED_ROOT, 1, ethers.ZeroHash, true, false); expect(ethers.dataLength(short)).to.equal(256); // the V1 8-word layout await expect(v2.deliver(short, "0x", destChainId, 1n, deployer.address, handler, "0x")) .to.be.revertedWithCustomError(v2, "BadPublicValuesLength") .withArgs(256); }); it("rejects a source block below the anchored set's validity window", async () => { const handler = await recorder.getAddress(); const destChainId = (await ethers.provider.getNetwork()).chainId; const pv = encodeV2(2800, 1000, FORGED_BLOCK_HASH, FORGED_ROOT, 1, ethers.ZeroHash, ANCHOR, true, false); await expect(v2.deliver(pv, "0x", destChainId, 1n, deployer.address, handler, "0x")) .to.be.revertedWithCustomError(v2, "SourceBlockTooOld") .withArgs(1000, MIN_SOURCE_BLOCK); }); it("rejects an unbacked post-quantum claim (falconVerified = true)", async () => { const handler = await recorder.getAddress(); const destChainId = (await ethers.provider.getNetwork()).chainId; const pv = encodeV2(2800, 9999999, FORGED_BLOCK_HASH, FORGED_ROOT, 1, ethers.ZeroHash, ANCHOR, true, true); await expect(v2.deliver(pv, "0x", destChainId, 1n, deployer.address, handler, "0x")).to.be.revertedWithCustomError( v2, "UnexpectedFalconFlag" ); }); it("constructor refuses a zero vkey (V1's fail-closed trap is not repeatable)", async () => { const V2 = await ethers.getContractFactory("AereOutboundVerifierV2"); await expect( V2.deploy(await mock.getAddress(), ethers.ZeroHash, ANCHOR, MIN_SOURCE_BLOCK, OUTBOX) ).to.be.revertedWithCustomError(V2, "ZeroValue"); await expect( V2.deploy(await mock.getAddress(), VKEY_V2, ethers.ZeroHash, MIN_SOURCE_BLOCK, OUTBOX) ).to.be.revertedWithCustomError(V2, "ZeroValue"); }); it("constructor refuses a code-less verifier", async () => { const V2 = await ethers.getContractFactory("AereOutboundVerifierV2"); await expect( V2.deploy(deployer.address, VKEY_V2, ANCHOR, MIN_SOURCE_BLOCK, OUTBOX) ).to.be.revertedWithCustomError(V2, "VerifierHasNoCode"); }); it("rejects a proof the SP1 verifier does not accept", async () => { const Rejecting = await ethers.getContractFactory("MockSP1VerifierAlwaysReverts"); const rej = await Rejecting.deploy(); await rej.waitForDeployment(); const V2 = await ethers.getContractFactory("AereOutboundVerifierV2"); const v2r = await V2.deploy(await rej.getAddress(), VKEY_V2, ANCHOR, MIN_SOURCE_BLOCK, OUTBOX); await v2r.waitForDeployment(); const handler = await recorder.getAddress(); const destChainId = (await ethers.provider.getNetwork()).chainId; const nonce = 42n; const sender = deployer.address; const payload = "0x"; const messageId = messageIdOf(destChainId, nonce, sender, handler, payload); const commitment = commitmentOf( FORGED_BLOCK_HASH, OUTBOX, messageId, destChainId, nonce, sender, handler, payload ); const pv = encodeV2(2800, 9999999, FORGED_BLOCK_HASH, FORGED_ROOT, 1, commitment, ANCHOR, true, false); await expect(v2r.deliver(pv, "0x", destChainId, nonce, sender, handler, payload)).to.be.revertedWithCustomError( v2r, "InvalidProof" ); }); });