aere-contracts/test/nav-oracle-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

601 lines
30 KiB
JavaScript

// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the AERE
// NAV / PRICE ATTESTATION ORACLE system.
//
// Two contracts under test (matched to canonical live addresses via
// sdk-js/src/addresses.ts):
// * AereNavOracleAdapter (0xBb8bB6846d2440FdF0076fc5750baE11C2731D21)
// owner-attested per-asset RWA NAV push feed (setNav / latestForAsset).
// * AereLendingOracle (canonical LendingOracle) — the CONSUMER-facing
// price surface that layers staleness + per-update deviation + pause on
// top of the NavOracle-type feed (and ChainlinkLike / ConstantOne feeds).
// * AereNavOracle (0xC8D12E44f10b03477330b35115b432750831fEBD)
// Merkle-rooted reserve-attestation contract (propose/challenge/attest/
// verifyReserve). Attestation-integrity invariants fuzzed separately.
//
// The exact invariant brief for this oracle:
// - NAV/price can be PUSHED ONLY by an authorized attestor (owner);
// - staleness bounds and max per-update deviation are ENFORCED (a single
// update cannot jump the value beyond the configured deviation, stale
// reads are rejected);
// - a consumer ALWAYS reads a value within the enforced bounds;
// - NO unauthorized address can move the reported value;
// - pause blocks updates but NEVER returns a silently-wrong value.
//
// METHOD: hardhat-based seeded (mulberry32) randomized multi-actor stateful
// fuzzing. `forge` is NOT installed; the repo builds with hardhat + OZ 4.9.6
// + viaIR. The base seed is printed and pinnable via FUZZ_SEED. Every
// assertion embeds (campaign, step, actor, action, values) for a minimal
// repro. The invariant oracle is an INDEPENDENT re-implementation of the
// contract spec (getPrice / _readNormalised / verifyReserve) compared against
// the live contract over hundreds of randomized states across several seeds,
// with authoritative dynamic state re-read from chain each step (no shadow
// drift).
//
// SCOPE: run this file in ISOLATION (the full suite OOMs on unrelated ML-DSA
// PQC KAT tests):
// npx hardhat test test/nav-oracle-invariant-property.test.js
// TEST-ONLY: no contract logic changed, nothing deployed to any live node.
// No em-dashes anywhere in this file.
// =============================================================================
const { expect } = require("chai");
const { ethers } = 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) : 0x4A7E0AC1;
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;
/* ------------------------------ spec helpers ------------------------------ */
// Independent re-implementation of AereLendingOracle._normalize.
function normalize(raw, dec) {
raw = BigInt(raw);
dec = Number(dec);
if (dec === 18) return raw;
if (dec < 18) return raw * 10n ** BigInt(18 - dec);
return raw / 10n ** BigInt(dec - 18);
}
// Extract the custom-error name from an ethers v6 revert, best-effort.
function errName(e) {
return (e && (e.revert?.name || e.errorName)) || null;
}
/* ---------------------- Merkle helpers (single-hash) ---------------------- */
const abi = ethers.AbiCoder.defaultAbiCoder();
function leafOf(asset, chainId, reserveAmount, decimals) {
return ethers.keccak256(abi.encode(
["address", "uint256", "uint256", "uint8"],
[asset, chainId, reserveAmount, decimals]
));
}
function buildTree(leaves) {
let layer = leaves.slice().map((l) => l.toLowerCase());
layer.sort();
const layers = [layer];
while (layer.length > 1) {
const next = [];
for (let i = 0; i < layer.length; i += 2) {
const a = layer[i];
const b = layer[i + 1] ?? layer[i];
const [lo, hi] = a < b ? [a, b] : [b, a];
next.push(ethers.keccak256(ethers.concat([lo, hi])));
}
layer = next;
layers.push(layer);
}
return { root: layer[0], layers };
}
function proofFor(tree, leaf) {
const proof = [];
let target = leaf.toLowerCase();
for (let i = 0; i < tree.layers.length - 1; i++) {
const lay = tree.layers[i];
const idx = lay.indexOf(target);
if (idx < 0) throw new Error("leaf not in layer");
const sib = idx ^ 1;
// buildTree self-pairs the last odd node (hash(L,L)); OZ MerkleProof folds
// each proof element unconditionally, so a self-paired node must include
// ITSELF as its sibling in the proof (not be skipped).
proof.push(sib < lay.length ? lay[sib] : lay[idx]);
const a = lay[idx];
const b = lay[sib] ?? lay[idx];
const [lo, hi] = a < b ? [a, b] : [b, a];
target = ethers.keccak256(ethers.concat([lo, hi]));
}
return proof;
}
/* ========================================================================== */
/* PART 1 — PRICE / NAV oracle: staleness, deviation, auth, pause, bounds */
/* ========================================================================== */
describe("AereNavOracle price system — property/invariant fuzz", function () {
this.timeout(600000);
const FEED_TYPE = { ChainlinkLike: 0, NavOracle: 1, ConstantOne: 2 };
const STALE_OPTS = [3600, 86400, 604800];
const DEV_OPTS = [0, 100, 500, 1000, 5000];
const DEC_OPTS = [6, 8, 18];
it("drives multi-actor randomized sequences and holds every price invariant", async function () {
const signers = await ethers.getSigners();
const owner = signers[0];
const actors = signers.slice(1, 8); // non-owner actors (consumers + attackers)
const CAMPAIGNS = 6;
const STEPS = 120;
let totalSteps = 0;
let totalChecks = 0;
console.log(`\n[nav-price] BASE_SEED=0x${(BASE_SEED >>> 0).toString(16)} campaigns=${CAMPAIGNS} steps=${STEPS}`);
for (let campaign = 0; campaign < CAMPAIGNS; campaign++) {
const rng = rngFor("navprice", campaign);
// Fresh deployment each campaign.
const AdapterF = await ethers.getContractFactory("AereNavOracleAdapter");
const adapter = await AdapterF.deploy(ethers.ZeroAddress);
await adapter.waitForDeployment();
const OracleF = await ethers.getContractFactory("AereLendingOracle");
const oracle = await OracleF.deploy();
await oracle.waitForDeployment();
// Build a handful of assets with random feed configs.
const NASSETS = ri(rng, 3, 6);
const assets = []; // {addr, feedType, aggregator?, navKey?, dec, staleness, devBps}
const navKeys = []; // reuse keys sometimes to exercise shared-key setNav
for (let i = 0; i < NASSETS; i++) {
const addr = ethers.getAddress("0x" + (i + 1).toString(16).padStart(2, "0").repeat(20));
const feedType = pick(rng, [FEED_TYPE.ChainlinkLike, FEED_TYPE.NavOracle, FEED_TYPE.ConstantOne]);
const dec = feedType === FEED_TYPE.ConstantOne ? 18 : pick(rng, DEC_OPTS);
const staleness = pick(rng, STALE_OPTS);
const devBps = pick(rng, DEV_OPTS);
const a = { addr, feedType, dec, staleness, devBps };
if (feedType === FEED_TYPE.ChainlinkLike) {
// initial positive answer in feed-native decimals
const raw = BigInt(ri(rng, 1, 5000)) * 10n ** BigInt(dec) / 100n + 1n;
const MockAgg = await ethers.getContractFactory("MockAggregator");
const agg = await MockAgg.deploy(raw);
await agg.waitForDeployment();
a.aggregator = agg;
a.source = await agg.getAddress();
} else if (feedType === FEED_TYPE.NavOracle) {
// Sometimes reuse an existing key.
let key;
if (navKeys.length > 0 && chance(rng, 0.35)) {
key = pick(rng, navKeys);
} else {
key = ethers.id("navkey-" + campaign + "-" + i);
navKeys.push(key);
}
a.navKey = key;
a.source = await adapter.getAddress();
// Must attest a nonzero NAV BEFORE configuring (configureFeed auto-pokes).
const nav = BigInt(ri(rng, 1, 5000)) * 10n ** BigInt(dec) / 100n + 1n;
await adapter.connect(owner).setNav(key, nav);
} else {
a.source = ethers.ZeroAddress;
}
assets.push(a);
await oracle.connect(owner).configureFeed(
a.addr, feedType, a.source, a.navKey ?? ethers.ZeroHash,
staleness, devBps, dec
);
}
// ---- Independent spec re-implementation reading authoritative chain state.
async function readUpstream(a) {
if (a.feedType === FEED_TYPE.ConstantOne) return { kind: "const" };
if (a.feedType === FEED_TYPE.ChainlinkLike) {
const answer = await a.aggregator.answer();
const updatedAt = await a.aggregator.updatedAt();
return { kind: "chainlink", answer, updatedAt };
}
const r = await adapter.readings(a.navKey); // (nav, timestamp, set)
return { kind: "nav", nav: r.nav, ts: r.timestamp };
}
// Predict _readNormalised outcome. Returns {price} or {revert:name}.
function predictRead(a, up, now) {
if (up.kind === "const") return { price: 10n ** 18n };
if (up.kind === "chainlink") {
const answer = up.answer;
const updatedAt = up.updatedAt;
if (answer <= 0n || updatedAt === 0n) return { revert: "InvalidFeedResponse" };
if (updatedAt > now) return { revert: "InvalidFeedResponse" };
if (now - updatedAt > BigInt(a.staleness)) return { revert: "FeedStale" };
return { price: normalize(answer, a.dec) };
}
// nav
if (up.ts === 0n || up.nav === 0n) return { revert: "InvalidFeedResponse" };
if (up.ts > now) return { revert: "InvalidFeedResponse" };
if (now - up.ts > BigInt(a.staleness)) return { revert: "FeedStale" };
return { price: normalize(up.nav, a.dec) };
}
// Predict getPrice given authoritative feed struct + upstream + now.
function predictGetPrice(a, f, up, now) {
if (!f.configured) return { revert: "FeedNotConfigured" };
if (f.paused) return { revert: "FeedPausedErr" };
const rd = predictRead(a, up, now);
if (rd.revert) return rd;
const price = rd.price;
if (price === 0n) return { revert: "ZeroPrice" };
if (f.lastGoodPrice !== 0n && f.maxDeviationBps !== 0n) {
const ref = f.lastGoodPrice;
const diff = price > ref ? price - ref : ref - price;
const maxDiff = (ref * BigInt(f.maxDeviationBps)) / 10000n;
if (diff > maxDiff) return { revert: "FeedDeviationExceeded" };
}
return { price };
}
// Assert getPrice model-match + structural bounds for every asset.
async function checkAllPrices(ctx) {
const now = BigInt(await time.latest());
for (const a of assets) {
const f = await oracle.feeds(a.addr);
const up = await readUpstream(a);
const pred = predictGetPrice(a, f, up, now);
const tag = `${ctx} asset=${a.addr.slice(0, 8)} ft=${a.feedType} dec=${a.dec} stale=${a.staleness} dev=${a.devBps} now=${now} lgp=${f.lastGoodPrice} paused=${f.paused}`;
let ok = true, val, err;
try { val = await oracle.getPrice.staticCall(a.addr); }
catch (e) { ok = false; err = e; }
if (pred.revert) {
expect(ok, `${tag} :: expected revert ${pred.revert} but got value ${ok ? val : ""}`).to.equal(false);
const name = errName(err);
if (name) {
expect(name, `${tag} :: expected revert ${pred.revert} got ${name}`).to.equal(pred.revert);
}
} else {
expect(ok, `${tag} :: expected price ${pred.price} but reverted ${ok ? "" : errName(err)}`).to.equal(true);
expect(val, `${tag} :: price mismatch`).to.equal(pred.price);
// INDEPENDENT structural bounds (the consumer-reads-within-bounds invariant).
expect(f.paused, `${tag} :: BUG returned a price while paused`).to.equal(false);
if (a.feedType !== FEED_TYPE.ConstantOne) {
// Not stale: upstream age must be within the staleness window.
const upTs = up.kind === "chainlink" ? up.updatedAt : up.ts;
expect(now - upTs <= BigInt(a.staleness),
`${tag} :: BUG returned a STALE price (age=${now - upTs} > ${a.staleness})`).to.equal(true);
}
if (f.lastGoodPrice !== 0n && f.maxDeviationBps !== 0n) {
const diff = val > f.lastGoodPrice ? val - f.lastGoodPrice : f.lastGoodPrice - val;
const maxDiff = (f.lastGoodPrice * BigInt(f.maxDeviationBps)) / 10000n;
expect(diff <= maxDiff,
`${tag} :: BUG returned OUT-OF-BAND price val=${val} lgp=${f.lastGoodPrice} diff=${diff} maxDiff=${maxDiff}`).to.equal(true);
}
}
totalChecks++;
}
}
await checkAllPrices(`c${campaign} s-1 init`);
// ------------------------------ fuzz loop ------------------------------
for (let step = 0; step < STEPS; step++) {
const action = pick(rng, [
"setNavOwner", "setNavAttacker",
"aggSet", "aggStale",
"pokeOwner", "pokeAttacker",
"pokeBounded",
"pauseToggle", "pauseAttacker",
"configAttacker",
"advanceTime",
]);
const actor = pick(rng, actors);
const a = pick(rng, assets);
const tag = `c${campaign} step${step} action=${action} actor=${actor.address.slice(0, 8)} asset=${a.addr.slice(0, 8)}`;
try {
if (action === "setNavOwner") {
const key = a.navKey ?? pick(rng, navKeys.length ? navKeys : [ethers.id("x")]);
const nav = BigInt(ri(rng, 1, 20000)) * 10n ** BigInt(a.dec) / 100n + 1n;
await adapter.connect(owner).setNav(key, nav);
} else if (action === "setNavAttacker") {
// AUTH: non-owner setNav MUST revert; reported value must NOT move.
const key = a.navKey ?? (navKeys.length ? pick(rng, navKeys) : ethers.id("x"));
const before = await adapter.readings(key);
let reverted = false;
try { await adapter.connect(actor).setNav(key, 123n); } catch { reverted = true; }
expect(reverted, `${tag} :: BUG non-owner setNav did not revert`).to.equal(true);
const after = await adapter.readings(key);
expect(after.nav, `${tag} :: BUG attacker moved NAV`).to.equal(before.nav);
expect(after.timestamp, `${tag} :: BUG attacker moved NAV ts`).to.equal(before.timestamp);
} else if (action === "aggSet") {
if (a.aggregator) {
const raw = BigInt(ri(rng, 0, 30000)) * 10n ** BigInt(a.dec) / 100n; // can be 0 -> InvalidFeedResponse
await a.aggregator.set(raw);
}
} else if (action === "aggStale") {
if (a.aggregator) {
const now = BigInt(await time.latest());
// Random past OR future timestamp to exercise FeedStale / InvalidFeedResponse.
const t = chance(rng, 0.5)
? (now > BigInt(a.staleness + 10) ? now - BigInt(a.staleness) - BigInt(ri(rng, 1, 5000)) : 1n)
: now + BigInt(ri(rng, 10, 100000));
const raw = BigInt(ri(rng, 1, 30000)) * 10n ** BigInt(a.dec) / 100n + 1n;
await a.aggregator.setStale(raw, t);
}
} else if (action === "pokeOwner") {
// Owner may legitimately move the reference beyond band. Reverts only
// if the upstream is currently unreadable (stale/invalid/zero).
try { await oracle.connect(owner).poke(a.addr); } catch { /* upstream unreadable */ }
} else if (action === "pokeAttacker") {
// AUTH: poke is onlyOwner. Must revert; lastGoodPrice must NOT move.
const before = await oracle.feeds(a.addr);
let reverted = false;
try { await oracle.connect(actor).poke(a.addr); } catch { reverted = true; }
expect(reverted, `${tag} :: BUG non-owner poke did not revert`).to.equal(true);
const after = await oracle.feeds(a.addr);
expect(after.lastGoodPrice, `${tag} :: BUG attacker moved lastGoodPrice via poke`).to.equal(before.lastGoodPrice);
} else if (action === "pokeBounded") {
// Permissionless, but a SUCCESS must stay within maxDeviationBps of the
// prior reference (the "no one-tick override" invariant).
const before = await oracle.feeds(a.addr);
let ok = true;
try { await oracle.connect(actor).pokeBounded(a.addr); } catch { ok = false; }
if (ok) {
const after = await oracle.feeds(a.addr);
if (before.lastGoodPrice !== 0n && before.maxDeviationBps !== 0n) {
const diff = after.lastGoodPrice > before.lastGoodPrice
? after.lastGoodPrice - before.lastGoodPrice
: before.lastGoodPrice - after.lastGoodPrice;
const maxDiff = (before.lastGoodPrice * BigInt(before.maxDeviationBps)) / 10000n;
expect(diff <= maxDiff,
`${tag} :: BUG pokeBounded moved reference OUT OF BAND before=${before.lastGoodPrice} after=${after.lastGoodPrice} diff=${diff} maxDiff=${maxDiff}`).to.equal(true);
}
}
} else if (action === "pauseToggle") {
const f = await oracle.feeds(a.addr);
await oracle.connect(owner).setFeedPaused(a.addr, !f.paused);
} else if (action === "pauseAttacker") {
// AUTH: setFeedPaused is onlyOwner.
const before = await oracle.feeds(a.addr);
let reverted = false;
try { await oracle.connect(actor).setFeedPaused(a.addr, !before.paused); } catch { reverted = true; }
expect(reverted, `${tag} :: BUG non-owner setFeedPaused did not revert`).to.equal(true);
const after = await oracle.feeds(a.addr);
expect(after.paused, `${tag} :: BUG attacker toggled pause`).to.equal(before.paused);
} else if (action === "configAttacker") {
// AUTH: configureFeed is onlyOwner.
let reverted = false;
try {
await oracle.connect(actor).configureFeed(
a.addr, FEED_TYPE.ConstantOne, ethers.ZeroAddress, ethers.ZeroHash, 3600, 0, 18);
} catch { reverted = true; }
expect(reverted, `${tag} :: BUG non-owner configureFeed did not revert`).to.equal(true);
} else if (action === "advanceTime") {
await time.increase(ri(rng, 1, 200000));
}
} catch (e) {
// Re-throw only genuine invariant assertion failures (chai AssertionError).
if (e && e.constructor && e.constructor.name === "AssertionError") throw e;
// Otherwise this was an expected/permitted contract revert on an owner
// action given current state (e.g. poke on a stale feed). Ignore.
}
await checkAllPrices(tag);
totalSteps++;
}
}
console.log(`[nav-price] PASS: ${totalSteps} steps, ${totalChecks} getPrice invariant checks across ${CAMPAIGNS} campaigns.`);
});
});
/* ========================================================================== */
/* PART 2 — AereNavOracle Merkle attestation integrity */
/* ========================================================================== */
describe("AereNavOracle (Merkle reserve attestation) — property/invariant fuzz", function () {
this.timeout(600000);
const State = { Proposed: 0, Challenged: 1, Attested: 2 };
const WINDOW = 24 * 3600;
it("drives randomized propose/challenge/dismiss/attest and holds attestation integrity", async function () {
const signers = await ethers.getSigners();
const owner = signers[0];
const actors = signers.slice(1, 8);
const CAMPAIGNS = 5;
const STEPS = 90;
let totalSteps = 0;
let verifyChecks = 0;
console.log(`\n[nav-merkle] BASE_SEED=0x${(BASE_SEED >>> 0).toString(16)} campaigns=${CAMPAIGNS} steps=${STEPS}`);
for (let campaign = 0; campaign < CAMPAIGNS; campaign++) {
const rng = rngFor("navmerkle", campaign);
const oracle = await (await ethers.getContractFactory("AereNavOracle")).deploy();
await oracle.waitForDeployment();
// shadow: epoch -> { root, proposedAt(bigint), state, open, chals:[{dismissed}], tree, realLeaves:Set(leafHash), leafInfo:[...] }
const shadow = new Map();
let nextEpoch = 1;
function makeTree() {
const n = ri(rng, 1, 5);
const leaves = [];
const infos = [];
for (let i = 0; i < n; i++) {
const asset = ethers.getAddress("0x" + (ri(rng, 1, 254)).toString(16).padStart(2, "0").repeat(20));
const chainId = pick(rng, [1, 2800]);
const amount = BigInt(ri(rng, 1, 9_000_000)) * 10n ** 6n;
const dec = pick(rng, [6, 8, 18]);
const lf = leafOf(asset, chainId, amount, dec);
leaves.push(lf);
infos.push({ asset, chainId, amount, dec, leaf: lf.toLowerCase() });
}
const tree = buildTree(leaves);
return { tree, infos, leafSet: new Set(leaves.map((l) => l.toLowerCase())) };
}
// Assert full state-machine + verifyReserve integrity for all epochs.
async function checkIntegrity(ctx) {
// latestAttestedEpoch must point at an actually-attested epoch (or 0).
const [laEpoch, laRoot] = await oracle.latestAttestedEpoch();
if (laEpoch !== 0n) {
const sh = shadow.get(Number(laEpoch));
expect(sh, `${ctx} :: latestAttestedEpoch=${laEpoch} unknown to shadow`).to.not.equal(undefined);
expect(sh.state, `${ctx} :: latestAttestedEpoch=${laEpoch} not Attested in shadow`).to.equal(State.Attested);
expect(laRoot, `${ctx} :: latestAttestedEpoch root mismatch`).to.equal(sh.root);
}
for (const [epoch, sh] of shadow.entries()) {
const st = await oracle.snapshotStatus(epoch);
// state + root + openChallenges match shadow
expect(Number(st.state), `${ctx} :: epoch=${epoch} state drift`).to.equal(sh.state);
expect(st.root, `${ctx} :: epoch=${epoch} root drift`).to.equal(sh.root);
expect(st.openChallengeCount, `${ctx} :: epoch=${epoch} open-challenge drift`).to.equal(BigInt(sh.open));
// verifyReserve integrity: a REAL leaf verifies TRUE iff Attested; a
// TAMPERED leaf is ALWAYS false; any leaf is false when not attested.
const info = pick(rng, sh.infos);
const proof = proofFor(sh.tree, info.leaf);
const okReal = await oracle.verifyReserve(epoch, info.asset, info.chainId, info.amount, info.dec, proof);
const expectReal = sh.state === State.Attested; // real leaf + attested => true
expect(okReal, `${ctx} :: epoch=${epoch} verifyReserve(real leaf)=${okReal} expected ${expectReal} (state=${sh.state})`).to.equal(expectReal);
// Tampered amount: leaf not in tree => always false regardless of state.
const okBad = await oracle.verifyReserve(epoch, info.asset, info.chainId, info.amount + 1n, info.dec, proof);
expect(okBad, `${ctx} :: epoch=${epoch} verifyReserve accepted a TAMPERED reserve amount`).to.equal(false);
verifyChecks += 2;
}
}
await checkIntegrity(`c${campaign} init`);
for (let step = 0; step < STEPS; step++) {
const action = pick(rng, [
"propose", "proposeAttacker",
"challenge", "dismiss", "dismissAttacker",
"attest", "advance",
]);
const actor = pick(rng, actors);
const now = BigInt(await time.latest());
const epochsArr = [...shadow.keys()];
const tag = `c${campaign} step${step} action=${action} actor=${actor.address.slice(0, 8)}`;
try {
if (action === "propose") {
const epoch = nextEpoch++;
const { tree, infos, leafSet } = makeTree();
const cid = "ipfs://Qm" + epoch;
await oracle.connect(owner).proposeSnapshot(epoch, tree.root, cid);
shadow.set(epoch, { root: tree.root, proposedAt: BigInt(await time.latest()), state: State.Proposed, open: 0, chals: [], tree, infos, leafSet });
} else if (action === "proposeAttacker") {
// AUTH: proposeSnapshot is onlyOwner.
const epoch = 100000 + step;
let reverted = false;
try { await oracle.connect(actor).proposeSnapshot(epoch, ethers.id("evil" + step), ""); } catch { reverted = true; }
expect(reverted, `${tag} :: BUG non-owner proposed a snapshot`).to.equal(true);
const st = await oracle.snapshotStatus(epoch);
expect(st.proposedAt, `${tag} :: BUG attacker snapshot exists on-chain`).to.equal(0n);
} else if (action === "challenge" && epochsArr.length) {
const epoch = pick(rng, epochsArr);
const sh = shadow.get(epoch);
const canChallenge = (sh.state === State.Proposed || sh.state === State.Challenged)
&& now < sh.proposedAt + BigInt(WINDOW);
let ok = true;
try { await oracle.connect(actor).challenge(epoch, "double-count?"); } catch { ok = false; }
if (canChallenge) {
// Might still fail if the tx timestamp crossed the window boundary;
// reconcile from chain rather than over-asserting on time.
if (ok) {
sh.chals.push({ dismissed: false });
sh.open += 1;
sh.state = State.Challenged;
}
} else {
expect(ok, `${tag} :: BUG challenge succeeded when it should not (state=${sh.state})`).to.equal(false);
}
} else if (action === "dismiss" && epochsArr.length) {
const epoch = pick(rng, epochsArr);
const sh = shadow.get(epoch);
const openIdx = sh.chals.findIndex((c) => !c.dismissed);
if (openIdx >= 0) {
await oracle.connect(owner).dismissChallenge(epoch, openIdx, "reviewed");
sh.chals[openIdx].dismissed = true;
sh.open -= 1;
if (sh.open === 0) sh.state = State.Proposed;
}
} else if (action === "dismissAttacker" && epochsArr.length) {
// AUTH: dismissChallenge is onlyOwner.
const epoch = pick(rng, epochsArr);
const sh = shadow.get(epoch);
const openIdx = sh.chals.findIndex((c) => !c.dismissed);
const before = await oracle.snapshotStatus(epoch);
let reverted = false;
try { await oracle.connect(actor).dismissChallenge(epoch, openIdx >= 0 ? openIdx : 0, "evil"); } catch { reverted = true; }
expect(reverted, `${tag} :: BUG non-owner dismissed a challenge`).to.equal(true);
const after = await oracle.snapshotStatus(epoch);
expect(after.openChallengeCount, `${tag} :: BUG attacker changed open-challenge count`).to.equal(before.openChallengeCount);
expect(after.state, `${tag} :: BUG attacker changed state`).to.equal(before.state);
} else if (action === "attest" && epochsArr.length) {
const epoch = pick(rng, epochsArr);
const sh = shadow.get(epoch);
const rootBefore = (await oracle.snapshotStatus(epoch)).root;
const canAttest = sh.state === State.Proposed
&& now >= sh.proposedAt + BigInt(WINDOW)
&& sh.open === 0;
let ok = true;
try { await oracle.connect(actor).attest(epoch); } catch { ok = false; }
if (ok) {
// A successful attest must have been legitimately attestable.
expect(sh.state, `${tag} :: BUG attested from wrong shadow state ${sh.state}`).to.equal(State.Proposed);
expect(sh.open, `${tag} :: BUG attested with open challenges`).to.equal(0);
const nowChain = BigInt(await time.latest());
expect(nowChain >= sh.proposedAt + BigInt(WINDOW),
`${tag} :: BUG attested before challenge window elapsed`).to.equal(true);
sh.state = State.Attested;
// Attested root must equal the proposed root (append-only, no mutation).
const rootAfter = (await oracle.snapshotStatus(epoch)).root;
expect(rootAfter, `${tag} :: BUG attest mutated the root`).to.equal(rootBefore);
} else if (canAttest) {
// Only acceptable failure is a boundary timestamp shift; verify still not attested.
const st = await oracle.snapshotStatus(epoch);
expect(Number(st.state), `${tag} :: attest failed yet shadow allowed it`).to.not.equal(State.Attested);
}
} else if (action === "advance") {
await time.increase(ri(rng, 1, WINDOW + 10000));
}
} catch (e) {
if (e && e.constructor && e.constructor.name === "AssertionError") throw e;
// permitted contract revert given state; ignore.
}
await checkIntegrity(tag);
totalSteps++;
}
}
console.log(`[nav-merkle] PASS: ${totalSteps} steps, ${verifyChecks} verifyReserve integrity checks across ${CAMPAIGNS} campaigns.`);
});
});