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.
450 lines
15 KiB
JavaScript
450 lines
15 KiB
JavaScript
// AereCompliancePool — property-based fuzz test
|
||
// 1500 randomized actions across 10 depositors + 4 CASPs + 5 challengers.
|
||
// Deterministic LCG seed so failures reproduce exactly.
|
||
//
|
||
// Invariants enforced after EVERY action:
|
||
// I1. TOKEN.balanceOf(pool) ==
|
||
// depositCount*DENOM - withdrawnTotal - bondsBurned + bondsLocked
|
||
// I2. nextLeafIndex == depositCount
|
||
// I3. spentNullifiers monotone non-decreasing (once true, stays true)
|
||
// I4. isKnownRoot(root) == true for every root recorded within the last
|
||
// ROOT_HISTORY_SIZE deposits
|
||
// I5. Each Deposited event leaf == keccak256(commitment, sender) and the
|
||
// contract-emitted newRoot matches an off-chain mirror tree
|
||
// I6. Challenge bond accounting: bondsLocked == (active challenges × BOND);
|
||
// bondsBurned increments by exactly BOND on every dismissal
|
||
// I7. associationRootDismissCount[r] <= MAX_DISMISSALS_PER_ROOT and a
|
||
// challenge on a maxed-out root reverts with MaxDismissalsReached
|
||
//
|
||
// CommonJS, ethers v6.
|
||
"use strict";
|
||
|
||
const { expect } = require("chai");
|
||
const { ethers } = require("hardhat");
|
||
|
||
// ---------- deterministic RNG (Mulberry32) -------------------------------
|
||
function mulberry32(seed) {
|
||
let s = seed >>> 0;
|
||
return function () {
|
||
s = (s + 0x6D2B79F5) >>> 0;
|
||
let t = s;
|
||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
// ---------- off-chain Merkle mirror (matches MerkleTreeWithHistory) -----
|
||
const TREE_DEPTH = 20;
|
||
const ROOT_HISTORY_SIZE = 256;
|
||
const ZERO_VALUE = ethers.keccak256(
|
||
ethers.toUtf8Bytes("aere.compliance-pool.v1.empty-leaf")
|
||
);
|
||
|
||
function buildMirror() {
|
||
const zeros = new Array(TREE_DEPTH);
|
||
const filled = new Array(TREE_DEPTH);
|
||
let z = ZERO_VALUE;
|
||
for (let i = 0; i < TREE_DEPTH; i++) {
|
||
zeros[i] = z;
|
||
filled[i] = z;
|
||
z = ethers.keccak256(ethers.concat([zeros[i], zeros[i]]));
|
||
}
|
||
return {
|
||
zeros,
|
||
filled,
|
||
nextLeafIndex: 0,
|
||
rootHistory: new Array(ROOT_HISTORY_SIZE).fill(ethers.ZeroHash),
|
||
currentRootIndex: 0,
|
||
knownRoots: new Set(), // for fast lookup; reflects rolling window
|
||
rootOrder: [], // chronological list of roots inserted
|
||
};
|
||
}
|
||
|
||
function mirrorInsert(m, leaf) {
|
||
const idx = m.nextLeafIndex;
|
||
m.nextLeafIndex = idx + 1;
|
||
let cur = idx;
|
||
let node = leaf;
|
||
for (let i = 0; i < TREE_DEPTH; i++) {
|
||
let left, right;
|
||
if ((cur & 1) === 0) {
|
||
left = node;
|
||
right = m.zeros[i];
|
||
m.filled[i] = node;
|
||
} else {
|
||
left = m.filled[i];
|
||
right = node;
|
||
}
|
||
node = ethers.keccak256(ethers.concat([left, right]));
|
||
cur >>= 1;
|
||
}
|
||
const newRootIdx = (m.currentRootIndex + 1) % ROOT_HISTORY_SIZE;
|
||
m.currentRootIndex = newRootIdx;
|
||
// evict
|
||
const evicted = m.rootHistory[newRootIdx];
|
||
if (evicted !== ethers.ZeroHash) m.knownRoots.delete(evicted);
|
||
m.rootHistory[newRootIdx] = node;
|
||
m.knownRoots.add(node);
|
||
m.rootOrder.push(node);
|
||
return { idx, newRoot: node };
|
||
}
|
||
|
||
// ---------- main test ---------------------------------------------------
|
||
|
||
describe("AereCompliancePool — fuzz (1500 actions)", function () {
|
||
this.timeout(600000);
|
||
|
||
const SEED = 0xA37E0001;
|
||
const DENOM = ethers.parseUnits("100", 18);
|
||
const BOND = ethers.parseEther("100");
|
||
const BURN = "0x000000000000000000000000000000000000dEaD";
|
||
const MAX_DISMISSALS = 2;
|
||
const CHALLENGE_WINDOW = 24 * 3600;
|
||
|
||
let rng;
|
||
let foundation, depositors, casps, challengers;
|
||
let token, verifier, pool, poolAddr, tokenAddr;
|
||
let mirror;
|
||
|
||
// off-chain tracking
|
||
let depositCount = 0n;
|
||
let withdrawnTotal = 0n;
|
||
let bondsLockedExpected = 0n;
|
||
let bondsBurnedExpected = 0n;
|
||
let usedNullifiers = new Set();
|
||
let prevSpentSnapshot = new Map(); // nullifierHash -> true
|
||
|
||
// association root lifecycle:
|
||
// "proposed" — proposed, not challenged, not published, window not elapsed
|
||
// "challenged" — challenge active, awaiting dismissal
|
||
// "publishable" — window elapsed, no active challenge
|
||
// "published" — final
|
||
// and dismissCount tracked.
|
||
let assocRoots = new Map(); // root -> { state, dismissCount, proposedAt, challenger }
|
||
|
||
// recorded deposit roots in order (used to pick known/unknown roots)
|
||
let depositRootHistory = [];
|
||
|
||
before(async () => {
|
||
rng = mulberry32(SEED);
|
||
const signers = await ethers.getSigners();
|
||
// 1 foundation + 10 depositors + 4 casps + 5 challengers = 20 signers
|
||
if (signers.length < 20) throw new Error("need >=20 hardhat accounts");
|
||
foundation = signers[0];
|
||
depositors = signers.slice(1, 11);
|
||
casps = signers.slice(11, 15);
|
||
challengers = signers.slice(15, 20);
|
||
|
||
const T = await ethers.getContractFactory("MockERC20Lending");
|
||
token = await T.deploy("USDC.e", "USDCe", 18);
|
||
await token.waitForDeployment();
|
||
tokenAddr = await token.getAddress();
|
||
|
||
const V = await ethers.getContractFactory("MockPrivacyPoolVerifier");
|
||
verifier = await V.deploy();
|
||
await verifier.waitForDeployment();
|
||
|
||
const P = await ethers.getContractFactory("AereCompliancePool");
|
||
pool = await P.deploy(tokenAddr, DENOM, foundation.address, await verifier.getAddress());
|
||
await pool.waitForDeployment();
|
||
poolAddr = await pool.getAddress();
|
||
|
||
// fund + approve depositors and challengers
|
||
const fat = DENOM * 1000n + BOND * 200n;
|
||
for (const a of [...depositors, ...challengers]) {
|
||
await token.mint(a.address, fat);
|
||
await token.connect(a).approve(poolAddr, ethers.MaxUint256);
|
||
}
|
||
|
||
// register all 4 CASPs
|
||
for (const c of casps) {
|
||
await pool.connect(foundation).setComplianceProvider(c.address, true);
|
||
}
|
||
|
||
mirror = buildMirror();
|
||
});
|
||
|
||
// ---------- helpers --------------------------------------------------
|
||
|
||
function pick(arr) {
|
||
return arr[Math.floor(rng() * arr.length)];
|
||
}
|
||
function pickInt(n) {
|
||
return Math.floor(rng() * n);
|
||
}
|
||
function randBytes32(tag) {
|
||
return ethers.keccak256(
|
||
ethers.toUtf8Bytes(`${tag}:${pickInt(1 << 30)}:${pickInt(1 << 30)}`)
|
||
);
|
||
}
|
||
|
||
function knownDepositRoot() {
|
||
// grab a root from the last ROOT_HISTORY_SIZE-1 inserts (to leave headroom)
|
||
if (depositRootHistory.length === 0) return null;
|
||
const startWindow = Math.max(0, depositRootHistory.length - (ROOT_HISTORY_SIZE - 1));
|
||
const i = startWindow + pickInt(depositRootHistory.length - startWindow);
|
||
return depositRootHistory[i];
|
||
}
|
||
|
||
function pickAssocByState(state) {
|
||
const matches = [];
|
||
for (const [r, info] of assocRoots) if (info.state === state) matches.push(r);
|
||
if (matches.length === 0) return null;
|
||
return matches[pickInt(matches.length)];
|
||
}
|
||
|
||
// ---------- actions --------------------------------------------------
|
||
|
||
async function actDeposit() {
|
||
const sender = pick(depositors);
|
||
const casp = pick(casps);
|
||
const commitment = randBytes32("commit");
|
||
const trh = randBytes32("trh");
|
||
const expectedLeaf = ethers.keccak256(
|
||
ethers.solidityPacked(["bytes32", "address"], [commitment, sender.address])
|
||
);
|
||
|
||
const tx = await pool
|
||
.connect(sender)
|
||
.deposit(commitment, trh, casp.address, "ipfs://tr");
|
||
const rc = await tx.wait();
|
||
|
||
// parse Deposited event
|
||
const ev = rc.logs.find((l) => {
|
||
try {
|
||
return pool.interface.parseLog(l)?.name === "Deposited";
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
const parsed = pool.interface.parseLog(ev);
|
||
const emittedLeafIndex = Number(parsed.args.leafIndex);
|
||
const emittedRoot = parsed.args.newRoot;
|
||
|
||
// mirror insert
|
||
const { idx, newRoot } = mirrorInsert(mirror, expectedLeaf);
|
||
expect(emittedLeafIndex).to.equal(idx);
|
||
expect(emittedRoot).to.equal(newRoot);
|
||
|
||
depositCount += 1n;
|
||
depositRootHistory.push(newRoot);
|
||
}
|
||
|
||
async function actPropose() {
|
||
const root = randBytes32("assoc");
|
||
if (assocRoots.has(root)) return;
|
||
await pool.connect(foundation).proposeAssociationRoot(root);
|
||
assocRoots.set(root, {
|
||
state: "proposed",
|
||
dismissCount: 0,
|
||
proposedAt: await currentTime(),
|
||
challenger: null,
|
||
});
|
||
}
|
||
|
||
async function actChallenge() {
|
||
const root = pickAssocByState("proposed");
|
||
if (!root) return;
|
||
const info = assocRoots.get(root);
|
||
const ch = pick(challengers);
|
||
|
||
if (info.dismissCount >= MAX_DISMISSALS) {
|
||
await expect(
|
||
pool.connect(ch).challengeAssociationRoot(root, "max-out")
|
||
).to.be.revertedWithCustomError(pool, "MaxDismissalsReached");
|
||
return;
|
||
}
|
||
await pool.connect(ch).challengeAssociationRoot(root, "fuzz-reason");
|
||
info.state = "challenged";
|
||
info.challenger = ch.address;
|
||
bondsLockedExpected += BOND;
|
||
}
|
||
|
||
async function actDismiss() {
|
||
const root = pickAssocByState("challenged");
|
||
if (!root) return;
|
||
const info = assocRoots.get(root);
|
||
await pool.connect(foundation).dismissAssociationRootChallenge(root);
|
||
bondsLockedExpected -= BOND;
|
||
bondsBurnedExpected += BOND;
|
||
info.dismissCount += 1;
|
||
info.challenger = null;
|
||
info.state = "proposed";
|
||
// R6 CRITICAL fix: dismissAssociationRootChallenge now RESETS the
|
||
// proposedAt timer so each dismissal genuinely costs 24h. Reflect that
|
||
// in the model so actPublish correctly waits the new window.
|
||
info.proposedAt = await currentTime();
|
||
}
|
||
|
||
async function actPublish() {
|
||
// need state proposed AND window elapsed AND not challenged
|
||
let candidate = null;
|
||
const now = await currentTime();
|
||
for (const [r, info] of assocRoots) {
|
||
if (info.state === "proposed" && now >= info.proposedAt + CHALLENGE_WINDOW) {
|
||
candidate = r;
|
||
break;
|
||
}
|
||
}
|
||
if (!candidate) return;
|
||
await pool.publishAssociationRoot(candidate);
|
||
assocRoots.get(candidate).state = "published";
|
||
}
|
||
|
||
async function actWithdraw() {
|
||
if (depositCount === 0n) return;
|
||
const publishedRoot = pickAssocByState("published");
|
||
if (!publishedRoot) return;
|
||
const depRoot = knownDepositRoot();
|
||
if (!depRoot) return;
|
||
|
||
// unique nullifier
|
||
let nh;
|
||
do {
|
||
nh = randBytes32("nh");
|
||
} while (usedNullifiers.has(nh));
|
||
usedNullifiers.add(nh);
|
||
|
||
const recipient = pick(depositors).address;
|
||
const feeRecipient = pick(depositors).address;
|
||
const fee = 0n;
|
||
const refund = 0n;
|
||
|
||
await pool
|
||
.connect(pick(depositors))
|
||
.withdraw("0x", depRoot, publishedRoot, nh, recipient, feeRecipient, fee, refund);
|
||
|
||
withdrawnTotal += DENOM - fee - refund;
|
||
prevSpentSnapshot.set(nh, true);
|
||
}
|
||
|
||
async function actSync() {
|
||
// jump forward 1-3h
|
||
const dt = 3600 + pickInt(2 * 3600);
|
||
await ethers.provider.send("evm_increaseTime", [dt]);
|
||
await ethers.provider.send("evm_mine", []);
|
||
}
|
||
|
||
async function currentTime() {
|
||
const b = await ethers.provider.getBlock("latest");
|
||
return b.timestamp;
|
||
}
|
||
|
||
// ---------- invariants ----------------------------------------------
|
||
|
||
async function checkInvariants(stepLabel) {
|
||
// I1: pool token balance
|
||
const bal = await token.balanceOf(poolAddr);
|
||
const expectedBal =
|
||
depositCount * DENOM - withdrawnTotal + bondsLockedExpected;
|
||
// bondsBurnedExpected was transferred OUT to dead address, so already not in pool.
|
||
expect(bal, `I1 bal @ ${stepLabel}`).to.equal(expectedBal);
|
||
|
||
// burn-sink balance reflects bondsBurnedExpected
|
||
const burnBal = await token.balanceOf(BURN);
|
||
expect(burnBal, `I1b burn @ ${stepLabel}`).to.equal(bondsBurnedExpected);
|
||
|
||
// I2: leaf index == deposit count
|
||
expect(await pool.nextLeafIndex(), `I2 @ ${stepLabel}`).to.equal(
|
||
Number(depositCount)
|
||
);
|
||
expect(await pool.depositCount(), `I2b @ ${stepLabel}`).to.equal(depositCount);
|
||
|
||
// I3: spent nullifiers monotone — every previously-spent nullifier still spent
|
||
for (const nh of prevSpentSnapshot.keys()) {
|
||
expect(await pool.spentNullifiers(nh), `I3 @ ${stepLabel}`).to.equal(true);
|
||
}
|
||
|
||
// I7: dismiss count bounded
|
||
for (const [r, info] of assocRoots) {
|
||
expect(await pool.associationRootDismissCount(r), `I7 @ ${stepLabel}`).to.equal(
|
||
info.dismissCount
|
||
);
|
||
expect(info.dismissCount).to.be.at.most(MAX_DISMISSALS);
|
||
}
|
||
}
|
||
|
||
async function checkKnownRootSample() {
|
||
// I4: sample up to 8 roots within the last ROOT_HISTORY_SIZE window
|
||
if (depositRootHistory.length === 0) return;
|
||
const tail = depositRootHistory.slice(-ROOT_HISTORY_SIZE);
|
||
const sampleSize = Math.min(8, tail.length);
|
||
for (let i = 0; i < sampleSize; i++) {
|
||
const r = tail[pickInt(tail.length)];
|
||
expect(await pool.isKnownRoot(r), "I4 known root").to.equal(true);
|
||
}
|
||
// also: a root we know is far older than the window (if we have one)
|
||
// should NOT be known anymore.
|
||
if (depositRootHistory.length > ROOT_HISTORY_SIZE + 4) {
|
||
const stale =
|
||
depositRootHistory[depositRootHistory.length - ROOT_HISTORY_SIZE - 2];
|
||
expect(await pool.isKnownRoot(stale), "I4b stale evicted").to.equal(false);
|
||
}
|
||
}
|
||
|
||
it("survives 1500 randomized actions with all invariants intact", async () => {
|
||
const N = 1500;
|
||
// action weights — favor deposits and assoc-root lifecycle to keep the
|
||
// system productive but exercise every code path.
|
||
const weights = [
|
||
["deposit", 35, actDeposit],
|
||
["propose", 14, actPropose],
|
||
["challenge", 12, actChallenge],
|
||
["dismiss", 8, actDismiss],
|
||
["publish", 10, actPublish],
|
||
["withdraw", 11, actWithdraw],
|
||
["sync", 10, actSync],
|
||
];
|
||
const totalW = weights.reduce((s, w) => s + w[1], 0);
|
||
|
||
let invariantSpotChecks = 0;
|
||
|
||
for (let step = 0; step < N; step++) {
|
||
const r = rng() * totalW;
|
||
let acc = 0;
|
||
let chosen = weights[0];
|
||
for (const w of weights) {
|
||
acc += w[1];
|
||
if (r < acc) {
|
||
chosen = w;
|
||
break;
|
||
}
|
||
}
|
||
const [name, , fn] = chosen;
|
||
try {
|
||
await fn();
|
||
} catch (e) {
|
||
// Some actions are inherently "noop if no candidate"; failures here
|
||
// are real — re-throw with context.
|
||
throw new Error(`action ${name} @ step ${step}: ${e.message}`);
|
||
}
|
||
|
||
// Fast-path invariants every step (cheap). Heavy checks every 50 steps.
|
||
await checkInvariants(`${name}#${step}`);
|
||
if (step % 50 === 0) {
|
||
await checkKnownRootSample();
|
||
invariantSpotChecks++;
|
||
}
|
||
}
|
||
|
||
// final sanity: a fresh challenge against a max-dismissed root must revert
|
||
let maxedRoot = null;
|
||
for (const [r, info] of assocRoots) {
|
||
if (info.dismissCount >= MAX_DISMISSALS && info.state === "proposed") {
|
||
maxedRoot = r;
|
||
break;
|
||
}
|
||
}
|
||
if (maxedRoot) {
|
||
await expect(
|
||
pool.connect(pick(challengers)).challengeAssociationRoot(maxedRoot, "post")
|
||
).to.be.revertedWithCustomError(pool, "MaxDismissalsReached");
|
||
}
|
||
|
||
// ensure we actually exercised deposits + at least one withdrawal
|
||
expect(depositCount).to.be.greaterThan(50n);
|
||
expect(invariantSpotChecks).to.be.greaterThan(10);
|
||
});
|
||
});
|