aere-contracts/test/settlementhub-netting-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

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