aere-contracts/test/compliance-pool.fuzz.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

450 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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