const { expect } = require("chai"); const { ethers, network } = require("hardhat"); // --------------------------------------------------------------------------- // AereOutboundVerifier — trust-minimised OUTBOUND interop (AERE -> dest chain). // // The zk-interop SP1 guest commits ABI-encoded public values: // (uint64 chainId, uint64 blockNumber, bytes32 blockHash, bytes32 root, // uint8 claimKind, bytes32 commitment, bool finalized, bool falconVerified) // where `commitment` = interop_core::log_commitment(0) over the Outbox log: // keccak256( cid(32) ‖ blockHash(32) ‖ addr(32) ‖ topic0 ‖ topic1 ‖ topic2 // ‖ keccak256(data) ) // // Hardhat has no SP1 Groth16 verifier, so we install a MockSP1Verifier that, // like a real verifier, accepts ONLY a registered (vkey, publicValues, proof) // triple. The commitment is rebuilt here byte-for-byte the way the Rust guest // builds it, so what the verifier checks on-chain is exactly what the guest // proves off-chain. (The real guest logic is separately validated against live // chain-2800 data in zk-interop/interop-core/tests/real_inclusion.rs.) // --------------------------------------------------------------------------- const AERE_CHAIN_ID = 2800n; const abi = ethers.AbiCoder.defaultAbiCoder(); // keccak256 of the event signature string (topic0). const EVENT_SIG = ethers.keccak256( ethers.toUtf8Bytes( "AereCrossChainMessage(bytes32,uint256,uint256,address,address,bytes)" ) ); // 32-byte left-padded word from a uint / address, matching the Rust padding. function word(v) { return ethers.zeroPadValue(ethers.toBeHex(BigInt(v)), 32); } function addrWord(a) { return ethers.zeroPadValue(ethers.getAddress(a), 32); } // Rebuild the guest commitment for log index 0 of the Outbox event. function buildCommitment({ blockHash, outbox, messageId, destChainId, dataHash }) { return ethers.keccak256( ethers.solidityPacked( ["bytes32", "bytes32", "bytes32", "bytes32", "bytes32", "bytes32", "bytes32"], [ word(AERE_CHAIN_ID), // cid blockHash, addrWord(outbox), EVENT_SIG, // topic0 messageId, // topic1 word(destChainId), // topic2 dataHash, ] ) ); } function messageIdOf(destChainId, nonce, sender, targetHandler, payload) { return ethers.keccak256( abi.encode( ["uint256", "uint256", "uint256", "address", "address", "bytes"], [AERE_CHAIN_ID, destChainId, nonce, sender, targetHandler, payload] ) ); } function dataHashOf(nonce, sender, targetHandler, payload) { return ethers.keccak256( abi.encode( ["uint256", "address", "address", "bytes"], [nonce, sender, targetHandler, payload] ) ); } // The existing MockSp1Verifier keys acceptance on a single marker that binds // vkey + publicValues + proof, exactly like the real gateway's (vkey, pv, proof). function proofMarker(vkey, pv, proof) { return ethers.keccak256( abi.encode(["bytes32", "bytes", "bytes"], [vkey, pv, proof]) ); } // Encode the guest's committed public values. function encodePublicValues(pv) { return abi.encode( ["uint64", "uint64", "bytes32", "bytes32", "uint8", "bytes32", "bool", "bool"], [ pv.chainId, pv.blockNumber, pv.blockHash, pv.root, pv.claimKind, pv.commitment, pv.finalized, pv.falconVerified, ] ); } describe("AereOutboundVerifier", function () { const GOOD_VKEY = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; const WRONG_VKEY = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; const PROOF = "0xa1b2c3d4"; // opaque Groth16 proof bytes (mock). let deployer, sender, other; let sp1, handler, outbox, verifier; let destChainId, blockHash, root; let msg; // { nonce, sender, targetHandler, payload } let messageId, dataHash, commitment, publicValues; beforeEach(async function () { [deployer, sender, other] = await ethers.getSigners(); // Destination chain id = the chain the verifier runs on (hardhat = 31337). destChainId = BigInt((await ethers.provider.getNetwork()).chainId); sp1 = await (await ethers.getContractFactory("MockSp1Verifier")).deploy(); handler = await ( await ethers.getContractFactory("MockAereMessageHandler") ).deploy(); outbox = await ( await ethers.getContractFactory("AereOutboundOutbox") ).deploy(); verifier = await ( await ethers.getContractFactory("AereOutboundVerifier") ).deploy(await sp1.getAddress(), GOOD_VKEY, await outbox.getAddress()); // The message fields. msg = { nonce: 0n, sender: sender.address, targetHandler: await handler.getAddress(), payload: "0xcafe1234", }; // A QBFT-final block that carries the Outbox log (arbitrary in the mock). blockHash = "0x6c9dbe1c0000000000000000000000000000000000000000000000000015e900"; root = "0x5ceadcd400000000000000000000000000000000000000000000000000a03500"; messageId = messageIdOf( destChainId, msg.nonce, msg.sender, msg.targetHandler, msg.payload ); dataHash = dataHashOf( msg.nonce, msg.sender, msg.targetHandler, msg.payload ); commitment = buildCommitment({ blockHash, outbox: await outbox.getAddress(), messageId, destChainId, dataHash, }); publicValues = encodePublicValues({ chainId: AERE_CHAIN_ID, blockNumber: 9261789n, blockHash, root, claimKind: 1, commitment, finalized: true, falconVerified: false, }); // Register the known-good proof triple with the mock verifier. await sp1.setValid(proofMarker(GOOD_VKEY, publicValues, PROOF), true); }); function callDeliver(v = verifier, pv = publicValues, proof = PROOF, m = msg) { return v.deliver( pv, proof, destChainId, m.nonce, m.sender, m.targetHandler, m.payload ); } it("Outbox.send emits the canonical event and matching messageId", async function () { const tx = await outbox .connect(sender) .send(destChainId, msg.targetHandler, msg.payload); await expect(tx) .to.emit(outbox, "AereCrossChainMessage") .withArgs( messageId, destChainId, 0n, sender.address, msg.targetHandler, msg.payload ); expect(await outbox.nonce()).to.equal(1n); }); it("delivers a valid proof with an unused nonce (exactly once)", async function () { await expect(callDeliver()) .to.emit(verifier, "MessageDelivered") .withArgs(messageId, sender.address, msg.targetHandler, 9261789n); expect(await verifier.delivered(messageId)).to.equal(true); expect(await handler.callCount()).to.equal(1n); expect(await handler.lastSrcChainId()).to.equal(AERE_CHAIN_ID); expect(await handler.lastSender()).to.equal(sender.address); expect(await handler.lastPayload()).to.equal(msg.payload); }); it("rejects a replay of the same message", async function () { await callDeliver(); await expect(callDeliver()) .to.be.revertedWithCustomError(verifier, "AlreadyDelivered") .withArgs(messageId); expect(await handler.callCount()).to.equal(1n); // no second delivery }); it("rejects a proof verified against the wrong pinned vkey", async function () { const wrongVerifier = await ( await ethers.getContractFactory("AereOutboundVerifier") ).deploy(await sp1.getAddress(), WRONG_VKEY, await outbox.getAddress()); // The mock only accepted (GOOD_VKEY, pv, proof); WRONG_VKEY is unregistered. await expect(callDeliver(wrongVerifier)).to.be.revertedWithCustomError( sp1, "MockInvalidProof" ); }); it("rejects tampered public inputs (SP1 layer catches it)", async function () { // Flip blockNumber — not part of the commitment, so only the SP1 proof // binding can catch it. The mock has no registered triple for this pv. const tampered = encodePublicValues({ chainId: AERE_CHAIN_ID, blockNumber: 9999999n, blockHash, root, claimKind: 1, commitment, finalized: true, falconVerified: false, }); await expect(callDeliver(verifier, tampered)).to.be.revertedWithCustomError( sp1, "MockInvalidProof" ); }); it("rejects delivered fields that do not match the proven commitment", async function () { // Correct proof/publicValues, but deliver a DIFFERENT payload: the // re-derived commitment no longer matches the proven one. const tamperedMsg = { ...msg, payload: "0xdeadbeef" }; await expect( callDeliver(verifier, publicValues, PROOF, tamperedMsg) ).to.be.revertedWithCustomError(verifier, "CommitmentMismatch"); }); it("rejects a proof for the wrong source chain", async function () { // chainId != 2800. Rebuild a self-consistent, registered proof so the SP1 // layer passes and the chain-id gate is what rejects it. const badChainId = 9999n; const pv = encodePublicValues({ chainId: badChainId, blockNumber: 9261789n, blockHash, root, claimKind: 1, commitment, finalized: true, falconVerified: false, }); await sp1.setValid(proofMarker(GOOD_VKEY, pv, PROOF), true); await expect(callDeliver(verifier, pv)) .to.be.revertedWithCustomError(verifier, "WrongSourceChain") .withArgs(badChainId); }); it("rejects a non-final source block", async function () { const pv = encodePublicValues({ chainId: AERE_CHAIN_ID, blockNumber: 9261789n, blockHash, root, claimKind: 1, commitment, finalized: false, falconVerified: false, }); await sp1.setValid(proofMarker(GOOD_VKEY, pv, PROOF), true); await expect(callDeliver(verifier, pv)).to.be.revertedWithCustomError( verifier, "NotFinal" ); }); it("rejects a non-receipt claim kind", async function () { const pv = encodePublicValues({ chainId: AERE_CHAIN_ID, blockNumber: 9261789n, blockHash, root, claimKind: 0, // storage claim, not a message log commitment, finalized: true, falconVerified: false, }); await sp1.setValid(proofMarker(GOOD_VKEY, pv, PROOF), true); await expect(callDeliver(verifier, pv)) .to.be.revertedWithCustomError(verifier, "NotReceiptClaim") .withArgs(0); }); it("rejects an unbacked post-quantum (falconVerified) claim", async function () { const pv = encodePublicValues({ chainId: AERE_CHAIN_ID, blockNumber: 9261789n, blockHash, root, claimKind: 1, commitment, finalized: true, falconVerified: true, // mainnet consensus is ECDSA; must be false }); await sp1.setValid(proofMarker(GOOD_VKEY, pv, PROOF), true); await expect(callDeliver(verifier, pv)).to.be.revertedWithCustomError( verifier, "UnexpectedFalconFlag" ); }); it("rejects a message addressed to the wrong destination chain", async function () { const badDest = destChainId + 1n; const mid = messageIdOf( badDest, msg.nonce, msg.sender, msg.targetHandler, msg.payload ); const dh = dataHashOf(msg.nonce, msg.sender, msg.targetHandler, msg.payload); const cmt = buildCommitment({ blockHash, outbox: await outbox.getAddress(), messageId: mid, destChainId: badDest, dataHash: dh, }); const pv = encodePublicValues({ chainId: AERE_CHAIN_ID, blockNumber: 9261789n, blockHash, root, claimKind: 1, commitment: cmt, finalized: true, falconVerified: false, }); await sp1.setValid(proofMarker(GOOD_VKEY, pv, PROOF), true); await expect( verifier.deliver( pv, PROOF, badDest, msg.nonce, msg.sender, msg.targetHandler, msg.payload ) ) .to.be.revertedWithCustomError(verifier, "WrongDestinationChain") .withArgs(badDest); }); it("rejects a log claimed from an untrusted (wrong) outbox address", async function () { // Build a commitment as if a DIFFERENT contract emitted the log. The // verifier is pinned to the real outbox, so re-derivation mismatches. const cmt = buildCommitment({ blockHash, outbox: other.address, // not the pinned outbox messageId, destChainId, dataHash, }); const pv = encodePublicValues({ chainId: AERE_CHAIN_ID, blockNumber: 9261789n, blockHash, root, claimKind: 1, commitment: cmt, finalized: true, falconVerified: false, }); await sp1.setValid(proofMarker(GOOD_VKEY, pv, PROOF), true); await expect(callDeliver(verifier, pv)).to.be.revertedWithCustomError( verifier, "CommitmentMismatch" ); }); it("propagates a handler revert (message not marked delivered)", async function () { await handler.setShouldRevert(true); await expect(callDeliver()).to.be.revertedWith("handler: forced revert"); // State-changing revert rolls back the delivered flag too. expect(await verifier.delivered(messageId)).to.equal(false); }); });