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.
570 lines
28 KiB
JavaScript
570 lines
28 KiB
JavaScript
// =============================================================================
|
|
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
|
|
// AereSettlementHub (canonical mainnet 0x2a02fD80c16293D2B5D8a295F31D1a6E6a582c02
|
|
// per sdk-js/src/addresses.ts).
|
|
//
|
|
// Source under test: contracts/settlement/AereSettlementHub.sol — an
|
|
// institutional cross-asset settlement venue: counterparties deposit()
|
|
// tokenised collateral under an intentId (a 50bps immutable protocol fee is
|
|
// routed to AereSink, the remainder parked), an allowlisted+bonded solver
|
|
// claim()s, and after a 6h challenge window anyone settle()s to release the
|
|
// parked remainder to the solver. Solvers post per-(asset,solver) bonds; the
|
|
// owner may slashSolverBond(); anyone may sweepResidual().
|
|
//
|
|
// TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry — `forge`
|
|
// is not installed; the repo builds through hardhat with 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.
|
|
//
|
|
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
|
|
// unrelated ML-DSA PQC KAT tests):
|
|
// npx hardhat test test/settlementhub-netting-invariant-property.test.js
|
|
// No deploy to any live node, no contract-logic change, one new test file only.
|
|
//
|
|
// INVARIANTS FUZZED (exactly the AereSettlementHub brief):
|
|
// * NETTING CONSERVATION: every deposit splits amount = fee + parkedRemainder;
|
|
// the fee leaves to the sink and the remainder is parked under the intent.
|
|
// Per asset, at every step:
|
|
// hubBalance == sum(parked amount of every unsettled intent)
|
|
// + sum(solverBond over all solvers)
|
|
// i.e. the hub holds exactly the sum of what it owes solvers plus posted
|
|
// bonds — no value is minted and nothing extra can be extracted.
|
|
// * DOUBLE-SETTLE: a settled intent can never be settled again (reverts
|
|
// IntentAlreadySettled); the solver is paid its parked amount exactly once.
|
|
// * GHOST / UNCLAIMED SETTLE: a non-existent intentId, an unclaimed intent, or
|
|
// an intent still inside its challenge window can never settle.
|
|
// * PAYOUT EXACTNESS: settle() transfers EXACTLY intent.amount to the recorded
|
|
// solver — never more (no over-payment), and the depositor never gets a
|
|
// refund path (there is none), so no party extracts more than owed.
|
|
// * FEE ACCOUNTING: cumulative fees observed == sink balance == sum over
|
|
// intents of (grossDeposited - parkedAmount).
|
|
// * SUPPLY RECONCILIATION: total tokens ever minted to actors are conserved:
|
|
// depositorBalances + hubBalance + sinkBalance + solverBalances == mintedTotal
|
|
// for each asset, across interleaved deposit/claim/settle/bond/slash.
|
|
//
|
|
// Plus two deterministic FINDINGS at the bottom (minimal repros), NOT papered
|
|
// over: (F-SWEEP) permissionless unbounded sweepResidual() drains parked
|
|
// settlement funds + bonds to the sink, and (F-LOCK) an unclaimed deposit has
|
|
// no refund path and is locked permanently.
|
|
//
|
|
// 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) : 0x5E77_1E00;
|
|
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;
|
|
|
|
/* ------------------------------- constants -------------------------------- */
|
|
const FEE_BPS = 50n;
|
|
const BPS = 10_000n;
|
|
const CHALLENGE_WINDOW = 6 * 60 * 60; // 6 hours in seconds
|
|
const MAXU = ethers.MaxUint256;
|
|
|
|
async function latestTs() {
|
|
return Number((await ethers.provider.getBlock("latest")).timestamp);
|
|
}
|
|
|
|
// Iteration counts (env-overridable so a quick smoke can shrink them).
|
|
const N = {
|
|
CAMPAIGNS: Number(process.env.HUB_CAMPAIGNS || 6),
|
|
STEPS: Number(process.env.HUB_STEPS || 90),
|
|
};
|
|
|
|
function feeOf(amount) {
|
|
const f = (amount * FEE_BPS) / BPS; // floor
|
|
return { fee: f, parked: amount - f };
|
|
}
|
|
|
|
// =============================================================================
|
|
describe("INVARIANT: AereSettlementHub netting conservation + settle lifecycle", function () {
|
|
this.timeout(0);
|
|
|
|
let signers, hubF, sinkF, erc20F;
|
|
|
|
before(async function () {
|
|
signers = await ethers.getSigners();
|
|
hubF = await ethers.getContractFactory("AereSettlementHub");
|
|
sinkF = await ethers.getContractFactory("MockSinkSimple");
|
|
erc20F = await ethers.getContractFactory("MockERC20Lending");
|
|
console.log(
|
|
` [hub] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} interleaved multi-actor`
|
|
);
|
|
});
|
|
|
|
// Fresh sink + hub + a small basket of assets with varied decimals / caps /
|
|
// restriction. Owner = signers[0].
|
|
async function deployStack(rng) {
|
|
const owner = signers[0];
|
|
const sink = await sinkF.deploy();
|
|
await sink.waitForDeployment();
|
|
const sinkAddr = await sink.getAddress();
|
|
|
|
const hub = await hubF.connect(owner).deploy(sinkAddr);
|
|
await hub.waitForDeployment();
|
|
const hubAddr = await hub.getAddress();
|
|
|
|
// Three assets: A = 18dec unrestricted no-cap high-bond,
|
|
// B = 6dec unrestricted capped low-bond,
|
|
// C = 8dec RESTRICTED-recipient no-cap mid-bond.
|
|
const A = await erc20F.deploy("AssetA", "AA", 18);
|
|
const B = await erc20F.deploy("AssetB", "BB", 6);
|
|
const C = await erc20F.deploy("AssetC", "CC", 8);
|
|
for (const t of [A, B, C]) await t.waitForDeployment();
|
|
|
|
const assets = [
|
|
{ tok: A, addr: await A.getAddress(), dec: 18n, cap: 0n, minBond: 10n ** 21n, restricted: false },
|
|
{ tok: B, addr: await B.getAddress(), dec: 6n, cap: 5n * 10n ** 12n, minBond: 10n ** 6n, restricted: false },
|
|
{ tok: C, addr: await C.getAddress(), dec: 8n, cap: 0n, minBond: 5n * 10n ** 8n, restricted: true },
|
|
];
|
|
|
|
for (const a of assets) {
|
|
await (await hub.connect(owner).listAsset(a.addr, Number(a.dec), a.minBond, a.cap, a.restricted)).wait();
|
|
}
|
|
return { sink, sinkAddr, hub, hubAddr, assets, A, B, C };
|
|
}
|
|
|
|
// Depositors, solvers, recipients (allowlisted for restricted asset C).
|
|
function rolesFrom(signers) {
|
|
return {
|
|
depositors: [signers[1], signers[2], signers[3], signers[4]],
|
|
solvers: [signers[5], signers[6], signers[7]],
|
|
recipients: [signers[10], signers[11]], // allowlisted for restricted asset
|
|
unrestrictedRecipients: [signers[12], signers[13], signers[14]],
|
|
};
|
|
}
|
|
|
|
async function fund(hub, hubAddr, assets, roles) {
|
|
const owner = signers[0];
|
|
const everyone = [...roles.depositors, ...roles.solvers];
|
|
for (const a of assets) {
|
|
// mint a big float to every actor and approve the hub for deposits + bonds.
|
|
for (const s of everyone) {
|
|
await (await a.tok.mint(s.address, a.dec >= 18n ? 10n ** 30n : 10n ** 24n)).wait();
|
|
await (await a.tok.connect(s).approve(hubAddr, MAXU)).wait();
|
|
}
|
|
// allowlist solvers for this asset.
|
|
for (const sv of roles.solvers) {
|
|
await (await hub.connect(owner).setSolverAllowed(a.addr, sv.address, true)).wait();
|
|
}
|
|
// for the restricted asset, allow the restricted-recipient set only.
|
|
if (a.restricted) {
|
|
for (const r of roles.recipients) {
|
|
await (await hub.connect(owner).setRecipientAllowed(a.addr, r.address, true)).wait();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The core invariant battery over the whole basket. `book` is our JS mirror:
|
|
// book.intents: id -> {assetAddr, gross, parked, settled, solver, claimedAt}
|
|
// book.ids: [id...]
|
|
// book.mint[assetAddr]: cumulative minted to actors (for supply reconcile)
|
|
async function assertInvariants(ctx, hub, hubAddr, sinkAddr, assets, roles, book) {
|
|
for (const a of assets) {
|
|
const bal = await a.tok.balanceOf(hubAddr);
|
|
|
|
// parked = sum of unsettled intent amounts on this asset (read from CHAIN).
|
|
let parked = 0n;
|
|
let feeSum = 0n;
|
|
for (const id of book.ids) {
|
|
const rec = book.intents[id];
|
|
if (rec.assetAddr !== a.addr) continue;
|
|
feeSum += rec.gross - rec.parked;
|
|
const on = await hub.intents(id); // [depositor,asset,amount,recipient,open,settled,solver,claimedAt]
|
|
// cross-check chain vs mirror.
|
|
expect(on.settled, `[hub MIRROR-settled] ${ctx} id=${id}`).to.equal(rec.settled);
|
|
expect(on.amount, `[hub MIRROR-amount] ${ctx} id=${id}`).to.equal(rec.parked);
|
|
if (!on.settled) parked += on.amount;
|
|
}
|
|
|
|
// bonds = sum of solverBond over all solvers on this asset (read from CHAIN).
|
|
let bonds = 0n;
|
|
for (const sv of roles.solvers) bonds += await hub.solverBond(a.addr, sv.address);
|
|
|
|
// ---- NETTING CONSERVATION: hub holds exactly parked + bonds. ----
|
|
expect(bal, `[hub CONSERVE] balance != parked(${parked}) + bonds(${bonds}) ${ctx} asset=${a.addr}`)
|
|
.to.equal(parked + bonds);
|
|
|
|
// ---- FEE ACCOUNTING: sink balance for this asset == cumulative fees. ----
|
|
const sinkBal = await a.tok.balanceOf(sinkAddr);
|
|
expect(sinkBal, `[hub FEE-ACCT] sink balance != cumulative fees ${ctx} asset=${a.addr}`)
|
|
.to.equal(feeSum + book.slashed[a.addr]);
|
|
|
|
// ---- SUPPLY RECONCILIATION: nothing minted, nothing burned. ----
|
|
let held = bal + sinkBal;
|
|
for (const s of [...roles.depositors, ...roles.solvers]) held += await a.tok.balanceOf(s.address);
|
|
expect(held, `[hub SUPPLY] token supply not conserved ${ctx} asset=${a.addr}`).to.equal(book.mint[a.addr]);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
it("interleaved deposit/claim/settle/bond/slash: conservation, exact payout, no double-settle", async function () {
|
|
let steps = 0, deposits = 0, bonds = 0, claims = 0, settles = 0, slashes = 0,
|
|
dblGuard = 0, ghostGuard = 0, earlyGuard = 0, unclaimedGuard = 0;
|
|
|
|
for (let c = 0; c < N.CAMPAIGNS; c++) {
|
|
const rng = rngFor("hub", c);
|
|
const { sink, sinkAddr, hub, hubAddr, assets } = await deployStack(rng);
|
|
const roles = rolesFrom(signers);
|
|
await fund(hub, hubAddr, assets, roles);
|
|
|
|
// JS mirror / bookkeeping.
|
|
const book = {
|
|
ids: [],
|
|
intents: {},
|
|
mint: {}, // assetAddr -> total minted to actors
|
|
slashed: {}, // assetAddr -> cumulative slashed-to-sink (adds to sink balance)
|
|
};
|
|
for (const a of assets) {
|
|
book.mint[a.addr] = (a.dec >= 18n ? 10n ** 30n : 10n ** 24n) * BigInt(roles.depositors.length + roles.solvers.length);
|
|
book.slashed[a.addr] = 0n;
|
|
}
|
|
|
|
let idNonce = 0;
|
|
const freshId = () =>
|
|
ethers.keccak256(ethers.toUtf8Bytes(`c${c}:${idNonce++}:${BASE_SEED}`));
|
|
|
|
await assertInvariants(`c${c} init`, hub, hubAddr, sinkAddr, assets, roles, book);
|
|
|
|
for (let s = 0; s < N.STEPS; s++) {
|
|
const action = pick(rng, [
|
|
"deposit", "deposit", "deposit", "bond", "bond",
|
|
"claim", "claim", "settle", "settle", "slash", "advance", "challenge",
|
|
]);
|
|
const ctx = `c${c} s${s} action=${action}`;
|
|
|
|
try {
|
|
if (action === "deposit") {
|
|
const a = pick(rng, assets);
|
|
const depositor = pick(rng, roles.depositors);
|
|
// amount: keep under cap where present; vary scale by decimals.
|
|
let amount;
|
|
const unit = 10n ** a.dec;
|
|
if (chance(rng, 0.2)) amount = BigInt(ri(rng, 1, 500)); // tiny (stress fee=0 floor)
|
|
else amount = BigInt(ri(rng, 1, 100000)) * unit / BigInt(ri(rng, 1, 97));
|
|
if (amount === 0n) amount = 1n;
|
|
if (a.cap !== 0n && amount > a.cap) amount = a.cap;
|
|
|
|
const recipient = a.restricted
|
|
? pick(rng, roles.recipients).address
|
|
: pick(rng, roles.unrestrictedRecipients).address;
|
|
|
|
const id = freshId();
|
|
const { fee, parked } = feeOf(amount);
|
|
const balBefore = await a.tok.balanceOf(hubAddr);
|
|
await (await hub.connect(depositor).deposit(a.addr, amount, id, recipient)).wait();
|
|
const balAfter = await a.tok.balanceOf(hubAddr);
|
|
|
|
// hub balance grew by EXACTLY the parked remainder (fee left to sink).
|
|
expect(balAfter - balBefore, `[hub DEP-DELTA] parked mismatch ${ctx} amount=${amount}`).to.equal(parked);
|
|
const on = await hub.intents(id);
|
|
expect(on.amount, `[hub DEP-AMOUNT] stored != parked ${ctx}`).to.equal(parked);
|
|
expect(on.depositor, `[hub DEP-WHO] ${ctx}`).to.equal(depositor.address);
|
|
|
|
book.ids.push(id);
|
|
book.intents[id] = { assetAddr: a.addr, gross: amount, parked, settled: false, solver: null, claimedAt: 0 };
|
|
deposits++;
|
|
} else if (action === "bond") {
|
|
const a = pick(rng, assets);
|
|
const sv = pick(rng, roles.solvers);
|
|
const amount = BigInt(ri(rng, 1, 100)) * (10n ** a.dec);
|
|
await (await hub.connect(sv).depositSolverBond(a.addr, sv.address, amount)).wait();
|
|
bonds++;
|
|
} else if (action === "claim") {
|
|
// pick an unsettled, unclaimed intent; use an allowlisted solver with enough bond.
|
|
const open = book.ids.filter((id) => {
|
|
const r = book.intents[id];
|
|
return !r.settled && r.claimedAt === 0;
|
|
});
|
|
if (open.length === 0) { steps++; continue; }
|
|
const id = pick(rng, open);
|
|
const rec = book.intents[id];
|
|
const a = assets.find((x) => x.addr === rec.assetAddr);
|
|
// find a solver whose bond >= minBond; if none, top one up first.
|
|
let chosen = null;
|
|
for (const sv of roles.solvers) {
|
|
const b = await hub.solverBond(a.addr, sv.address);
|
|
if (b >= a.minBond) { chosen = sv; break; }
|
|
}
|
|
if (!chosen) {
|
|
chosen = pick(rng, roles.solvers);
|
|
const need = a.minBond;
|
|
await (await hub.connect(chosen).depositSolverBond(a.addr, chosen.address, need)).wait();
|
|
}
|
|
await (await hub.connect(chosen).claim(id, "0x1234")).wait();
|
|
const on = await hub.intents(id);
|
|
rec.claimedAt = Number(on.claimedAt);
|
|
rec.solver = chosen.address;
|
|
claims++;
|
|
|
|
// GUARD: claiming an already-claimed intent must revert.
|
|
if (chance(rng, 0.5)) {
|
|
let reverted = false;
|
|
try { await hub.connect(chosen).claim(id, "0x"); } catch { reverted = true; }
|
|
expect(reverted, `[hub GUARD dbl-claim did not revert] ${ctx} id=${id}`).to.equal(true);
|
|
}
|
|
} else if (action === "settle") {
|
|
const now = await latestTs();
|
|
const ready = book.ids.filter((id) => {
|
|
const r = book.intents[id];
|
|
return !r.settled && r.claimedAt !== 0 && now >= r.claimedAt + CHALLENGE_WINDOW;
|
|
});
|
|
if (ready.length === 0) {
|
|
// Opportunistically prove that a still-in-window claimed intent CANNOT settle.
|
|
const early = book.ids.filter((id) => {
|
|
const r = book.intents[id];
|
|
return !r.settled && r.claimedAt !== 0 && now < r.claimedAt + CHALLENGE_WINDOW;
|
|
});
|
|
if (early.length > 0) {
|
|
const id = pick(rng, early);
|
|
let reverted = false;
|
|
try { await hub.settle(id); } catch { reverted = true; }
|
|
expect(reverted, `[hub GUARD early-settle did not revert] ${ctx} id=${id}`).to.equal(true);
|
|
earlyGuard++;
|
|
}
|
|
steps++; continue;
|
|
}
|
|
const id = pick(rng, ready);
|
|
const rec = book.intents[id];
|
|
const a = assets.find((x) => x.addr === rec.assetAddr);
|
|
const solverBalBefore = await a.tok.balanceOf(rec.solver);
|
|
const hubBalBefore = await a.tok.balanceOf(hubAddr);
|
|
await (await hub.connect(pick(rng, roles.depositors)).settle(id)).wait();
|
|
const solverBalAfter = await a.tok.balanceOf(rec.solver);
|
|
const hubBalAfter = await a.tok.balanceOf(hubAddr);
|
|
|
|
// PAYOUT EXACTNESS: solver received EXACTLY parked; hub dropped EXACTLY parked.
|
|
expect(solverBalAfter - solverBalBefore, `[hub PAYOUT] solver got != parked ${ctx} id=${id}`).to.equal(rec.parked);
|
|
expect(hubBalBefore - hubBalAfter, `[hub PAYOUT-HUB] hub dropped != parked ${ctx} id=${id}`).to.equal(rec.parked);
|
|
|
|
rec.settled = true;
|
|
settles++;
|
|
|
|
// GUARD: settling the same intent again must revert (no double payout).
|
|
let reverted = false;
|
|
try { await hub.settle(id); } catch { reverted = true; }
|
|
expect(reverted, `[hub GUARD double-settle did not revert] ${ctx} id=${id}`).to.equal(true);
|
|
dblGuard++;
|
|
} else if (action === "slash") {
|
|
const a = pick(rng, assets);
|
|
const sv = pick(rng, roles.solvers);
|
|
const cur = await hub.solverBond(a.addr, sv.address);
|
|
if (cur === 0n) { steps++; continue; }
|
|
const amt = cur === 1n ? 1n : BigInt(ri(rng, 1, Number(cur > 1_000_000n ? 1_000_000n : cur))) * cur / (cur > 1_000_000n ? 1_000_000n : cur);
|
|
const slashAmt = amt === 0n ? 1n : (amt > cur ? cur : amt);
|
|
await (await hub.connect(signers[0]).slashSolverBond(a.addr, sv.address, slashAmt)).wait();
|
|
book.slashed[a.addr] += slashAmt; // slashed funds land in the sink
|
|
slashes++;
|
|
} else if (action === "advance") {
|
|
await time.increase(ri(rng, 60, CHALLENGE_WINDOW + 2 * 3600)); // sometimes past the window
|
|
await network.provider.send("evm_mine");
|
|
} else if (action === "challenge") {
|
|
// challenge only emits an event on a claimed, in-window intent. Exercise it.
|
|
const now = await latestTs();
|
|
const inWin = book.ids.filter((id) => {
|
|
const r = book.intents[id];
|
|
return !r.settled && r.claimedAt !== 0 && now < r.claimedAt + CHALLENGE_WINDOW;
|
|
});
|
|
if (inWin.length > 0) {
|
|
await (await hub.challenge(pick(rng, inWin), "fuzz-reason")).wait();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (/hub [A-Z]/.test(String(e.message))) throw e; // never swallow an invariant break
|
|
// Legal reverts on edge inputs (rounding to fee=0, cap clamps, etc.).
|
|
}
|
|
|
|
// Occasional GHOST-settle guard: a random never-created id must not settle.
|
|
if (chance(rng, 0.15)) {
|
|
const ghost = ethers.keccak256(ethers.toUtf8Bytes(`ghost:${c}:${s}:${idNonce}`));
|
|
let reverted = false;
|
|
try { await hub.settle(ghost); } catch { reverted = true; }
|
|
expect(reverted, `[hub GUARD ghost-settle did not revert] ${ctx}`).to.equal(true);
|
|
ghostGuard++;
|
|
}
|
|
|
|
// Occasional UNCLAIMED-settle guard: a deposited-but-never-claimed intent must not settle.
|
|
if (chance(rng, 0.15)) {
|
|
const unclaimed = book.ids.filter((id) => {
|
|
const r = book.intents[id];
|
|
return !r.settled && r.claimedAt === 0;
|
|
});
|
|
if (unclaimed.length > 0) {
|
|
let reverted = false;
|
|
try { await hub.settle(pick(rng, unclaimed)); } catch { reverted = true; }
|
|
expect(reverted, `[hub GUARD unclaimed-settle did not revert] ${ctx}`).to.equal(true);
|
|
unclaimedGuard++;
|
|
}
|
|
}
|
|
|
|
await assertInvariants(ctx, hub, hubAddr, sinkAddr, assets, roles, book);
|
|
steps++;
|
|
}
|
|
|
|
// ---- finale: advance well past every window and settle everything claimed. ----
|
|
await time.increase(CHALLENGE_WINDOW + 8 * 3600);
|
|
await network.provider.send("evm_mine");
|
|
for (const id of book.ids) {
|
|
const rec = book.intents[id];
|
|
if (!rec.settled && rec.claimedAt !== 0) {
|
|
const a = assets.find((x) => x.addr === rec.assetAddr);
|
|
const before = await a.tok.balanceOf(rec.solver);
|
|
await (await hub.settle(id)).wait();
|
|
expect((await a.tok.balanceOf(rec.solver)) - before, `[hub FINALE-PAYOUT] c${c} id=${id}`).to.equal(rec.parked);
|
|
rec.settled = true;
|
|
}
|
|
}
|
|
await assertInvariants(`c${c} finale`, hub, hubAddr, sinkAddr, assets, roles, book);
|
|
}
|
|
|
|
console.log(
|
|
` [hub] steps=${steps} dep=${deposits} bond=${bonds} claim=${claims} settle=${settles} ` +
|
|
`slash=${slashes} | guards: dbl=${dblGuard} ghost=${ghostGuard} early=${earlyGuard} unclaimed=${unclaimedGuard}`
|
|
);
|
|
expect(steps).to.be.greaterThan(400);
|
|
expect(deposits).to.be.greaterThan(60);
|
|
expect(settles).to.be.greaterThan(10);
|
|
expect(dblGuard + ghostGuard).to.be.greaterThan(0);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// FINDING (F-SWEEP): sweepResidual() is permissionless AND unbounded. Its
|
|
// NatSpec intent is to re-flush ONLY residual fees that could not reach the
|
|
// sink on their first attempt. But it accepts an ARBITRARY `amount` from ANY
|
|
// caller and simply approves+flushes it to the sink. There is no accounting of
|
|
// what is actually "residual" versus committed. When the sink accepts (the
|
|
// normal case), a griefer can sweep the parked settlement funds (money owed to
|
|
// a solver for a real, claimed intent) and/or posted solver bonds straight into
|
|
// the sink. The hub becomes insolvent: the honest settle() then reverts
|
|
// TransferFailed and the depositor's funds are gone (burned into the sink),
|
|
// never delivered. Reachable by any EOA on mainnet.
|
|
it("FINDING (F-SWEEP): permissionless unbounded sweepResidual drains parked funds + bonds, bricks settle", async function () {
|
|
const [owner, depositor, solver, griefer, recip] = signers;
|
|
const { sink, sinkAddr, hub, hubAddr } = await deployStack();
|
|
|
|
// one unrestricted 18-dec asset, no cap, minBond = 1e18.
|
|
const T = await erc20F.deploy("Tok", "TK", 18);
|
|
await T.waitForDeployment();
|
|
const tokAddr = await T.getAddress();
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait();
|
|
await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait();
|
|
|
|
for (const s of [depositor, solver, griefer]) {
|
|
await (await T.mint(s.address, 10n ** 24n)).wait();
|
|
await (await T.connect(s).approve(hubAddr, MAXU)).wait();
|
|
}
|
|
|
|
// A real settlement in flight: depositor deposits 1000e18, solver bonds + claims.
|
|
const amount = 1000n * 10n ** 18n;
|
|
const { fee, parked } = feeOf(amount);
|
|
const id = ethers.keccak256(ethers.toUtf8Bytes("sweep-victim"));
|
|
await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait();
|
|
await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, 10n ** 18n)).wait();
|
|
await (await hub.connect(solver).claim(id, "0xabcd")).wait();
|
|
|
|
// Hub now holds parked (995e18) + bond (1e18). The fee (5e18) already left to the sink.
|
|
const heldBefore = await T.balanceOf(hubAddr);
|
|
expect(heldBefore, "[F-SWEEP precheck] hub holdings").to.equal(parked + 10n ** 18n);
|
|
const sinkBefore = await T.balanceOf(sinkAddr);
|
|
|
|
// GRIEF: anyone calls sweepResidual for the FULL hub balance. Nothing was
|
|
// actually residual (the sink already took the fee), yet it flushes the
|
|
// solver's parked settlement money + the bond into the sink.
|
|
await (await hub.connect(griefer).sweepResidual(tokAddr, heldBefore)).wait();
|
|
|
|
const heldAfter = await T.balanceOf(hubAddr);
|
|
const sinkAfter = await T.balanceOf(sinkAddr);
|
|
expect(heldAfter, "[F-SWEEP] hub not drained").to.equal(0n);
|
|
expect(sinkAfter - sinkBefore, "[F-SWEEP] funds did not move to sink").to.equal(heldBefore);
|
|
|
|
// The intent is still live and claimed; advance past the window and settle.
|
|
// It now reverts because the hub has no tokens left: the depositor's money is
|
|
// gone (burned to the sink) and the solver is never paid.
|
|
await time.increase(CHALLENGE_WINDOW + 60);
|
|
await network.provider.send("evm_mine");
|
|
let settleReverted = false;
|
|
try { await hub.settle(id); } catch { settleReverted = true; }
|
|
expect(settleReverted, "[F-SWEEP] settle unexpectedly succeeded after drain").to.equal(true);
|
|
|
|
// The intent is permanently unsettleable (funds neither with solver nor refundable).
|
|
const on = await hub.intents(id);
|
|
expect(on.settled, "[F-SWEEP] intent should remain unsettled/stuck").to.equal(false);
|
|
|
|
console.log(
|
|
` [hub F-SWEEP] drained=${ethers.formatEther(heldBefore)} tokens to sink; ` +
|
|
`settle now reverts -> depositor funds burned, solver unpaid, intent bricked.`
|
|
);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// FINDING (F-LOCK): there is NO refund / cancel / timeout path for a deposited
|
|
// intent that no solver ever claims. deposit() pulls the funds and parks them;
|
|
// the ONLY exit is settle(), which requires a prior claim() by an allowlisted
|
|
// bonded solver. If no solver ever claims (asset paused, solver set removed,
|
|
// or simply nobody bothers), the depositor's parked funds are locked forever.
|
|
// Not a value mint, but a permanent-lock / liveness defect worth recording.
|
|
it("FINDING (F-LOCK): an unclaimed deposit has no refund path and is locked permanently", async function () {
|
|
const [owner, depositor, , , recip] = signers;
|
|
const { hub, hubAddr } = await deployStack();
|
|
|
|
const T = await erc20F.deploy("Tok", "TK", 18);
|
|
await T.waitForDeployment();
|
|
const tokAddr = await T.getAddress();
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait();
|
|
await (await T.mint(depositor.address, 10n ** 24n)).wait();
|
|
await (await T.connect(depositor).approve(hubAddr, MAXU)).wait();
|
|
|
|
const amount = 500n * 10n ** 18n;
|
|
const { parked } = feeOf(amount);
|
|
const id = ethers.keccak256(ethers.toUtf8Bytes("lock-victim"));
|
|
const balBefore = await T.balanceOf(depositor.address);
|
|
await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait();
|
|
|
|
// Depositor is out `amount`; hub holds `parked`. No solver claims (ever).
|
|
expect(balBefore - (await T.balanceOf(depositor.address)), "[F-LOCK] depositor debit").to.equal(amount);
|
|
|
|
// Time passes arbitrarily. There is no cancel()/refund()/withdraw() to call.
|
|
await time.increase(365 * 24 * 3600);
|
|
await network.provider.send("evm_mine");
|
|
|
|
// settle() cannot run (never claimed): the funds are stuck in the hub.
|
|
let reverted = false;
|
|
try { await hub.settle(id); } catch { reverted = true; }
|
|
expect(reverted, "[F-LOCK] settle should revert on an unclaimed intent").to.equal(true);
|
|
expect(await T.balanceOf(hubAddr), "[F-LOCK] parked funds still trapped in hub").to.equal(parked);
|
|
|
|
// Confirm the contract truly exposes no depositor-side exit (surface check).
|
|
const fns = hub.interface.fragments.filter((f) => f.type === "function").map((f) => f.name);
|
|
for (const forbidden of ["cancel", "refund", "reclaim", "withdrawIntent"]) {
|
|
expect(fns.includes(forbidden), `[F-LOCK] unexpected refund fn ${forbidden}`).to.equal(false);
|
|
}
|
|
|
|
console.log(
|
|
` [hub F-LOCK] deposited ${ethers.formatEther(amount)}, ${ethers.formatEther(parked)} parked; ` +
|
|
`no claim ever -> no refund path exists, funds locked permanently.`
|
|
);
|
|
});
|
|
});
|