aere-contracts/test/outbound-verifier-v2-anchor-fix.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

322 lines
14 KiB
JavaScript

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"
);
});
});