aere-contracts/test/evidence/PqEquivocationEvidence.test.js
Aere Network 6038f079af
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (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
PqEquivocationEvidence: on-chain equivocation evidence for post-quantum consensus seals (AIP-22 section 6), with its local tests and the drill signer; measured on testnet 28001
2026-09-24 01:38:15 +03:00

186 lines
13 KiB
JavaScript

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