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