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.
601 lines
30 KiB
JavaScript
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.`);
|
|
});
|
|
});
|