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