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

275 lines
15 KiB
JavaScript

const { expect } = require("chai");
const { ethers } = require("hardhat");
const tss = require("./helpers/tss");
// ---------------------------------------------------------------------------
// AereThresholdRegistry -- on-chain registry of t-of-n threshold-ECDSA committees.
//
// The signatures in these tests are produced by an INDEPENDENT CJS reimplementation
// of the same Feldman-VSS DKG + threshold combine that ships in sdk-js/src/mpc. A
// threshold signature is an ordinary (r,s,v) recovering to the DKG group address, so
// the contract verifies it with plain ecrecover. Two facts these tests establish:
// * a valid t-of-n signature verifies; a below-threshold (t-1) combine does NOT.
// * committee governance (reshare / rotate / slash) is authorized by a threshold
// signature of the CURRENT committee over a domain-separated intent hash.
//
// HONEST caveat mirrored from the contract: on-chain, a threshold sig is
// indistinguishable from a single-key sig; "t participated" is an off-chain property.
// ---------------------------------------------------------------------------
const ACTION_RESHARE = ethers.keccak256(ethers.toUtf8Bytes("AereThresholdRegistry.reshare.v1"));
const ACTION_ROTATE = ethers.keccak256(ethers.toUtf8Bytes("AereThresholdRegistry.rotateKey.v1"));
const ACTION_SLASH = ethers.keccak256(ethers.toUtf8Bytes("AereThresholdRegistry.slashMember.v1"));
const abi = ethers.AbiCoder.defaultAbiCoder();
// Random member identity addresses (not the group key; just committee roster).
function makeMembers(n) {
const a = [];
for (let i = 0; i < n; i++) a.push(ethers.Wallet.createRandom().address);
return a;
}
// Recompute the contract's intent hashes in JS so we can threshold-sign them.
function reshareIntent(chainId, addr, id, epoch, nonce, newMembers, newT) {
return ethers.keccak256(
abi.encode(
["bytes32", "uint256", "address", "uint256", "uint64", "uint64", "address[]", "uint8"],
[ACTION_RESHARE, chainId, addr, id, epoch, nonce, newMembers, newT],
),
);
}
function rotateIntent(chainId, addr, id, epoch, nonce, newPubKey, newMembers, newT) {
return ethers.keccak256(
abi.encode(
["bytes32", "uint256", "address", "uint256", "uint64", "uint64", "bytes32", "address[]", "uint8"],
[ACTION_ROTATE, chainId, addr, id, epoch, nonce, ethers.keccak256(newPubKey), newMembers, newT],
),
);
}
function slashIntent(chainId, addr, id, epoch, nonce, member, evidence) {
return ethers.keccak256(
abi.encode(
["bytes32", "uint256", "address", "uint256", "uint64", "uint64", "address", "bytes32"],
[ACTION_SLASH, chainId, addr, id, epoch, nonce, member, evidence],
),
);
}
describe("AereThresholdRegistry", function () {
let reg, chainId, deployer, guardian, outsider;
beforeEach(async function () {
[deployer, guardian, outsider] = await ethers.getSigners();
const F = await ethers.getContractFactory("AereThresholdRegistry");
reg = await F.deploy();
await reg.waitForDeployment();
chainId = (await ethers.provider.getNetwork()).chainId;
});
async function register(n, t, guardianAddr = ethers.ZeroAddress) {
const dkg = tss.runDkg(n, t);
const members = makeMembers(n);
const rc = await reg.registerCommittee(members, t, dkg.groupPubKey64, guardianAddr);
const rcpt = await rc.wait();
// committeeId is committeeCount after the call
const id = await reg.committeeCount();
return { dkg, members, id: Number(id) };
}
it("registers a committee and stores the DKG group key + roster", async function () {
const { dkg, members, id } = await register(3, 2);
const c = await reg.getCommittee(id);
expect(c.threshold).to.equal(2);
expect(c.size).to.equal(3);
expect(c.epoch).to.equal(1n);
expect(c.groupAddress.toLowerCase()).to.equal(dkg.groupAddress.toLowerCase());
expect(c.groupPubKey.toLowerCase()).to.equal(dkg.groupPubKey64.toLowerCase());
const onchainMembers = await reg.getMembers(id);
expect(onchainMembers.map((x) => x.toLowerCase())).to.deep.equal(members.map((x) => x.toLowerCase()));
for (const m of members) expect(await reg.isMember(id, m)).to.equal(true);
});
it("verifies a valid t-of-n threshold signature (2 of 3)", async function () {
const { dkg, id } = await register(3, 2);
const digest = ethers.keccak256(ethers.toUtf8Bytes("withdraw 5 AERE"));
const sig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[1]]);
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(true);
// a different valid quorum {1,3} verifies too (same group key)
const sig2 = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[2]]);
expect(await reg.verifyThresholdSignature(id, digest, sig2)).to.equal(true);
// EIP-1271-style helper returns the magic value
expect(await reg.isValidGroupSignature(id, digest, sig)).to.equal("0x1626ba7e");
});
it("REJECTS a below-threshold signature (1 of 3 -> wrong key)", async function () {
const { dkg, id } = await register(3, 2);
const digest = ethers.keccak256(ethers.toUtf8Bytes("withdraw 5 AERE"));
// combine with only ONE share -> interpolates to the wrong secret
const badSig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0]]);
expect(await reg.verifyThresholdSignature(id, digest, badSig)).to.equal(false);
});
it("REJECTS a signature over a different message, a wrong committee, malleable high-s, and bad v", async function () {
const { dkg, id } = await register(3, 2);
const digest = ethers.keccak256(ethers.toUtf8Bytes("msg A"));
const other = ethers.keccak256(ethers.toUtf8Bytes("msg B"));
const sig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[1]]);
// wrong message
expect(await reg.verifyThresholdSignature(id, other, sig)).to.equal(false);
// unknown committee id
expect(await reg.verifyThresholdSignature(999, digest, sig)).to.equal(false);
// high-s malleated copy must be rejected (s -> N-s, flip v)
const r = ethers.dataSlice(sig, 0, 32);
const s = BigInt(ethers.dataSlice(sig, 32, 64));
const v = parseInt(ethers.dataSlice(sig, 64, 65), 16);
const highS = tss.N - s;
const flippedV = v === 27 ? 28 : 27;
const malleable = ethers.concat([r, ethers.toBeHex(highS, 32), ethers.toBeHex(flippedV, 1)]);
expect(await reg.verifyThresholdSignature(id, digest, malleable)).to.equal(false);
// bad v (=29)
const badV = ethers.concat([ethers.dataSlice(sig, 0, 64), "0x1d"]);
expect(await reg.verifyThresholdSignature(id, digest, badV)).to.equal(false);
// wrong length
expect(await reg.verifyThresholdSignature(id, digest, ethers.dataSlice(sig, 0, 64))).to.equal(false);
});
it("reshares membership (2of3 -> 3of4) keeping the SAME group key, authorized by the current committee", async function () {
const { dkg, id } = await register(3, 2);
const newMembers = makeMembers(4);
const newT = 3;
const c0 = await reg.getCommittee(id);
const intent = reshareIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, newMembers, newT);
const authSig = tss.thresholdSign(ethers.getBytes(intent), [dkg.shares[0], dkg.shares[1]]);
await expect(reg.reshareCommittee(id, newMembers, newT, authSig))
.to.emit(reg, "CommitteeReshared")
.withArgs(id, 2, newT, 4, ethers.keccak256(abi.encode(["address[]"], [newMembers])));
const c = await reg.getCommittee(id);
expect(c.threshold).to.equal(3);
expect(c.size).to.equal(4);
expect(c.epoch).to.equal(2n);
// SAME group key preserved
expect(c.groupAddress.toLowerCase()).to.equal(dkg.groupAddress.toLowerCase());
// new roster installed, old roster cleared
const onchain = await reg.getMembers(id);
expect(onchain.map((x) => x.toLowerCase())).to.deep.equal(newMembers.map((x) => x.toLowerCase()));
// The SAME group key still signs (shares unchanged in this reshare model).
const digest = ethers.keccak256(ethers.toUtf8Bytes("post-reshare"));
const sig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[2]]);
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(true);
});
it("REJECTS a reshare not authorized by the current committee (forged auth)", async function () {
const { id } = await register(3, 2);
const foreign = tss.runDkg(3, 2); // a DIFFERENT group key
const newMembers = makeMembers(3);
const c0 = await reg.getCommittee(id);
const intent = reshareIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, newMembers, 2);
const badAuth = tss.thresholdSign(ethers.getBytes(intent), [foreign.shares[0], foreign.shares[1]]);
await expect(reg.reshareCommittee(id, newMembers, 2, badAuth)).to.be.revertedWith("bad committee auth");
});
it("prevents replaying a reshare auth after the epoch/nonce advances", async function () {
const { dkg, id } = await register(3, 2);
const newMembers1 = makeMembers(3);
const c0 = await reg.getCommittee(id);
const intent1 = reshareIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, newMembers1, 2);
const auth1 = tss.thresholdSign(ethers.getBytes(intent1), [dkg.shares[0], dkg.shares[1]]);
await reg.reshareCommittee(id, newMembers1, 2, auth1);
// replay the exact same call+sig -> epoch/nonce changed so intent no longer matches
await expect(reg.reshareCommittee(id, newMembers1, 2, auth1)).to.be.revertedWith("bad committee auth");
});
it("rotates to a NEW group key (post-compromise), authorized by the OLD committee", async function () {
const { dkg, id } = await register(3, 2);
const fresh = tss.runDkg(5, 3); // brand new committee + key
const newMembers = makeMembers(5);
const c0 = await reg.getCommittee(id);
const intent = rotateIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, fresh.groupPubKey64, newMembers, 3);
// authorized by the CURRENT (old) committee
const authSig = tss.thresholdSign(ethers.getBytes(intent), [dkg.shares[1], dkg.shares[2]]);
await expect(reg.rotateKey(id, fresh.groupPubKey64, newMembers, 3, authSig)).to.emit(reg, "KeyRotated");
const c = await reg.getCommittee(id);
expect(c.groupAddress.toLowerCase()).to.equal(fresh.groupAddress.toLowerCase());
expect(c.threshold).to.equal(3);
expect(c.size).to.equal(5);
expect(c.epoch).to.equal(2n);
// OLD key no longer verifies; NEW key does.
const digest = ethers.keccak256(ethers.toUtf8Bytes("after rotation"));
const oldSig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[1]]);
const newSig = tss.thresholdSign(ethers.getBytes(digest), [fresh.shares[0], fresh.shares[1], fresh.shares[2]]);
expect(await reg.verifyThresholdSignature(id, digest, oldSig)).to.equal(false);
expect(await reg.verifyThresholdSignature(id, digest, newSig)).to.equal(true);
});
it("rejects rotate to the SAME key (must actually change)", async function () {
const { dkg, id } = await register(3, 2);
const newMembers = makeMembers(3);
const c0 = await reg.getCommittee(id);
const intent = rotateIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, dkg.groupPubKey64, newMembers, 2);
const authSig = tss.thresholdSign(ethers.getBytes(intent), [dkg.shares[0], dkg.shares[1]]);
await expect(reg.rotateKey(id, dkg.groupPubKey64, newMembers, 2, authSig)).to.be.revertedWith("key unchanged");
});
it("slashes a member via committee threshold signature, and marks the member", async function () {
const { dkg, members, id } = await register(3, 2);
const target = members[2];
const evidence = ethers.keccak256(ethers.toUtf8Bytes("equivocation proof blob"));
const c0 = await reg.getCommittee(id);
const intent = slashIntent(chainId, await reg.getAddress(), id, c0.epoch, c0.actionNonce, target, evidence);
const authSig = tss.thresholdSign(ethers.getBytes(intent), [dkg.shares[0], dkg.shares[1]]);
expect(await reg.slashed(id, target)).to.equal(false);
await expect(reg.slashMember(id, target, evidence, authSig))
.to.emit(reg, "MemberSlashed")
.withArgs(id, target, evidence, deployer.address);
expect(await reg.slashed(id, target)).to.equal(true);
// double slash blocked
await expect(reg.slashMember(id, target, evidence, authSig)).to.be.revertedWith("already slashed");
});
it("lets the guardian slash and pause/unpause; non-guardian cannot", async function () {
const { members, id } = await register(3, 2, guardian.address);
// guardian slash (no threshold sig needed)
const target = members[1];
await expect(reg.connect(guardian).slashMember(id, target, ethers.ZeroHash, "0x"))
.to.emit(reg, "MemberSlashed");
expect(await reg.slashed(id, target)).to.equal(true);
// guardian pause -> verification returns false even for a valid sig
await expect(reg.connect(guardian).pauseCommittee(id)).to.emit(reg, "CommitteePaused");
// outsider cannot unpause
await expect(reg.connect(outsider).unpauseCommittee(id)).to.be.revertedWith("not guardian");
await expect(reg.connect(guardian).unpauseCommittee(id)).to.emit(reg, "CommitteeUnpaused");
});
it("a PAUSED committee fails verification; ACTIVE again after unpause", async function () {
const { dkg, id } = await register(3, 2, guardian.address);
const digest = ethers.keccak256(ethers.toUtf8Bytes("x"));
const sig = tss.thresholdSign(ethers.getBytes(digest), [dkg.shares[0], dkg.shares[1]]);
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(true);
await reg.connect(guardian).pauseCommittee(id);
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(false);
await reg.connect(guardian).unpauseCommittee(id);
expect(await reg.verifyThresholdSignature(id, digest, sig)).to.equal(true);
});
it("rejects bad registration params (bad t, empty members, dup member, bad pubkey length)", async function () {
const dkg = tss.runDkg(3, 2);
const m = makeMembers(3);
await expect(reg.registerCommittee(m, 0, dkg.groupPubKey64, ethers.ZeroAddress)).to.be.revertedWith("bad t");
await expect(reg.registerCommittee(m, 4, dkg.groupPubKey64, ethers.ZeroAddress)).to.be.revertedWith("bad t");
await expect(reg.registerCommittee([], 1, dkg.groupPubKey64, ethers.ZeroAddress)).to.be.revertedWith("bad n");
const dup = [m[0], m[0], m[1]];
await expect(reg.registerCommittee(dup, 2, dkg.groupPubKey64, ethers.ZeroAddress)).to.be.revertedWith("dup member");
// 65-byte (with 0x04 prefix) pubkey is wrong -- contract wants raw 64-byte x||y
const bad65 = ethers.concat(["0x04", dkg.groupPubKey64]);
await expect(reg.registerCommittee(m, 2, bad65, ethers.ZeroAddress)).to.be.revertedWith("bad groupPubKey");
});
});