aere-contracts/test/aereproof-compliance-invariant-property.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

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