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.
788 lines
40 KiB
JavaScript
788 lines
40 KiB
JavaScript
// =============================================================================
|
|
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the AereProof v0
|
|
// COMPLIANCE PRIMITIVES (the AERE on-chain compliance gate stack):
|
|
//
|
|
// * AereSanctionsRegistry (live 0xb7d235718D99560F6EA4Fc5eAea2F8a306A3Cacf)
|
|
// - optimistic Merkle-root sanctions screen (propose -> challenge ->
|
|
// attest); isSanctioned(epoch, addr, listId, proof) is the GATE.
|
|
// * AereAttestationGateway (live 0x9bdacA8dfF39Fc688e8D3c4bbA13bCFC0580c325)
|
|
// - schema registry + per-schema authorised attestors + inheritance;
|
|
// subjectCurrentlyAttested / isValidNow are the "cleared status" gate.
|
|
// * AereTravelRuleHashRegistry (live 0xcF0E2e010E6e4506672019b1e570874AEeBC4c84)
|
|
// - FATF Travel-Rule commit/acknowledge/dispute anchor.
|
|
//
|
|
// Source under test (matched to the live addresses via sdk-js/src/addresses.ts):
|
|
// contracts/compliance/AereSanctionsRegistry.sol
|
|
// contracts/compliance/AereAttestationGateway.sol
|
|
// contracts/compliance/AereTravelRuleHashRegistry.sol
|
|
//
|
|
// TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (NOT Foundry;
|
|
// `forge` is not installed, repo builds through hardhat + OZ 4.9.6 + viaIR).
|
|
// Every random choice comes from mulberry32(seed); the base seed is printed at
|
|
// suite start and pinnable via FUZZ_SEED. On any break the failing
|
|
// (campaign, step, actor, action, values) is embedded in the assertion message
|
|
// for a minimal repro. Mirrors test/saere-4626-invariant-property.test.js and
|
|
// test/spokepool-intents-invariant-property.test.js.
|
|
//
|
|
// SCOPE: run this file in ISOLATION (the full suite OOMs on the unrelated
|
|
// ML-DSA PQC KAT tests):
|
|
// npx hardhat test test/aereproof-compliance-invariant-property.test.js
|
|
// No deploy to any live node, no contract-logic change, one new test file only.
|
|
//
|
|
// INVARIANTS FUZZED (exactly the AereProof v0 compliance brief):
|
|
// GATE INTEGRITY
|
|
// * isSanctioned returns true ONLY for an address that is genuinely in the
|
|
// ATTESTED epoch's tree, proven by a VALID Merkle inclusion proof. It is
|
|
// false for a non-member, for a stale (Proposed / Challenged, not yet
|
|
// Attested) root, for a swapped root (valid proof against the wrong
|
|
// epoch), and for a malformed / corrupted proof.
|
|
// * subjectCurrentlyAttested is true ONLY when an authorised attestor has
|
|
// issued a non-revoked, in-window attestation whose whole ancestor chain
|
|
// is valid. A never-cleared subject is always false (forge resistance).
|
|
// AUTHORISATION
|
|
// * only owner may proposeSnapshot / dismissChallenge; only Foundation may
|
|
// publishSchema / setAttestor; only an authorised attestor may attest;
|
|
// only the issuer may revoke; only the beneficiary VASP may ack / dispute.
|
|
// A third party is rejected on every one of these.
|
|
// NON-BYPASS / REPLAY
|
|
// * a sanctioned address cannot escape the gate via a stale, swapped, or
|
|
// malformed proof; attested roots are append-only (never rewritten, no
|
|
// de-sanction path); attestation ids are nonce-derived and unique (no id
|
|
// collision / replay); a revoked attestation can never be un-revoked.
|
|
// FORGE RESISTANCE
|
|
// * a cleared status can never be forged for a subject that was never
|
|
// actually cleared by an authorised attestor.
|
|
//
|
|
// The AereSanctionsRegistry Merkle scheme is OpenZeppelin MerkleProof (sorted
|
|
// commutative keccak pairs) over leaf = keccak256(abi.encode(address,uint16));
|
|
// the JS tree/proof builder below is byte-compatible with it. No zk verifier is
|
|
// involved in these three contracts, so nothing is mocked here.
|
|
//
|
|
// No em-dashes anywhere in this file.
|
|
// =============================================================================
|
|
|
|
const { expect } = require("chai");
|
|
const { ethers, network } = require("hardhat");
|
|
const { time } = require("@nomicfoundation/hardhat-network-helpers");
|
|
|
|
/* ------------------------------- seeded PRNG ------------------------------ */
|
|
function mulberry32(a) {
|
|
return function () {
|
|
a |= 0;
|
|
a = (a + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0xC0FFEE12;
|
|
function rngFor(tag, campaign) {
|
|
let h = BASE_SEED ^ (campaign * 0x9e3779b1);
|
|
for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0;
|
|
return mulberry32(h >>> 0);
|
|
}
|
|
const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive
|
|
const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)];
|
|
const chance = (rng, p) => rng() < p;
|
|
|
|
/* ------------------------------- misc helpers ----------------------------- */
|
|
const ZERO32 = ethers.ZeroHash;
|
|
const abi = ethers.AbiCoder.defaultAbiCoder();
|
|
async function latestTs() {
|
|
return Number((await ethers.provider.getBlock("latest")).timestamp);
|
|
}
|
|
function randHex32(rng) {
|
|
let s = "0x";
|
|
for (let i = 0; i < 64; i++) s += "0123456789abcdef"[Math.floor(rng() * 16)];
|
|
return s;
|
|
}
|
|
function randAddr(rng) {
|
|
let s = "0x";
|
|
for (let i = 0; i < 40; i++) s += "0123456789abcdef"[Math.floor(rng() * 16)];
|
|
return ethers.getAddress(s);
|
|
}
|
|
|
|
/* --------------- OZ-MerkleProof-compatible tree (sorted pairs) ------------- */
|
|
// leaf = keccak256(abi.encode(address, uint16))
|
|
function sanctionLeaf(addr, listId) {
|
|
return ethers.keccak256(abi.encode(["address", "uint16"], [addr, listId]));
|
|
}
|
|
// commutative parent hash, matching OZ MerkleProof._hashPair (sort the 32-byte words)
|
|
function hashPair(a, b) {
|
|
const [lo, hi] = BigInt(a) <= BigInt(b) ? [a, b] : [b, a];
|
|
return ethers.keccak256(ethers.concat([lo, hi]));
|
|
}
|
|
// Build layers bottom-up; odd node is promoted (OZ-compatible folding).
|
|
function buildTree(leaves) {
|
|
if (leaves.length === 0) return { root: ZERO32, layers: [[]] };
|
|
const layers = [leaves.slice()];
|
|
while (layers[layers.length - 1].length > 1) {
|
|
const cur = layers[layers.length - 1];
|
|
const next = [];
|
|
for (let i = 0; i < cur.length; i += 2) {
|
|
if (i + 1 < cur.length) next.push(hashPair(cur[i], cur[i + 1]));
|
|
else next.push(cur[i]); // promote unpaired
|
|
}
|
|
layers.push(next);
|
|
}
|
|
return { root: layers[layers.length - 1][0], layers };
|
|
}
|
|
// Bottom-up sibling proof for the leaf at `index` (empty for a single-leaf tree).
|
|
function getProof(tree, index) {
|
|
const proof = [];
|
|
let idx = index;
|
|
for (let l = 0; l < tree.layers.length - 1; l++) {
|
|
const layer = tree.layers[l];
|
|
const pair = idx ^ 1;
|
|
if (pair < layer.length) proof.push(layer[pair]);
|
|
idx = idx >> 1;
|
|
}
|
|
return proof;
|
|
}
|
|
// Pure-JS mirror of MerkleProof.verify for self-checking the builder.
|
|
function verifyProof(proof, root, leaf) {
|
|
let h = leaf;
|
|
for (const p of proof) h = hashPair(h, p);
|
|
return h === root;
|
|
}
|
|
|
|
// Iteration counts (env-overridable for a quick smoke).
|
|
const N = {
|
|
SANCT_CAMPAIGNS: Number(process.env.CMPL_SANCT_CAMPAIGNS || 6),
|
|
SANCT_STEPS: Number(process.env.CMPL_SANCT_STEPS || 45),
|
|
ATT_CAMPAIGNS: Number(process.env.CMPL_ATT_CAMPAIGNS || 6),
|
|
ATT_STEPS: Number(process.env.CMPL_ATT_STEPS || 55),
|
|
TR_CAMPAIGNS: Number(process.env.CMPL_TR_CAMPAIGNS || 4),
|
|
TR_STEPS: Number(process.env.CMPL_TR_STEPS || 45),
|
|
};
|
|
|
|
// =============================================================================
|
|
describe("INVARIANT: AereProof v0 compliance primitives (sanctions / attestation / travel-rule)", function () {
|
|
this.timeout(0);
|
|
|
|
let signers;
|
|
before(async function () {
|
|
signers = await ethers.getSigners();
|
|
console.log(
|
|
` [aereproof] base seed 0x${BASE_SEED.toString(16)} | ` +
|
|
`sanctions ${N.SANCT_CAMPAIGNS}x${N.SANCT_STEPS} | ` +
|
|
`attestation ${N.ATT_CAMPAIGNS}x${N.ATT_STEPS} | ` +
|
|
`travel-rule ${N.TR_CAMPAIGNS}x${N.TR_STEPS}`
|
|
);
|
|
// self-test the OZ-compatible tree builder against a tiny fixed vector so a
|
|
// broken builder can never mask a real gate finding.
|
|
const ls = [0, 1, 2, 3, 4].map((i) => sanctionLeaf(signers[i].address, 1));
|
|
const t = buildTree(ls);
|
|
for (let i = 0; i < ls.length; i++) {
|
|
expect(verifyProof(getProof(t, i), t.root, ls[i]), `builder self-test leaf ${i}`).to.equal(true);
|
|
}
|
|
expect(verifyProof(getProof(t, 0), t.root, sanctionLeaf(signers[6].address, 1)), "builder self-test non-member")
|
|
.to.equal(false);
|
|
});
|
|
|
|
// ==========================================================================
|
|
// CAMPAIGN S: AereSanctionsRegistry gate integrity + soundness + authorisation
|
|
// ==========================================================================
|
|
it("sanctions registry: gate integrity, proof soundness, stale/swapped/malformed rejection, authorisation, append-only", async function () {
|
|
let steps = 0, proposes = 0, challenges = 0, dismisses = 0, attests = 0;
|
|
let queriesTrue = 0, queriesFalse = 0, staleRejected = 0, swappedRejected = 0, malformedRejected = 0;
|
|
let authRejected = 0, appendOnlyRejected = 0;
|
|
|
|
for (let c = 0; c < N.SANCT_CAMPAIGNS; c++) {
|
|
const rng = rngFor("sanct", c);
|
|
const owner = signers[0];
|
|
const F = await ethers.getContractFactory("AereSanctionsRegistry");
|
|
const reg = await F.connect(owner).deploy();
|
|
await reg.waitForDeployment();
|
|
expect(await reg.owner(), `[SANCT OWNER] c${c}`).to.equal(owner.address);
|
|
|
|
const nonOwners = [signers[1], signers[2], signers[3]];
|
|
const LIST = 1; // LIST_OFAC_SDN
|
|
|
|
// JS model: epoch -> { root, state, tree, members[], proposedAt, openChallenges, attested }
|
|
const model = {};
|
|
let nextEpoch = 1;
|
|
// a stable universe of "possibly sanctioned" addresses + a disjoint set of
|
|
// definitely-clean addresses used for the soundness (false) checks.
|
|
const universe = [];
|
|
for (let i = 0; i < 40; i++) universe.push(randAddr(rng));
|
|
const clean = [];
|
|
for (let i = 0; i < 12; i++) clean.push(randAddr(rng));
|
|
|
|
async function assertGate(ctx) {
|
|
// For every attested epoch, EVERY member proves true and EVERY clean
|
|
// address (and a swapped-root / malformed proof) proves false.
|
|
for (const epStr of Object.keys(model)) {
|
|
const ep = Number(epStr);
|
|
const m = model[ep];
|
|
|
|
// on-chain state matches the model.
|
|
const st = await reg.snapshotStatus(ep);
|
|
const chainState = Number(st[3]); // 0 Proposed, 1 Challenged, 2 Attested
|
|
expect(chainState, `[SANCT STATE] ${ctx} ep${ep} chain=${chainState} model=${m.state}`).to.equal(m.state);
|
|
expect(st[0], `[SANCT ROOT-IMMUT] ${ctx} ep${ep} root drifted`).to.equal(m.root);
|
|
|
|
if (m.members.length === 0) continue;
|
|
const sampleMembers = m.members.filter((_, i) => i % 3 === 0 || m.members.length <= 3);
|
|
|
|
for (const mem of sampleMembers) {
|
|
const idx = m.members.indexOf(mem);
|
|
const proof = getProof(m.tree, idx);
|
|
const leaf = sanctionLeaf(mem, LIST);
|
|
expect(verifyProof(proof, m.tree.root, leaf), `[SANCT BUILDER] ${ctx} ep${ep}`).to.equal(true);
|
|
const onchain = await reg.isSanctioned(ep, mem, LIST, proof);
|
|
|
|
if (m.state === 2 /* Attested */) {
|
|
// GATE INTEGRITY: attested member with a valid proof -> sanctioned.
|
|
expect(onchain, `[SANCT GATE-TRUE] ${ctx} ep${ep} member=${mem.slice(0, 10)} valid proof not honoured`)
|
|
.to.equal(true);
|
|
queriesTrue++;
|
|
} else {
|
|
// STALE ROOT: a not-yet-attested (Proposed/Challenged) root can
|
|
// NEVER be used to assert sanctioned status, even with a real proof.
|
|
expect(onchain, `[SANCT STALE] ${ctx} ep${ep} state=${m.state} stale root honoured for ${mem.slice(0, 10)}`)
|
|
.to.equal(false);
|
|
staleRejected++;
|
|
}
|
|
}
|
|
|
|
if (m.state !== 2) continue;
|
|
|
|
// SOUNDNESS: a genuinely clean address can never prove membership.
|
|
for (const cl of clean.slice(0, 4)) {
|
|
// (a) empty proof
|
|
expect(await reg.isSanctioned(ep, cl, LIST, []), `[SANCT SOUND-EMPTY] ${ctx} ep${ep} clean=${cl.slice(0, 10)}`)
|
|
.to.equal(false);
|
|
// (b) malformed random proof
|
|
const junk = [randHex32(rng), randHex32(rng)];
|
|
expect(await reg.isSanctioned(ep, cl, LIST, junk), `[SANCT SOUND-JUNK] ${ctx} ep${ep} clean=${cl.slice(0, 10)}`)
|
|
.to.equal(false);
|
|
malformedRejected++;
|
|
}
|
|
// (c) borrow a real member's valid proof but query a DIFFERENT clean
|
|
// address: the folded leaf differs so it must not verify.
|
|
if (m.members.length > 0) {
|
|
const stolenProof = getProof(m.tree, 0);
|
|
expect(await reg.isSanctioned(ep, clean[0], LIST, stolenProof), `[SANCT SOUND-STOLEN] ${ctx} ep${ep}`)
|
|
.to.equal(false);
|
|
}
|
|
// (d) wrong listId for a real member: leaf changes, proof no longer valid.
|
|
if (m.members.length > 0) {
|
|
const mem0 = m.members[0];
|
|
const p0 = getProof(m.tree, 0);
|
|
expect(await reg.isSanctioned(ep, mem0, 2 /* LIST_OFAC_SSI */, p0), `[SANCT WRONG-LIST] ${ctx} ep${ep}`)
|
|
.to.equal(false);
|
|
}
|
|
}
|
|
|
|
// SWAPPED ROOT: a valid proof for a member of attested epoch X must NOT
|
|
// verify against a different attested epoch Y whose tree differs.
|
|
const attestedEpochs = Object.keys(model).map(Number).filter((e) => model[e].state === 2 && model[e].members.length > 0);
|
|
if (attestedEpochs.length >= 2) {
|
|
const ex = attestedEpochs[0], ey = attestedEpochs[attestedEpochs.length - 1];
|
|
if (model[ex].root !== model[ey].root) {
|
|
const memX = model[ex].members[0];
|
|
const proofX = getProof(model[ex].tree, 0);
|
|
// memX genuinely in X:
|
|
expect(await reg.isSanctioned(ex, memX, LIST, proofX), `[SANCT SWAP-SELF] ${ctx}`).to.equal(true);
|
|
// same (member, proof) against Y's root: only true if memX also happens
|
|
// to be in Y (disjoint sets make that essentially impossible). Assert it
|
|
// equals the honest membership of memX in Y under a VALID Y-proof.
|
|
const inY = model[ey].members.includes(memX);
|
|
const swappedRes = await reg.isSanctioned(ey, memX, LIST, proofX);
|
|
if (!inY) {
|
|
expect(swappedRes, `[SANCT SWAP] ${ctx} X=${ex} Y=${ey} proof-for-X honoured under Y root`).to.equal(false);
|
|
swappedRejected++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await assertGate(`c${c} init`);
|
|
|
|
for (let s = 0; s < N.SANCT_STEPS; s++) {
|
|
const action = pick(rng, [
|
|
"propose", "propose", "challenge", "dismiss", "attest", "attest",
|
|
"advance", "authcheck", "readonlyprobe",
|
|
]);
|
|
const ctx = `c${c} s${s} action=${action}`;
|
|
|
|
try {
|
|
if (action === "propose") {
|
|
const ep = nextEpoch++;
|
|
// random distinct membership set from the universe.
|
|
const k = ri(rng, 1, 8);
|
|
const members = [];
|
|
const shuffled = universe.slice().sort(() => rng() - 0.5);
|
|
for (let i = 0; i < k; i++) members.push(shuffled[i]);
|
|
const leaves = members.map((a) => sanctionLeaf(a, LIST));
|
|
const tree = buildTree(leaves);
|
|
await (await reg.connect(owner).proposeSnapshot(ep, tree.root, "https://ofac/sdn.txt", "ipfs://cid")).wait();
|
|
model[ep] = { root: tree.root, state: 0, tree, members, proposedAt: await latestTs(), open: 0 };
|
|
proposes++;
|
|
// AUTHORISATION: a non-owner cannot propose (a fresh epoch id).
|
|
await expect(
|
|
reg.connect(pick(rng, nonOwners)).proposeSnapshot(nextEpoch + 500, tree.root, "x", "y"),
|
|
`[SANCT AUTH-PROPOSE] ${ctx} non-owner proposed`
|
|
).to.be.reverted;
|
|
authRejected++;
|
|
// APPEND-ONLY: re-proposing an existing epoch reverts.
|
|
await expect(
|
|
reg.connect(owner).proposeSnapshot(ep, randHex32(rng), "x", "y"),
|
|
`[SANCT APPEND-ONLY] ${ctx} epoch overwrite allowed`
|
|
).to.be.revertedWithCustomError(reg, "EpochAlreadyExists");
|
|
appendOnlyRejected++;
|
|
} else if (action === "challenge") {
|
|
const cand = Object.keys(model).map(Number).filter((e) => (model[e].state === 0 || model[e].state === 1));
|
|
if (cand.length === 0) { steps++; continue; }
|
|
const ep = pick(rng, cand);
|
|
const withinWindow = (await latestTs()) < model[ep].proposedAt + 24 * 3600;
|
|
if (!withinWindow) { steps++; continue; }
|
|
await (await reg.connect(pick(rng, nonOwners)).challenge(ep, "mismatch vs OFAC")).wait();
|
|
model[ep].state = 1; model[ep].open++;
|
|
challenges++;
|
|
} else if (action === "dismiss") {
|
|
const cand = Object.keys(model).map(Number).filter((e) => model[e].open > 0);
|
|
if (cand.length === 0) { steps++; continue; }
|
|
const ep = pick(rng, cand);
|
|
// AUTHORISATION: only owner dismisses.
|
|
await expect(
|
|
reg.connect(pick(rng, nonOwners)).dismissChallenge(ep, 0, "no"),
|
|
`[SANCT AUTH-DISMISS] ${ctx}`
|
|
).to.be.reverted;
|
|
authRejected++;
|
|
// dismiss the lowest still-open challenge id.
|
|
const list = model[ep];
|
|
// find an un-dismissed challengeId in the model by scanning chain.
|
|
let cid = -1;
|
|
const chLen = Number((await reg.snapshotStatus(ep))[4]); // openChallengeCount
|
|
if (chLen > 0) {
|
|
// dismiss id 0..n until one succeeds
|
|
for (let j = 0; j < 8; j++) {
|
|
try {
|
|
await (await reg.connect(owner).dismissChallenge(ep, j, "resolved")).wait();
|
|
cid = j; break;
|
|
} catch (_) { /* already dismissed / unknown */ }
|
|
}
|
|
}
|
|
if (cid >= 0) {
|
|
list.open--;
|
|
if (list.open === 0) list.state = 0; // back to Proposed
|
|
dismisses++;
|
|
}
|
|
} else if (action === "attest") {
|
|
const cand = Object.keys(model).map(Number).filter((e) => model[e].state === 0);
|
|
if (cand.length === 0) { steps++; continue; }
|
|
const ep = pick(rng, cand);
|
|
const earliest = model[ep].proposedAt + 24 * 3600;
|
|
const now = await latestTs();
|
|
if (now < earliest) {
|
|
// window not elapsed -> attest must revert (guard proof).
|
|
await expect(reg.connect(pick(rng, nonOwners)).attest(ep), `[SANCT ATTEST-EARLY] ${ctx}`).to.be.reverted;
|
|
steps++; continue;
|
|
}
|
|
if (model[ep].open !== 0) { steps++; continue; }
|
|
// attest is permissionless (any address may finalise an eligible epoch).
|
|
await (await reg.connect(pick(rng, [owner, ...nonOwners])).attest(ep)).wait();
|
|
model[ep].state = 2;
|
|
attests++;
|
|
} else if (action === "advance") {
|
|
await time.increase(ri(rng, 1, 30 * 3600)); // up to ~30h (crosses the 24h window)
|
|
await network.provider.send("evm_mine");
|
|
} else if (action === "authcheck") {
|
|
// extra authorisation probes on random targets.
|
|
await expect(reg.connect(pick(rng, nonOwners)).proposeSnapshot(90000 + s, randHex32(rng), "x", "y"),
|
|
`[SANCT AUTH-PROBE] ${ctx}`).to.be.reverted;
|
|
authRejected++;
|
|
} else if (action === "readonlyprobe") {
|
|
// isSanctionedLatest is consistent with the newest attested epoch.
|
|
const attested = Object.keys(model).map(Number).filter((e) => model[e].state === 2 && model[e].members.length > 0);
|
|
if (attested.length > 0) {
|
|
const newest = Math.max(...attested);
|
|
const mem = model[newest].members[0];
|
|
const proof = getProof(model[newest].tree, 0);
|
|
const [res, ep] = await reg.isSanctionedLatest(mem, LIST, proof);
|
|
// it returns against the newest attested epoch; if mem is in it -> true.
|
|
const expected = model[newest].members.includes(mem);
|
|
expect(res, `[SANCT LATEST] ${ctx} newest=${newest} ep=${ep}`).to.equal(expected);
|
|
queriesFalse += expected ? 0 : 1;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (/SANCT [A-Z]|INVARIANT/.test(String(e.message))) throw e;
|
|
// otherwise a legal revert on an edge transition; state unchanged.
|
|
}
|
|
|
|
await assertGate(ctx);
|
|
steps++;
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
` [sanct] steps=${steps} propose=${proposes} challenge=${challenges} dismiss=${dismisses} attest=${attests} ` +
|
|
`gateTrue=${queriesTrue} stale-rej=${staleRejected} swap-rej=${swappedRejected} junk-rej=${malformedRejected} ` +
|
|
`auth-rej=${authRejected} appendonly-rej=${appendOnlyRejected}`
|
|
);
|
|
expect(steps).to.be.greaterThan(200);
|
|
expect(proposes).to.be.greaterThan(10);
|
|
expect(attests).to.be.greaterThan(3);
|
|
expect(queriesTrue).to.be.greaterThan(3);
|
|
expect(malformedRejected).to.be.greaterThan(3);
|
|
expect(authRejected).to.be.greaterThan(10);
|
|
});
|
|
|
|
// ==========================================================================
|
|
// CAMPAIGN A: AereAttestationGateway forge-resistance + authorisation + replay
|
|
// ==========================================================================
|
|
it("attestation gateway: forge resistance, authorisation, nonce-unique ids, revocation permanence, inheritance validity", async function () {
|
|
const MAX_DEPTH = 8;
|
|
let steps = 0, schemas = 0, authToggles = 0, attestsOk = 0, attestRej = 0;
|
|
let revokes = 0, revokeRej = 0, idChecks = 0, forgeRej = 0, inheritRej = 0;
|
|
|
|
for (let c = 0; c < N.ATT_CAMPAIGNS; c++) {
|
|
const rng = rngFor("att", c);
|
|
const foundation = signers[0];
|
|
const F = await ethers.getContractFactory("AereAttestationGateway");
|
|
const gw = await F.connect(foundation).deploy(foundation.address);
|
|
await gw.waitForDeployment();
|
|
|
|
const attestors = [signers[1], signers[2], signers[3]]; // may be authorised
|
|
const outsiders = [signers[4], signers[5]]; // never authorised
|
|
const subjects = [];
|
|
for (let i = 0; i < 6; i++) subjects.push(ethers.keccak256(ethers.toUtf8Bytes(`subject:${c}:${i}`)));
|
|
|
|
// JS model.
|
|
const schemaIds = [];
|
|
const schemaSet = {}; // schemaId -> true
|
|
const authModel = {}; // schemaId -> { attestorAddr -> bool }
|
|
const atts = {}; // id -> {attestor, subject, schemaId, parentId, validFrom, validUntil, revoked, exists}
|
|
const latestFor = {}; // `${subject}|${schemaId}` -> id
|
|
const nonces = {}; // attestorAddr -> next nonce
|
|
for (const a of [...attestors, ...outsiders]) nonces[a.address] = 0;
|
|
const allIds = new Set();
|
|
|
|
function key(sub, sch) { return `${sub}|${sch}`; }
|
|
function isValidModel(id, nowTs, depth) {
|
|
if (depth >= MAX_DEPTH) return false;
|
|
const a = atts[id];
|
|
if (!a || !a.exists) return false;
|
|
if (a.revoked) return false;
|
|
if (nowTs < a.validFrom) return false;
|
|
if (a.validUntil !== 0 && nowTs > a.validUntil) return false;
|
|
if (a.parentId === ZERO32) return true;
|
|
return isValidModel(a.parentId, nowTs, depth + 1);
|
|
}
|
|
function predictId(attestorAddr, nonce) {
|
|
return ethers.keccak256(abi.encode(["address", "uint256"], [attestorAddr, nonce]));
|
|
}
|
|
|
|
async function assertAtt(ctx) {
|
|
const now = await latestTs();
|
|
// isValidNow matches the model for every known id.
|
|
for (const id of Object.keys(atts)) {
|
|
const chain = await gw.isValidNow(id);
|
|
const model = isValidModel(id, now, 0);
|
|
expect(chain, `[ATT VALIDNOW] ${ctx} id=${id.slice(0, 12)} chain=${chain} model=${model}`).to.equal(model);
|
|
}
|
|
// subjectCurrentlyAttested matches latestFor + validity (forge resistance).
|
|
for (const sub of subjects) {
|
|
for (const sch of schemaIds) {
|
|
const chain = await gw.subjectCurrentlyAttested(sub, sch);
|
|
const id = latestFor[key(sub, sch)];
|
|
const model = id ? isValidModel(id, now, 0) : false;
|
|
expect(chain, `[ATT FORGE] ${ctx} sub=${sub.slice(0, 10)} sch=${sch.slice(0, 10)} chain=${chain} model=${model}`)
|
|
.to.equal(model);
|
|
}
|
|
}
|
|
}
|
|
|
|
// FORGE-RESISTANCE baseline: with no schema / no attestation, nothing is
|
|
// ever "currently attested".
|
|
await assertAtt(`c${c} init`);
|
|
|
|
for (let s = 0; s < N.ATT_STEPS; s++) {
|
|
const action = pick(rng, [
|
|
"publishSchema", "setAttestor", "setAttestor",
|
|
"attest", "attest", "attest", "attestChild",
|
|
"attestUnauth", "revoke", "revokeUnauth", "advance", "idcheck",
|
|
]);
|
|
const ctx = `c${c} s${s} action=${action}`;
|
|
|
|
try {
|
|
if (action === "publishSchema") {
|
|
const sid = ethers.keccak256(ethers.toUtf8Bytes(`schema:${c}:${schemaIds.length}:${s}`));
|
|
const specHash = ethers.keccak256(ethers.toUtf8Bytes(`spec:${sid}`));
|
|
// AUTHORISATION: only Foundation may publish.
|
|
await expect(
|
|
gw.connect(pick(rng, [...attestors, ...outsiders])).publishSchema(sid, specHash, "n", "1.0.0", "ipfs://"),
|
|
`[ATT AUTH-SCHEMA] ${ctx}`
|
|
).to.be.revertedWithCustomError(gw, "NotFoundation");
|
|
await (await gw.connect(foundation).publishSchema(sid, specHash, "n", "1.0.0", "ipfs://")).wait();
|
|
schemaIds.push(sid); schemaSet[sid] = true; authModel[sid] = {};
|
|
schemas++;
|
|
// APPEND-ONLY: republish reverts.
|
|
await expect(
|
|
gw.connect(foundation).publishSchema(sid, specHash, "n", "2.0.0", "ipfs://"),
|
|
`[ATT SCHEMA-APPEND] ${ctx}`
|
|
).to.be.revertedWithCustomError(gw, "SchemaExists");
|
|
} else if (action === "setAttestor") {
|
|
if (schemaIds.length === 0) { steps++; continue; }
|
|
const sid = pick(rng, schemaIds);
|
|
const at = pick(rng, attestors);
|
|
const flag = chance(rng, 0.75);
|
|
// AUTHORISATION: only Foundation toggles.
|
|
await expect(
|
|
gw.connect(pick(rng, outsiders)).setAttestor(sid, at.address, flag),
|
|
`[ATT AUTH-SETATT] ${ctx}`
|
|
).to.be.revertedWithCustomError(gw, "NotFoundation");
|
|
await (await gw.connect(foundation).setAttestor(sid, at.address, flag)).wait();
|
|
authModel[sid][at.address] = flag;
|
|
authToggles++;
|
|
} else if (action === "attest") {
|
|
if (schemaIds.length === 0) { steps++; continue; }
|
|
const sid = pick(rng, schemaIds);
|
|
const at = pick(rng, attestors);
|
|
const sub = pick(rng, subjects);
|
|
const authorised = !!authModel[sid][at.address];
|
|
const now = await latestTs();
|
|
// random validity window around now.
|
|
const validFrom = chance(rng, 0.25) ? now + ri(rng, 100, 5000) : now - ri(rng, 0, 3000);
|
|
const validUntil = chance(rng, 0.3) ? 0 : (chance(rng, 0.3) ? now - ri(rng, 1, 50) /*already expired*/ : now + ri(rng, 3600, 200000));
|
|
const payloadHash = ethers.keccak256(ethers.toUtf8Bytes(`p:${s}`));
|
|
|
|
if (!authorised) {
|
|
// FORGE RESISTANCE: an unauthorised attestor can NEVER create a record.
|
|
await expect(
|
|
gw.connect(at).attest(sid, sub, ZERO32, payloadHash, validFrom, validUntil, "ipfs://"),
|
|
`[ATT FORGE-UNAUTH] ${ctx}`
|
|
).to.be.revertedWithCustomError(gw, "NotAuthorisedAttestor");
|
|
forgeRej++; attestRej++;
|
|
steps++; await assertAtt(ctx); continue;
|
|
}
|
|
const expId = predictId(at.address, nonces[at.address]);
|
|
if (validUntil !== 0 && validUntil < validFrom) {
|
|
await expect(
|
|
gw.connect(at).attest(sid, sub, ZERO32, payloadHash, validFrom, validUntil, "ipfs://"),
|
|
`[ATT BADWINDOW] ${ctx}`
|
|
).to.be.revertedWithCustomError(gw, "BadValidityWindow");
|
|
attestRej++;
|
|
steps++; await assertAtt(ctx); continue;
|
|
}
|
|
const rc = await (await gw.connect(at).attest(sid, sub, ZERO32, payloadHash, validFrom, validUntil, "ipfs://")).wait();
|
|
const txTs = Number((await ethers.provider.getBlock(rc.blockNumber)).timestamp);
|
|
// record id + bump nonce in model.
|
|
expect(allIds.has(expId), `[ATT ID-COLLISION] ${ctx} id reused ${expId}`).to.equal(false);
|
|
allIds.add(expId);
|
|
nonces[at.address]++;
|
|
atts[expId] = { attestor: at.address, subject: sub, schemaId: sid, parentId: ZERO32, validFrom, validUntil, revoked: false, exists: true };
|
|
// replicate latestFor write rule (HIGH #24 fix) using tx timestamp.
|
|
const kk = key(sub, sid);
|
|
const prior = latestFor[kk];
|
|
const priorValid = prior ? isValidModel(prior, txTs, 0) : false;
|
|
const newValidNow = validFrom <= txTs && (validUntil === 0 || validUntil >= txTs);
|
|
if (!priorValid || newValidNow) latestFor[kk] = expId;
|
|
attestsOk++;
|
|
} else if (action === "attestChild") {
|
|
// inheritance: pick an existing parent for the SAME subject/attestor.
|
|
const ids = Object.keys(atts);
|
|
if (ids.length === 0 || schemaIds.length === 0) { steps++; continue; }
|
|
const parentId = pick(rng, ids);
|
|
const parent = atts[parentId];
|
|
const at = pick(rng, attestors);
|
|
const authorised = !!authModel[parent.schemaId][at.address];
|
|
const now = await latestTs();
|
|
const validFrom = now - ri(rng, 0, 1000);
|
|
const validUntil = chance(rng, 0.5) ? 0 : now + ri(rng, 3600, 100000);
|
|
const payloadHash = ethers.keccak256(ethers.toUtf8Bytes(`pc:${s}`));
|
|
|
|
// mismatched-subject child must revert (fabricated transitive trust).
|
|
const wrongSub = pick(rng, subjects.filter((x) => x !== parent.subject));
|
|
if (authorised && wrongSub) {
|
|
await expect(
|
|
gw.connect(at).attest(parent.schemaId, wrongSub, parentId, payloadHash, validFrom, validUntil, "ipfs://"),
|
|
`[ATT INHERIT-SUBJECT] ${ctx}`
|
|
).to.be.revertedWithCustomError(gw, "ParentSubjectMismatch");
|
|
inheritRej++;
|
|
}
|
|
if (!authorised) { steps++; await assertAtt(ctx); continue; }
|
|
// valid same-subject child.
|
|
const expId = predictId(at.address, nonces[at.address]);
|
|
const rc = await (await gw.connect(at).attest(parent.schemaId, parent.subject, parentId, payloadHash, validFrom, validUntil, "ipfs://")).wait();
|
|
const txTs = Number((await ethers.provider.getBlock(rc.blockNumber)).timestamp);
|
|
expect(allIds.has(expId), `[ATT ID-COLLISION] ${ctx}`).to.equal(false);
|
|
allIds.add(expId); nonces[at.address]++;
|
|
atts[expId] = { attestor: at.address, subject: parent.subject, schemaId: parent.schemaId, parentId, validFrom, validUntil, revoked: false, exists: true };
|
|
const kk = key(parent.subject, parent.schemaId);
|
|
const prior = latestFor[kk];
|
|
const priorValid = prior ? isValidModel(prior, txTs, 0) : false;
|
|
const newValidNow = validFrom <= txTs && (validUntil === 0 || validUntil >= txTs);
|
|
if (!priorValid || newValidNow) latestFor[kk] = expId;
|
|
attestsOk++;
|
|
} else if (action === "attestUnauth") {
|
|
// explicit forge probe: an outsider (never authorised for anything)
|
|
// tries to attest on a random known schema.
|
|
if (schemaIds.length === 0) { steps++; continue; }
|
|
const sid = pick(rng, schemaIds);
|
|
const sub = pick(rng, subjects);
|
|
const now = await latestTs();
|
|
await expect(
|
|
gw.connect(pick(rng, outsiders)).attest(sid, sub, ZERO32, ethers.keccak256(ethers.toUtf8Bytes("x")), now - 10, 0, "ipfs://"),
|
|
`[ATT FORGE-OUTSIDER] ${ctx}`
|
|
).to.be.revertedWithCustomError(gw, "NotAuthorisedAttestor");
|
|
forgeRej++; attestRej++;
|
|
} else if (action === "revoke") {
|
|
const ids = Object.keys(atts).filter((id) => !atts[id].revoked);
|
|
if (ids.length === 0) { steps++; continue; }
|
|
const id = pick(rng, ids);
|
|
const issuer = atts[id].attestor;
|
|
const issuerSigner = [...attestors, ...outsiders].find((x) => x.address === issuer);
|
|
// AUTHORISATION: a non-issuer cannot revoke.
|
|
const notIssuer = [...attestors, ...outsiders].find((x) => x.address !== issuer);
|
|
await expect(gw.connect(notIssuer).revoke(id), `[ATT AUTH-REVOKE] ${ctx}`).to.be.revertedWithCustomError(gw, "NotIssuer");
|
|
revokeRej++;
|
|
await (await gw.connect(issuerSigner).revoke(id)).wait();
|
|
atts[id].revoked = true;
|
|
revokes++;
|
|
// PERMANENCE: cannot un-revoke (no such path) and double-revoke reverts.
|
|
await expect(gw.connect(issuerSigner).revoke(id), `[ATT REVOKE-PERM] ${ctx}`).to.be.revertedWithCustomError(gw, "AlreadyRevoked");
|
|
} else if (action === "revokeUnauth") {
|
|
const ids = Object.keys(atts).filter((id) => !atts[id].revoked);
|
|
if (ids.length === 0) { steps++; continue; }
|
|
const id = pick(rng, ids);
|
|
const notIssuer = [...attestors, ...outsiders].find((x) => x.address !== atts[id].attestor);
|
|
await expect(gw.connect(notIssuer).revoke(id), `[ATT AUTH-REVOKE2] ${ctx}`).to.be.revertedWithCustomError(gw, "NotIssuer");
|
|
revokeRej++;
|
|
} else if (action === "advance") {
|
|
await time.increase(ri(rng, 1, 40000));
|
|
await network.provider.send("evm_mine");
|
|
} else if (action === "idcheck") {
|
|
// determinism of the id derivation for a couple of live records.
|
|
for (const id of Object.keys(atts).slice(0, 3)) {
|
|
const a = await gw.attestationOf(id);
|
|
expect(a.exists, `[ATT ID-EXISTS] ${ctx} id=${id.slice(0, 12)}`).to.equal(true);
|
|
expect(a.attestor, `[ATT ID-ATTESTOR] ${ctx}`).to.equal(atts[id].attestor);
|
|
idChecks++;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (/ATT [A-Z]|INVARIANT/.test(String(e.message))) throw e;
|
|
}
|
|
|
|
await assertAtt(ctx);
|
|
steps++;
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
` [att] steps=${steps} schemas=${schemas} authToggles=${authToggles} attestOk=${attestsOk} attestRej=${attestRej} ` +
|
|
`forge-rej=${forgeRej} revoke=${revokes} revoke-rej=${revokeRej} inherit-rej=${inheritRej} idChecks=${idChecks}`
|
|
);
|
|
expect(steps).to.be.greaterThan(250);
|
|
expect(attestsOk).to.be.greaterThan(20);
|
|
expect(forgeRej).to.be.greaterThan(5);
|
|
expect(revokes).to.be.greaterThan(3);
|
|
expect(revokeRej).to.be.greaterThan(3);
|
|
});
|
|
|
|
// ==========================================================================
|
|
// CAMPAIGN T: AereTravelRuleHashRegistry authorisation + state machine + append-only
|
|
// ==========================================================================
|
|
it("travel-rule registry: only beneficiary acts, immutable commitment, terminal-state guards", async function () {
|
|
let steps = 0, commits = 0, acks = 0, disputes = 0, authRej = 0, dupRej = 0, immutChecks = 0;
|
|
|
|
for (let c = 0; c < N.TR_CAMPAIGNS; c++) {
|
|
const rng = rngFor("tr", c);
|
|
const F = await ethers.getContractFactory("AereTravelRuleHashRegistry");
|
|
const reg = await F.deploy();
|
|
await reg.waitForDeployment();
|
|
|
|
const vasps = [signers[1], signers[2], signers[3], signers[4]];
|
|
const strangers = [signers[6], signers[7]];
|
|
// model: id -> {payloadHash, originator, beneficiary, state} state 0 Committed,1 Ack,2 Disputed
|
|
const model = {};
|
|
let nextId = 0;
|
|
|
|
async function assertTR(ctx) {
|
|
for (const idStr of Object.keys(model)) {
|
|
const id = Number(idStr);
|
|
const m = model[id];
|
|
const chain = await reg.commitments(id);
|
|
// APPEND-ONLY / IMMUTABLE: payloadHash, originator, beneficiary never change.
|
|
expect(chain.payloadHash, `[TR IMMUT-HASH] ${ctx} id${id}`).to.equal(m.payloadHash);
|
|
expect(chain.originatorVasp, `[TR IMMUT-ORIG] ${ctx} id${id}`).to.equal(m.originator);
|
|
expect(chain.beneficiaryVasp, `[TR IMMUT-BEN] ${ctx} id${id}`).to.equal(m.beneficiary);
|
|
expect(Number(chain.state), `[TR STATE] ${ctx} id${id}`).to.equal(m.state);
|
|
}
|
|
}
|
|
|
|
for (let s = 0; s < N.TR_STEPS; s++) {
|
|
const action = pick(rng, ["commit", "commit", "ack", "dispute", "authprobe", "dupprobe"]);
|
|
const ctx = `c${c} s${s} action=${action}`;
|
|
try {
|
|
if (action === "commit") {
|
|
const orig = pick(rng, vasps);
|
|
let ben = pick(rng, vasps);
|
|
while (ben.address === orig.address) ben = pick(rng, vasps);
|
|
const ph = randHex32(rng);
|
|
const id = nextId++;
|
|
await (await reg.connect(orig).commit(ph, ben.address, ri(rng, 100000, 500000))).wait();
|
|
model[id] = { payloadHash: ph, originator: orig.address, beneficiary: ben.address, state: 0 };
|
|
commits++;
|
|
// ZeroAddress beneficiary reverts.
|
|
await expect(reg.connect(orig).commit(ph, ethers.ZeroAddress, 1000), `[TR ZERO] ${ctx}`)
|
|
.to.be.revertedWithCustomError(reg, "ZeroAddress");
|
|
} else if (action === "ack") {
|
|
const cand = Object.keys(model).map(Number).filter((i) => model[i].state === 0);
|
|
if (cand.length === 0) { steps++; continue; }
|
|
const id = pick(rng, cand);
|
|
const ben = vasps.find((v) => v.address === model[id].beneficiary);
|
|
// AUTHORISATION: a non-beneficiary (originator or stranger) cannot ack.
|
|
const nonBen = pick(rng, [...strangers, vasps.find((v) => v.address === model[id].originator)]);
|
|
await expect(reg.connect(nonBen).acknowledge(id), `[TR AUTH-ACK] ${ctx}`).to.be.revertedWithCustomError(reg, "NotBeneficiary");
|
|
authRej++;
|
|
await (await reg.connect(ben).acknowledge(id)).wait();
|
|
model[id].state = 1;
|
|
acks++;
|
|
// terminal guard: re-ack reverts.
|
|
await expect(reg.connect(ben).acknowledge(id), `[TR DUP-ACK] ${ctx}`).to.be.revertedWithCustomError(reg, "AlreadyAcknowledged");
|
|
dupRej++;
|
|
} else if (action === "dispute") {
|
|
const cand = Object.keys(model).map(Number).filter((i) => model[i].state !== 2);
|
|
if (cand.length === 0) { steps++; continue; }
|
|
const id = pick(rng, cand);
|
|
const ben = vasps.find((v) => v.address === model[id].beneficiary);
|
|
const nonBen = pick(rng, strangers);
|
|
await expect(reg.connect(nonBen).dispute(id, "x"), `[TR AUTH-DISPUTE] ${ctx}`).to.be.revertedWithCustomError(reg, "NotBeneficiary");
|
|
authRej++;
|
|
await (await reg.connect(ben).dispute(id, "payload mismatch")).wait();
|
|
model[id].state = 2;
|
|
disputes++;
|
|
// terminal guard: re-dispute reverts.
|
|
await expect(reg.connect(ben).dispute(id, "y"), `[TR DUP-DISPUTE] ${ctx}`).to.be.revertedWithCustomError(reg, "AlreadyDisputed");
|
|
dupRej++;
|
|
} else if (action === "authprobe") {
|
|
// ack/dispute of an unknown id reverts (no silent creation).
|
|
await expect(reg.connect(pick(rng, vasps)).acknowledge(999999), `[TR UNKNOWN-ACK] ${ctx}`).to.be.revertedWithCustomError(reg, "UnknownCommitment");
|
|
await expect(reg.connect(pick(rng, vasps)).dispute(999998, "z"), `[TR UNKNOWN-DISP] ${ctx}`).to.be.revertedWithCustomError(reg, "UnknownCommitment");
|
|
} else if (action === "dupprobe") {
|
|
immutChecks++;
|
|
}
|
|
} catch (e) {
|
|
if (/TR [A-Z]|INVARIANT/.test(String(e.message))) throw e;
|
|
}
|
|
await assertTR(ctx);
|
|
steps++;
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
` [tr] steps=${steps} commit=${commits} ack=${acks} dispute=${disputes} auth-rej=${authRej} dup-rej=${dupRej}`
|
|
);
|
|
expect(steps).to.be.greaterThan(120);
|
|
expect(commits).to.be.greaterThan(15);
|
|
expect(acks + disputes).to.be.greaterThan(10);
|
|
expect(authRej).to.be.greaterThan(8);
|
|
expect(dupRej).to.be.greaterThan(5);
|
|
});
|
|
});
|