// AIP-22 stage 6: PqEquivocationEvidence, local probes (2026-09-23). // A registry v3 epoch of THREE rows (odd, so the Merkle self-pairing is exercised) is built here with the canonical bytes of // PqRegistryV3.rowPreimage (the same code as the published registry-v3.mjs), pinned in the 11-byte getter, and the live Falcon-512 // precompile is stood in by MockFalcon512 at 0x0AE1 (a "seal" is keccak256(pk || message) framed like a real one). Every refusal is // asserted by its REASON (custom error), not by a bare revert. Real Falcon seals are measured on testnet 28001, not here. const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const CHAIN = 28001n; const BIND = 3097600n; const PREC = "0x0000000000000000000000000000000000000ae1"; const K = (b) => Buffer.from(ethers.getBytes(ethers.keccak256(b))); const u32 = (v) => { const b = Buffer.alloc(4); b.writeUInt32BE(Number(v)); return b; }; const u64 = (v) => { const b = Buffer.alloc(8); b.writeBigUInt64BE(BigInt(v)); return b; }; function falconPk(seed) { const pk = Buffer.alloc(897); pk[0] = 0x09; for (let i = 1; i < 897; i++) pk[i] = (seed * 31 + i * 7) & 0xff; return pk; } const idOf = (pk) => K(Buffer.concat([Buffer.from([0x50, 0x20]), pk])).subarray(12, 32); // idOverride: a ROTATED row keeps the id of its first registration, so its id is not the derivation of its current key (AIP-22 stage 3b) function rowPreimage(chainId, bind, count, index, pk, withSlh, transport, idOverride) { const parts = [Buffer.from("AERE-PQ-ROW-3", "ascii"), Buffer.from([3]), u64(chainId), u64(bind), u32(count), u32(index), idOverride || idOf(pk)]; const keys = [[1, pk, Buffer.alloc(666, 0x39)]]; if (withSlh) keys.push([2, Buffer.alloc(32, 0x5a + index), Buffer.alloc(7856, 0x11)]); parts.push(Buffer.from([keys.length])); for (const [w, k, pop] of keys) parts.push(Buffer.from([w]), u32(k.length), k, u32(pop.length), pop); parts.push(transport ? Buffer.concat([Buffer.from([1]), transport]) : Buffer.from([0])); return Buffer.concat(parts); } const node = (l, r) => K(Buffer.concat([Buffer.from([1]), l, r])); function merkle(leaves) { const levels = [leaves]; while (levels[levels.length - 1].length > 1) { const lv = levels[levels.length - 1], nx = []; for (let i = 0; i < lv.length; i += 2) nx.push(node(lv[i], lv[i + 1] ?? lv[i])); levels.push(nx); } return levels; } function proof(levels, index) { const p = []; let i = index; for (let d = 0; d < levels.length - 1; d++) { const lv = levels[d]; const s = i % 2 === 0 ? i + 1 : i - 1; p.push(lv[s] ?? lv[i]); i = Math.floor(i / 2); } return p.map((x) => "0x" + x.toString("hex")); } const registryHash = (chainId, bind, count, root) => K(Buffer.concat([Buffer.from("AERE-PQ-REGISTRY-3", "ascii"), u64(chainId), u64(bind), u32(count), root])); // RLP and the PqAnchor pre-images, written from the Java source (PqAnchor.prepareMessage / proposalMessage / commitMessage / roundChangePreimage) const rlpLen = (n, off) => { if (n <= 55) return Buffer.from([off + n]); let h = n.toString(16); if (h.length % 2) h = "0" + h; const l = Buffer.from(h, "hex"); return Buffer.concat([Buffer.from([off + 55 + l.length]), l]); }; const rb = (b) => (b.length === 1 && b[0] < 0x80 ? b : Buffer.concat([rlpLen(b.length, 0x80), b])); const sc = (v) => { v = BigInt(v); if (v === 0n) return rb(Buffer.alloc(0)); let h = v.toString(16); if (h.length % 2) h = "0" + h; return rb(Buffer.from(h, "hex")); }; const list = (...x) => { const p = Buffer.concat(x); return Buffer.concat([rlpLen(p.length, 0xc0), p]); }; const A = (s) => rb(Buffer.from(s, "ascii")); const H = (h) => rb(Buffer.from(ethers.getBytes(h))); const msgPR = (dom, h, r, d) => K(list(A(dom), sc(CHAIN), sc(h), sc(r), H(d))); const msgCommit = (h, hash) => K(list(A("AERE-PQ-COMMIT-1"), sc(CHAIN), sc(h), H(hash))); const msgRC = (h, t, prep, pr, d) => K(list(A("AERE-PQ-ROUNDCHANGE-1"), sc(CHAIN), sc(h), sc(t), sc(prep ? 1 : 0), sc(prep ? pr : 0), prep ? H(d) : rb(Buffer.alloc(0)))); // a mock "seal": 0x39 || nonce(40) || keccak256(pk || message) const seal = (pk, m) => "0x" + Buffer.concat([Buffer.from([0x39]), Buffer.alloc(40, 0x77), K(Buffer.concat([pk, m]))]).toString("hex"); describe("PqEquivocationEvidence (AIP-22 stage 6)", function () { let ev, pin, pks, pre, levels, root, count, ROT, ids; const D1 = "0x" + "11".repeat(32), D2 = "0x" + "22".repeat(32); async function deployPin(hash) { const [s] = await ethers.getSigners(); const init = "0x7f" + hash.toString("hex") + "600055600b80602f6000396000f3" + "60005460005260206000f3"; const tx = await s.sendTransaction({ data: init }); const r = await tx.wait(); return r.contractAddress; } before(async function () { const Mock = await ethers.getContractFactory("MockFalcon512"); const m = await Mock.deploy(); await m.waitForDeployment(); await network.provider.send("hardhat_setCode", [PREC, await ethers.provider.getCode(await m.getAddress())]); count = 3n; pks = [falconPk(1), falconPk(2), falconPk(3)]; // row 0 is a ROTATED row (its id is another key's derivation), as row 2 of the live epoch 3,097,600 on 28001 ROT = idOf(falconPk(99)); ids = [ROT, idOf(pks[1]), idOf(pks[2])]; pre = [rowPreimage(CHAIN, BIND, count, 0, pks[0], true, null, ROT), rowPreimage(CHAIN, BIND, count, 1, pks[1], true, Buffer.alloc(20, 0xab)), rowPreimage(CHAIN, BIND, count, 2, pks[2], false, null)]; levels = merkle(pre.map((p) => K(p))); root = levels[levels.length - 1][0]; pin = await deployPin(registryHash(CHAIN, BIND, count, root)); const F = await ethers.getContractFactory("PqEquivocationEvidence"); ev = await F.deploy(CHAIN, [BIND], [pin]); await ev.waitForDeployment(); }); const base = (kind, h, r, dA, dB, idx, sA, sB, extra = {}) => ({ kind, height: h, round: r, digestA: dA, digestB: dB, preparedA: false, preparedB: false, preparedRoundA: 0, preparedRoundB: 0, sealA: sA, sealB: sB, rowPreimage: "0x" + pre[idx].toString("hex"), merkleProof: proof(levels, idx), ...extra, }); it("the pin is the 11-byte read-only getter and returns the registry hash", async function () { expect(await ethers.provider.getCode(pin)).to.equal("0x60005460005260206000f3"); expect(await ethers.provider.call({ to: pin })).to.equal("0x" + registryHash(CHAIN, BIND, count, root).toString("hex")); }); it("rebuilds the four pre-images exactly as PqAnchor writes them", async function () { const h = 3131500n; const e1 = base(2, h, 3, D1, D2, 1, "0x", "0x"); const [a, b] = await ev.messages(e1); expect(a).to.equal("0x" + msgPR("AERE-PQ-PREPARE-1", h, 3, D1).toString("hex")); expect(b).to.equal("0x" + msgPR("AERE-PQ-PREPARE-1", h, 3, D2).toString("hex")); const [p] = await ev.messages(base(1, h, 0, D1, D2, 1, "0x", "0x")); expect(p).to.equal("0x" + msgPR("AERE-PQ-PROPOSAL-1", h, 0, D1).toString("hex")); const [c] = await ev.messages(base(3, h, 0, D1, D2, 1, "0x", "0x")); expect(c).to.equal("0x" + msgCommit(h, D1).toString("hex")); const [r1, r2] = await ev.messages(base(4, h, 2, D1, D2, 1, "0x", "0x", { preparedA: false, preparedB: true, preparedRoundB: 1 })); expect(r1).to.equal("0x" + msgRC(h, 2, false, 0, D1).toString("hex")); expect(r2).to.equal("0x" + msgRC(h, 2, true, 1, D2).toString("hex")); }); it("records a PREPARE equivocation of one row, once, and names its index and id", async function () { const h = 3131500n, r = 3n; const e = base(2, h, r, D1, D2, 1, seal(pks[1], msgPR("AERE-PQ-PREPARE-1", h, r, D1)), seal(pks[1], msgPR("AERE-PQ-PREPARE-1", h, r, D2))); await expect(ev.submit(e)).to.emit(ev, "EquivocationRecorded"); const key = ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes20", "uint8", "uint64", "uint64"], ["0x" + idOf(pks[1]).toString("hex"), 2, h, r])); const rec = await ev.records(key); expect(rec.id).to.equal("0x" + idOf(pks[1]).toString("hex")); expect(rec.index).to.equal(1n); expect(rec.bindHeight).to.equal(BIND); expect(await ev.recordCount()).to.equal(1n); await expect(ev.submit(e)).to.be.revertedWithCustomError(ev, "AlreadyRecorded"); }); it("records PROPOSAL, COMMIT and ROUND-CHANGE equivocations (last row of an odd tree: self-paired leaf)", async function () { const h = 3131600n; await expect(ev.submit(base(1, h, 1, D1, D2, 2, seal(pks[2], msgPR("AERE-PQ-PROPOSAL-1", h, 1, D1)), seal(pks[2], msgPR("AERE-PQ-PROPOSAL-1", h, 1, D2))))).to.emit(ev, "EquivocationRecorded"); await expect(ev.submit(base(3, h, 0, D1, D2, 0, seal(pks[0], msgCommit(h, D1)), seal(pks[0], msgCommit(h, D2))))).to.emit(ev, "EquivocationRecorded"); const x = { preparedA: false, preparedB: true, preparedRoundB: 1 }; await expect(ev.submit(base(4, h, 2, D1, D2, 0, seal(pks[0], msgRC(h, 2, false, 0, D1)), seal(pks[0], msgRC(h, 2, true, 1, D2)), x))).to.emit(ev, "EquivocationRecorded"); expect(await ev.recordCount()).to.equal(4n); }); it("refuses each way of not being an equivocation, by its reason", async function () { const h = 3131700n, r = 0n; const mA = msgPR("AERE-PQ-PREPARE-1", h, r, D1), mB = msgPR("AERE-PQ-PREPARE-1", h, r, D2); // the same message twice is not a conflict await expect(ev.submit(base(2, h, r, D1, D1, 1, seal(pks[1], mA), seal(pks[1], mA)))).to.be.revertedWithCustomError(ev, "NotAConflict"); // the second seal by ANOTHER row's key: one validator did not sign both await expect(ev.submit(base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[0], mB)))).to.be.revertedWithCustomError(ev, "SealInvalid").withArgs(2); // a seal over another round await expect(ev.submit(base(2, h, r, D1, D2, 1, seal(pks[1], msgPR("AERE-PQ-PREPARE-1", h, 1, D1)), seal(pks[1], mB)))).to.be.revertedWithCustomError(ev, "SealInvalid").withArgs(1); // a seal of the PROPOSAL domain offered as a PREPARE await expect(ev.submit(base(2, h, r, D1, D2, 1, seal(pks[1], msgPR("AERE-PQ-PROPOSAL-1", h, r, D1)), seal(pks[1], mB)))).to.be.revertedWithCustomError(ev, "SealInvalid").withArgs(1); // a Merkle path that does not reach the pinned root const bad = base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)); bad.merkleProof = [...bad.merkleProof]; bad.merkleProof[0] = "0x" + "00".repeat(32); await expect(ev.submit(bad)).to.be.revertedWithCustomError(ev, "PinMismatch"); // a row claiming the index of another row (the path of row 1 used for row 0's preimage) const swap = base(2, h, r, D1, D2, 0, seal(pks[0], mA), seal(pks[0], mB)); swap.merkleProof = proof(levels, 1); await expect(ev.submit(swap)).to.be.revertedWithCustomError(ev, "PinMismatch"); // a proof of the wrong depth const short = base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)); short.merkleProof = short.merkleProof.slice(1); await expect(ev.submit(short)).to.be.revertedWithCustomError(ev, "BadRow").withArgs("proof length"); // a row of another chain, another epoch, and an id not derived from its key const other = (mut) => { const p = Buffer.from(pre[1]); mut(p); const e = base(2, h, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)); e.rowPreimage = "0x" + p.toString("hex"); return e; }; await expect(ev.submit(other((p) => u64(28002).copy(p, 14)))).to.be.revertedWithCustomError(ev, "BadRow").withArgs("chain id"); await expect(ev.submit(other((p) => u64(BIND + 1n).copy(p, 22)))).to.be.revertedWithCustomError(ev, "RowNotInEpoch"); // the id is bound by the REGISTRY (inside the row hash), not re-derived on chain: a changed id no longer proves into the pin await expect(ev.submit(other((p) => { p[38] ^= 1; }))).to.be.revertedWithCustomError(ev, "PinMismatch"); await expect(ev.submit(other((p) => { p[0] = 0x42; }))).to.be.revertedWithCustomError(ev, "BadRow").withArgs("prefix"); // a COMMIT that names a round, an unknown type, a height before the first epoch await expect(ev.submit(base(3, h, 1, D1, D2, 1, "0x", "0x"))).to.be.revertedWithCustomError(ev, "CommitCarriesRound"); await expect(ev.submit(base(9, h, r, D1, D2, 1, "0x", "0x"))).to.be.revertedWithCustomError(ev, "UnknownKind").withArgs(9); await expect(ev.submit(base(2, BIND - 1n, r, D1, D2, 1, seal(pks[1], mA), seal(pks[1], mB)))).to.be.revertedWithCustomError(ev, "NoEpochAtHeight"); expect(await ev.recordCount()).to.equal(4n); }); it("rowAt: each row proves into the pinned epoch as a plain call, and a tampered row does not", async function () { for (let i = 0; i < 3; i++) { const [id, index] = await ev.rowAt(BIND + 5n, "0x" + pre[i].toString("hex"), proof(levels, i)); expect(id).to.equal("0x" + ids[i].toString("hex")); expect(index).to.equal(BigInt(i)); } const t = Buffer.from(pre[2]); t[t.length - 1] ^= 1; await expect(ev.rowAt(BIND + 5n, "0x" + t.toString("hex"), proof(levels, 2))).to.be.revertedWithCustomError(ev, "BadRow"); }); it("will not be built on a pin that is not the read-only getter, nor on unordered epochs", async function () { const F = await ethers.getContractFactory("PqEquivocationEvidence"); await expect(F.deploy(CHAIN, [BIND], [await ev.getAddress()])).to.be.revertedWithCustomError(F, "BadPin"); await expect(F.deploy(CHAIN, [BIND, BIND], [pin, pin])).to.be.revertedWithCustomError(F, "BadEpochs"); await expect(F.deploy(CHAIN, [], [])).to.be.revertedWithCustomError(F, "BadEpochs"); }); });