aere-contracts/test/AerePQAggregate.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

425 lines
22 KiB
JavaScript

const { expect } = require("chai");
const { ethers } = require("hardhat");
// ---------------------------------------------------------------------------
// AerePQAggregate: constant-cost on-chain verification of a t-of-n POST-QUANTUM
// committee authorization via ONE succinct SP1 proof.
//
// WHAT IS UNDER TEST (real, on-chain): AerePQAggregateVerifier and the ERC-7579
// validator module AerePQAggregateModule. The verifier binds a committee root, t,
// n, scheme and the message digest into the SP1 public values and gates on a single
// gateway verifyProof call whose cost does not grow with n.
//
// TEST DOUBLE: MockSp1Verifier is the deployed SP1 gateway stand-in. Its verifyProof
// REVERTS on an invalid proof (exact production gateway semantics), so it faithfully
// exercises the fail-closed path. It accepts only (vkey, publicValues, proof) triples
// pre-registered via setValid(), so a "valid proof" in these tests means the gateway
// asserted the exact bound public values.
//
// HONEST BOUNDARY: the OFF-CHAIN SP1 aggregation guest circuit (verify n ML-DSA/Falcon
// signatures against the committee root inside the zkVM and emit these public values)
// is NOT implemented and is [MEASURE]. These tests prove the on-chain verifier + module
// only, against a mock gateway, exactly like AereComputeMarketV3 and the zk light clients.
// ---------------------------------------------------------------------------
const SCHEME_MLDSA65 = 5;
const VALIDATION_SUCCESS = 0n;
const SIG_VALIDATION_FAILED = 1n;
const EIP1271_MAGIC = "0x1626ba7e";
const EIP1271_FAIL = "0xffffffff";
function abi() {
return ethers.AbiCoder.defaultAbiCoder();
}
// The fixed 192-byte SP1 public-values layout the aggregation circuit must emit.
function encodePublicValues({ root, threshold, size, scheme, digest, validCount }) {
return abi().encode(
["bytes32", "uint256", "uint256", "uint256", "bytes32", "uint256"],
[root, threshold, size, scheme, digest, validCount]
);
}
// Marker the MockSp1Verifier keys a valid proof on: keccak256(abi.encode(vkey, publicValues, proof)).
function proofMarker(vkey, publicValues, proof) {
return ethers.keccak256(abi().encode(["bytes32", "bytes", "bytes"], [vkey, publicValues, proof]));
}
// A committee root: a commitment to the n authorized PQC public keys. The contract only
// compares a bytes32, so any binding commitment works; we use keccak over a label + n.
function committeeRoot(label, n) {
return ethers.keccak256(ethers.toUtf8Bytes(`committee:${label}:n=${n}`));
}
describe("AerePQAggregate (verifier + ERC-7579 module)", function () {
let sp1, verifier, module;
let deployer, account, relayer, other;
const VKEY = ethers.keccak256(ethers.toUtf8Bytes("sp1-program:aere-pq-aggregate-mldsa65-v1"));
beforeEach(async function () {
[deployer, account, relayer, other] = await ethers.getSigners();
sp1 = await (await ethers.getContractFactory("MockSp1Verifier")).deploy();
await sp1.waitForDeployment();
verifier = await (await ethers.getContractFactory("AerePQAggregateVerifier")).deploy(
await sp1.getAddress(),
VKEY
);
await verifier.waitForDeployment();
module = await (await ethers.getContractFactory("AerePQAggregateModule")).deploy(
await verifier.getAddress()
);
await module.waitForDeployment();
});
// Register a committee and return {id, root, threshold, size, scheme}.
async function registerCommittee({ label = "A", threshold = 3, size = 5, scheme = SCHEME_MLDSA65 } = {}) {
const root = committeeRoot(label, size);
const idBefore = await verifier.committeeCount();
await verifier.connect(deployer).registerCommittee(root, threshold, size, scheme);
const id = idBefore + 1n;
return { id, root, threshold, size, scheme };
}
// Build public values + a proof, and (optionally) register it valid in the mock gateway.
async function makeProof(c, { digest, validCount, proof = "0xa1b2c3d4", register = true } = {}) {
const publicValues = encodePublicValues({
root: c.root,
threshold: c.threshold,
size: c.size,
scheme: c.scheme,
digest,
validCount,
});
if (register) {
await sp1.setValid(proofMarker(VKEY, publicValues, proof), true);
}
return { publicValues, proof };
}
// =========================================================================
// 1. Registration
// =========================================================================
describe("registration", function () {
it("registers a committee and rejects bad params", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
const info = await verifier.getCommittee(c.id);
expect(info.exists).to.equal(true);
expect(info.threshold).to.equal(3);
expect(info.size).to.equal(5);
expect(info.scheme).to.equal(SCHEME_MLDSA65);
expect(info.root).to.equal(c.root);
expect(await verifier.committeeExists(c.id)).to.equal(true);
await expect(
verifier.registerCommittee(ethers.ZeroHash, 3, 5, SCHEME_MLDSA65)
).to.be.revertedWithCustomError(verifier, "ZeroRoot");
await expect(
verifier.registerCommittee(committeeRoot("bad", 5), 0, 5, SCHEME_MLDSA65)
).to.be.revertedWithCustomError(verifier, "BadCommitteeParams");
await expect(
verifier.registerCommittee(committeeRoot("bad", 5), 6, 5, SCHEME_MLDSA65)
).to.be.revertedWithCustomError(verifier, "BadCommitteeParams");
await expect(
verifier.registerCommittee(committeeRoot("bad", 5), 3, 5, 0)
).to.be.revertedWithCustomError(verifier, "BadScheme");
});
// L1 (LOW) fix: verifyProof returns void, so a code-less gateway makes the call succeed silently
// (fail-OPEN = post-quantum authorization bypass). The constructor must reject a code-less gateway,
// exactly like the sibling AereFinalityCertificateVerifier.
it("rejects a code-less SP1 gateway at construction (L1 fail-open guard)", async function () {
const F = await ethers.getContractFactory("AerePQAggregateVerifier");
// A non-zero EOA / empty address has no code.
await expect(F.deploy(other.address, VKEY)).to.be.revertedWithCustomError(verifier, "VerifierHasNoCode");
// A zero gateway still reverts on the earlier ZeroGateway check.
await expect(F.deploy(ethers.ZeroAddress, VKEY)).to.be.revertedWithCustomError(verifier, "ZeroGateway");
// Control: the real (code-bearing) mock gateway deploys fine.
const ok = await F.deploy(await sp1.getAddress(), VKEY);
await ok.waitForDeployment();
expect(await ok.SP1_GATEWAY()).to.equal(await sp1.getAddress());
});
});
// =========================================================================
// 2. Verifier: valid proof authorizes; invalid / wrong-digest / wrong-root reject
// =========================================================================
describe("verifier fail-closed semantics", function () {
it("a valid aggregate proof authorizes (view + recorded)", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
const digest = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:approve-transfer"));
const { publicValues, proof } = await makeProof(c, { digest, validCount: 3 });
// Strict view path does not revert.
expect(await verifier.verifyAggregate(c.id, digest, publicValues, proof)).to.equal(true);
// Non-reverting path returns true.
expect(await verifier.tryVerifyAggregate(c.id, digest, publicValues, proof)).to.equal(true);
// State path records provenance and emits.
await expect(verifier.connect(relayer).recordAggregate(c.id, digest, publicValues, proof))
.to.emit(verifier, "AggregateVerified")
.withArgs(c.id, digest, c.threshold, 3, anyUint());
const att = await verifier.getAttestation(c.id, digest);
expect(att.exists).to.equal(true);
expect(att.validCount).to.equal(3);
expect(await verifier.isAttested(c.id, digest)).to.equal(true);
});
it("rejects an invalid proof fail-closed (proof not registered in the gateway)", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
const digest = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:1"));
// register=false => the mock gateway will revert MockInvalidProof.
const { publicValues, proof } = await makeProof(c, { digest, validCount: 3, register: false });
await expect(
verifier.verifyAggregate(c.id, digest, publicValues, proof)
).to.be.revertedWithCustomError(sp1, "MockInvalidProof");
expect(await verifier.tryVerifyAggregate(c.id, digest, publicValues, proof)).to.equal(false);
await expect(
verifier.recordAggregate(c.id, digest, publicValues, proof)
).to.be.revertedWithCustomError(sp1, "MockInvalidProof");
expect(await verifier.isAttested(c.id, digest)).to.equal(false);
});
it("rejects a proof for the WRONG message digest", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
const signedDigest = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:the-committee-signed-this"));
const claimedDigest = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:but-caller-claims-this"));
// The proof binds signedDigest and is even registered valid in the gateway...
const { publicValues, proof } = await makeProof(c, { digest: signedDigest, validCount: 3 });
// ...but verifying it against a different claimed digest is rejected BEFORE the gateway call.
await expect(
verifier.verifyAggregate(c.id, claimedDigest, publicValues, proof)
).to.be.revertedWithCustomError(verifier, "MessageDigestMismatch");
expect(await verifier.tryVerifyAggregate(c.id, claimedDigest, publicValues, proof)).to.equal(false);
});
it("rejects a proof whose committee root does NOT match the registered committee", async function () {
const c = await registerCommittee({ label: "real", threshold: 3, size: 5 });
const digest = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:x"));
// Public values carry a DIFFERENT root than the registered committee, and are registered valid.
const forgedRoot = committeeRoot("attacker-key-set", 5);
const publicValues = encodePublicValues({
root: forgedRoot,
threshold: c.threshold,
size: c.size,
scheme: c.scheme,
digest,
validCount: 3,
});
const proof = "0xdeadbeef";
await sp1.setValid(proofMarker(VKEY, publicValues, proof), true);
await expect(
verifier.verifyAggregate(c.id, digest, publicValues, proof)
).to.be.revertedWithCustomError(verifier, "CommitteeRootMismatch");
expect(await verifier.tryVerifyAggregate(c.id, digest, publicValues, proof)).to.equal(false);
});
it("rejects when the proven valid-signature count is below threshold", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
const digest = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:subquorum"));
// Only 2 valid signatures proven, threshold is 3. Even a gateway-valid proof must be rejected.
const { publicValues, proof } = await makeProof(c, { digest, validCount: 2 });
await expect(
verifier.verifyAggregate(c.id, digest, publicValues, proof)
).to.be.revertedWithCustomError(verifier, "BelowThreshold");
expect(await verifier.tryVerifyAggregate(c.id, digest, publicValues, proof)).to.equal(false);
});
it("rejects on threshold / size / scheme mismatch and malformed public values", async function () {
const c = await registerCommittee({ threshold: 3, size: 5, scheme: SCHEME_MLDSA65 });
const digest = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:mismatch"));
const wrongT = encodePublicValues({ root: c.root, threshold: 2, size: 5, scheme: SCHEME_MLDSA65, digest, validCount: 5 });
await sp1.setValid(proofMarker(VKEY, wrongT, "0x01"), true);
await expect(verifier.verifyAggregate(c.id, digest, wrongT, "0x01")).to.be.revertedWithCustomError(verifier, "ThresholdMismatch");
const wrongN = encodePublicValues({ root: c.root, threshold: 3, size: 9, scheme: SCHEME_MLDSA65, digest, validCount: 3 });
await sp1.setValid(proofMarker(VKEY, wrongN, "0x02"), true);
await expect(verifier.verifyAggregate(c.id, digest, wrongN, "0x02")).to.be.revertedWithCustomError(verifier, "SizeMismatch");
const wrongScheme = encodePublicValues({ root: c.root, threshold: 3, size: 5, scheme: 3, digest, validCount: 3 });
await sp1.setValid(proofMarker(VKEY, wrongScheme, "0x03"), true);
await expect(verifier.verifyAggregate(c.id, digest, wrongScheme, "0x03")).to.be.revertedWithCustomError(verifier, "SchemeMismatch");
// Malformed (not 192 bytes) public values: strict reverts, non-reverting returns false.
const malformed = "0x1234";
await expect(verifier.verifyAggregate(c.id, digest, malformed, "0x04")).to.be.revertedWithCustomError(verifier, "BadPublicValues");
expect(await verifier.tryVerifyAggregate(c.id, digest, malformed, "0x04")).to.equal(false);
});
it("rejects an unknown committee", async function () {
const digest = ethers.keccak256(ethers.toUtf8Bytes("x"));
const publicValues = encodePublicValues({
root: committeeRoot("ghost", 5), threshold: 3, size: 5, scheme: SCHEME_MLDSA65, digest, validCount: 3,
});
await expect(
verifier.verifyAggregate(999, digest, publicValues, "0x05")
).to.be.revertedWithCustomError(verifier, "UnknownCommittee");
expect(await verifier.tryVerifyAggregate(999, digest, publicValues, "0x05")).to.equal(false);
});
});
// =========================================================================
// 3. Gas is INDEPENDENT of the committee size n (the whole point)
// =========================================================================
describe("constant on-chain cost regardless of committee size n", function () {
it("recordAggregate costs the same for a tiny and a huge committee", async function () {
const small = await registerCommittee({ label: "small", threshold: 3, size: 5 });
const huge = await registerCommittee({ label: "huge", threshold: 3, size: 1_000_000 });
const digest = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:gas-parity"));
const pSmall = await makeProof(small, { digest, validCount: 3, proof: "0xaa" });
const pHuge = await makeProof(huge, { digest, validCount: 3, proof: "0xbb" });
const rcSmall = await (await verifier.recordAggregate(small.id, digest, pSmall.publicValues, pSmall.proof)).wait();
const rcHuge = await (await verifier.recordAggregate(huge.id, digest, pHuge.publicValues, pHuge.proof)).wait();
// The only residual difference is the calldata encoding of the integer n itself (a few bytes),
// which is O(log n), NOT the O(n) tens-of-thousands-of-gas-per-signature a linear verifier costs.
const diff = rcSmall.gasUsed > rcHuge.gasUsed ? rcSmall.gasUsed - rcHuge.gasUsed : rcHuge.gasUsed - rcSmall.gasUsed;
expect(diff).to.be.lessThan(500n);
// Sanity: n grew 200,000x yet gas barely moved and stayed well under any per-member budget.
expect(rcHuge.gasUsed).to.be.lessThan(200_000n);
console.log(` gas n=5: ${rcSmall.gasUsed} gas n=1,000,000: ${rcHuge.gasUsed} diff: ${diff}`);
});
});
// =========================================================================
// 4. ERC-7579 validator module
// =========================================================================
describe("AerePQAggregateModule (ERC-7579 validator, type 1)", function () {
function makeUserOp(sender, signature) {
return {
sender,
nonce: 0,
initCode: "0x",
callData: "0x",
accountGasLimits: ethers.ZeroHash,
preVerificationGas: 0,
gasFees: ethers.ZeroHash,
paymasterAndData: "0x",
signature,
};
}
// module signature = abi.encode(bytes publicValues, bytes proofBytes)
function moduleSig(publicValues, proof) {
return abi().encode(["bytes", "bytes"], [publicValues, proof]);
}
it("declares module type 1 (validator)", async function () {
expect(await module.isModuleType(1)).to.equal(true);
expect(await module.isModuleType(2)).to.equal(false);
expect(await module.name()).to.equal("aere.pq-aggregate-validator.1.0.0");
});
it("install binds the account to a registered committee; unknown committee reverts", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
await expect(
module.connect(account).onInstall(abi().encode(["uint256"], [999]))
).to.be.revertedWithCustomError(module, "UnknownCommittee");
await module.connect(account).onInstall(abi().encode(["uint256"], [c.id]));
expect(await module.committeeOf(account.address)).to.equal(c.id);
});
it("validateUserOp returns SUCCESS on a valid aggregate proof over the userOpHash", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
await module.connect(account).onInstall(abi().encode(["uint256"], [c.id]));
const userOpHash = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:committee-approved"));
const { publicValues, proof } = await makeProof(c, { digest: userOpHash, validCount: 4 });
const userOp = makeUserOp(account.address, moduleSig(publicValues, proof));
const res = await module.connect(account).validateUserOp.staticCall(userOp, userOpHash);
expect(res).to.equal(VALIDATION_SUCCESS);
});
it("validateUserOp returns FAILED (no revert) on an invalid proof", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
await module.connect(account).onInstall(abi().encode(["uint256"], [c.id]));
const userOpHash = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:forged"));
// Not registered in the gateway => the proof is invalid.
const { publicValues, proof } = await makeProof(c, { digest: userOpHash, validCount: 4, register: false });
const userOp = makeUserOp(account.address, moduleSig(publicValues, proof));
const res = await module.connect(account).validateUserOp.staticCall(userOp, userOpHash);
expect(res).to.equal(SIG_VALIDATION_FAILED);
});
it("validateUserOp returns FAILED on a wrong-digest proof and on a malformed signature", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
await module.connect(account).onInstall(abi().encode(["uint256"], [c.id]));
// Valid proof, but bound to a DIFFERENT digest than the userOpHash being validated.
const otherDigest = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:other-op"));
const { publicValues, proof } = await makeProof(c, { digest: otherDigest, validCount: 4 });
const userOpHash = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:this-op"));
const userOp = makeUserOp(account.address, moduleSig(publicValues, proof));
expect(await module.connect(account).validateUserOp.staticCall(userOp, userOpHash)).to.equal(SIG_VALIDATION_FAILED);
// Malformed signature blob must NOT revert; it returns FAILED.
const junkOp = makeUserOp(account.address, "0x1234");
expect(await module.connect(account).validateUserOp.staticCall(junkOp, userOpHash)).to.equal(SIG_VALIDATION_FAILED);
});
it("validateUserOp returns FAILED for an account with no committee configured", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
const userOpHash = ethers.keccak256(ethers.toUtf8Bytes("userOpHash:unconfigured"));
const { publicValues, proof } = await makeProof(c, { digest: userOpHash, validCount: 3 });
// `other` never installed the module.
const userOp = makeUserOp(other.address, moduleSig(publicValues, proof));
expect(await module.connect(other).validateUserOp.staticCall(userOp, userOpHash)).to.equal(SIG_VALIDATION_FAILED);
});
it("isValidSignatureWithSender returns the ERC-1271 magic on valid, fail on invalid", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
await module.connect(account).onInstall(abi().encode(["uint256"], [c.id]));
const hash = ethers.keccak256(ethers.toUtf8Bytes("erc1271:committee-approved-message"));
const good = await makeProof(c, { digest: hash, validCount: 3, proof: "0xc0ffee" });
const okMagic = await module
.connect(account)
.isValidSignatureWithSender(other.address, hash, moduleSig(good.publicValues, good.proof));
expect(okMagic).to.equal(EIP1271_MAGIC);
// Invalid proof => fail magic, no revert.
const bad = await makeProof(c, { digest: hash, validCount: 3, proof: "0xbadbad", register: false });
const failMagic = await module
.connect(account)
.isValidSignatureWithSender(other.address, hash, moduleSig(bad.publicValues, bad.proof));
expect(failMagic).to.equal(EIP1271_FAIL);
// Malformed signature => fail magic, no revert.
const junkMagic = await module.connect(account).isValidSignatureWithSender(other.address, hash, "0xabcd");
expect(junkMagic).to.equal(EIP1271_FAIL);
});
it("uninstall clears the binding; validation then fails closed", async function () {
const c = await registerCommittee({ threshold: 3, size: 5 });
await module.connect(account).onInstall(abi().encode(["uint256"], [c.id]));
await module.connect(account).onUninstall("0x");
expect(await module.committeeOf(account.address)).to.equal(0n);
const hash = ethers.keccak256(ethers.toUtf8Bytes("after-uninstall"));
const p = await makeProof(c, { digest: hash, validCount: 3 });
expect(
await module.connect(account).isValidSignatureWithSender(other.address, hash, moduleSig(p.publicValues, p.proof))
).to.equal(EIP1271_FAIL);
});
});
});
// Matches any uint (for the block.number arg in the AggregateVerified event).
function anyUint() {
const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs");
return anyValue;
}