// ============================================================================= // SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for AereStateChannels // (live 0x64488eda27fA5b55A277ac8D386E169DC78cd7b6, 24h challenge window). // // Bidirectional ERC-20 payment channels + HTLC. This drives long random // multi-actor, multi-channel sequences (open / cooperativeClose / startChallenge // / overrideChallenge / settleHtlc / finalize / withdraw / time jumps) against a // SHARED contract holding several channels of one token, asserting the core // invariants after EVERY step: // // I1 GLOBAL SOLVENCY contract token balance == sum of every channel's owed // funds (open/challenged -> whole deposit is locked; // finalised -> unwithdrawn allocation). A cross-channel // drain or any mint breaks this exact equality. // I2 NO MINT per channel, finalBalanceA + finalBalanceB <= depositA + // depositB after any close (never creates value). // I3 WITHDRAW CAP a party withdraws EXACTLY its last-signed allocation, // once; a re-withdraw pays 0; non-party reverts. // I4 STALE<-NEWER during the challenge window a stale (<= latest nonce) // signed state can NOT override a strictly-newer one. // I5 HTLC EXCLUSIVITY locked funds are claimable only with the correct // preimage before timeout, and refundable only after // (finalize), never both. // I6 REENTRANCY/CEI withdraw zeroes the allocation before transferring and // is nonReentrant; the exact global-solvency equality // across interleaved withdrawals leaves no drain path. // // TOOLCHAIN: hardhat-based seeded fuzzing (Foundry/forge not installed). Every // random choice comes from mulberry32(seed); seed printed at start + pinnable via // FUZZ_SEED. Each assertion embeds campaign/step/actor/values for a minimal repro. // // SCOPE: run in ISOLATION (the full suite OOM/segfaults on the ML-DSA KAT tests): // npx hardhat test test/invariant-statechannels-property.test.js // One new test file. No contract-logic change, no deploy, no live node. Reuses // the existing MockERC20Lending mock (standard OZ ERC20, bool-returning transfers). // ============================================================================= 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) : 0x5C2800AE; 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; /* ----------------------------- misc helpers ------------------------------- */ const E = (n) => ethers.parseEther(String(n)); const rand32 = (rng) => "0x" + Array.from({ length: 32 }, () => ri(rng, 0, 255).toString(16).padStart(2, "0")).join(""); async function now() { return BigInt(await time.latest()); } const N = { CAMPAIGNS: Number(process.env.SC_CAMPAIGNS || 16), STEPS: Number(process.env.SC_STEPS || 55), }; const Status = { None: 0, Open: 1, Challenged: 2, Finalised: 3 }; describe("INVARIANT: AereStateChannels bidirectional channels + HTLC", function () { this.timeout(0); let signers, tokenF, scF, chainId; before(async function () { signers = await ethers.getSigners(); tokenF = await ethers.getContractFactory("MockERC20Lending"); scF = await ethers.getContractFactory("AereStateChannels"); chainId = Number((await ethers.provider.getNetwork()).chainId); console.log( ` [sc] base seed 0x${BASE_SEED.toString(16)}, ${N.CAMPAIGNS} campaigns x ${N.STEPS} steps, chainId ${chainId}` ); }); // EIP-712 types mirroring STATE_TYPEHASH exactly. const TYPES = { ChannelState: [ { name: "channelId", type: "bytes32" }, { name: "nonce", type: "uint256" }, { name: "balanceA", type: "uint256" }, { name: "balanceB", type: "uint256" }, { name: "htlcLockedFromA", type: "uint256" }, { name: "htlcLockedFromB", type: "uint256" }, { name: "lockHash", type: "bytes32" }, { name: "lockTimeout", type: "uint256" }, ], }; function domainFor(addr) { return { name: "AereStateChannels", version: "1", chainId, verifyingContract: addr }; } function structFor(st) { return { channelId: st.channelId, nonce: st.nonce, balanceA: st.balanceA, balanceB: st.balanceB, htlcLockedFromA: st.htlcA, htlcLockedFromB: st.htlcB, lockHash: st.lockHash, lockTimeout: st.lockTimeout, }; } async function signState(domain, partyA, partyB, st) { const v = structFor(st); const sigA = await partyA.signTypedData(domain, TYPES, v); const sigB = await partyB.signTypedData(domain, TYPES, v); return { struct: v, sigA, sigB }; } // Generate a balances-conserving signed state at nonce `nonce`. Optionally carve // an HTLC out of A's and/or B's shares. lockTimeout is baked as an absolute unix // ts (genTime + offset) so both "settleable" and "already-expired" paths arise. async function makeSignedState(rng, ch, domain, nonce) { const total = ch.depA + ch.depB; let a = BigInt(ri(rng, 0, Number(total))); let b = total - a; let htlcA = 0n, htlcB = 0n, lockHash = ethers.ZeroHash, lockTimeout = 0n, preimage = null; if (chance(rng, 0.45)) { const ha = a > 0n ? BigInt(ri(rng, 0, Number(a))) : 0n; const hb = b > 0n ? BigInt(ri(rng, 0, Number(b))) : 0n; if (ha + hb > 0n) { a -= ha; b -= hb; htlcA = ha; htlcB = hb; preimage = rand32(rng); lockHash = ethers.keccak256(preimage); const off = ri(rng, -30, 4000); // may be already-past to force expiry lockTimeout = (await now()) + BigInt(off); if (lockTimeout < 0n) lockTimeout = 0n; } } const st = { channelId: ch.id, nonce: BigInt(nonce), balanceA: a, balanceB: b, htlcA, htlcB, lockHash, lockTimeout, preimage }; const signed = await signState(domain, ch.pA, ch.pB, st); st.sigA = signed.sigA; st.sigB = signed.sigB; st.struct = signed.struct; return st; } // Owed funds still held by the contract for this channel. function owed(ch) { if (ch.status === Status.Open || ch.status === Status.Challenged) return ch.depA + ch.depB; if (ch.status === Status.Finalised) return ch.remA + ch.remB; return 0n; } it("global solvency, no-mint, withdraw-cap, stale-override & HTLC exclusivity hold over random sequences", async function () { let steps = 0; const tally = { opens: 0, coopClose: 0, startChal: 0, override: 0, overrideStaleBlocked: 0, overrideExpiredBlocked: 0, settleOk: 0, settleWrongPre: 0, settleExpired: 0, settleAlready: 0, finalizeSettled: 0, finalizeRefund: 0, finalizeNoHtlc: 0, finalizeWindowOpen: 0, withdraws: 0, reWithdrawZero: 0, nonPartyBlocked: 0, solvencyChecks: 0, }; for (let c = 0; c < N.CAMPAIGNS; c++) { const rng = rngFor("sc", c); const token = await tokenF.deploy("Mock", "MCK", 18); await token.waitForDeployment(); const tokenAddr = await token.getAddress(); const window = BigInt(ri(rng, 60, 3 * 24 * 3600)); // 1min .. 3 days const sc = await scF.deploy(window); await sc.waitForDeployment(); const scAddr = await sc.getAddress(); const domain = domainFor(scAddr); // Actor pool: 6 funded signers, all approve the channel contract. const actors = signers.slice(1, 7); for (const a of actors) { await (await token.mint(a.address, E("1000000000"))).wait(); await (await token.connect(a).approve(scAddr, ethers.MaxUint256)).wait(); } const outsider = signers[8]; // never a channel party // Open a handful of channels between distinct actor pairs. const channels = []; const nCh = ri(rng, 2, 4); for (let k = 0; k < nCh; k++) { const i = ri(rng, 0, actors.length - 1); let j = ri(rng, 0, actors.length - 1); if (j === i) j = (j + 1) % actors.length; const pA = actors[i], pB = actors[j]; const depA = E(ri(rng, 0, 500)); let depB = E(ri(rng, 0, 500)); if (depA + depB === 0n) depB = E(ri(rng, 1, 500)); // open() rejects zero total const rc = await (await sc.connect(pA).open(pA.address, pB.address, tokenAddr, depA, depB)).wait(); // Recover channelId from the Opened event. let id = null; for (const log of rc.logs) { try { const parsed = sc.interface.parseLog(log); if (parsed && parsed.name === "Opened") id = parsed.args.channelId; } catch (_) {} } expect(id, `[sc OPEN] no Opened event (c${c} k${k})`).to.not.equal(null); const ch = { id, pA, pB, depA, depB, status: Status.Open, latestNonce: 0n, nextNonce: 1, signed: [], // pre-signed states (nonce >= 1) challenged: null, // the state used to challenge (model copy) htlcResolved: false, // on-chain htlcResolved persists once set (mirrors contract) resolvedHash: null, // the lockHash that settleHtlc recorded finalA: null, finalB: null, remA: 0n, remB: 0n, }; // Pre-sign a chain of states. const m = ri(rng, 1, 5); for (let s = 1; s <= m; s++) ch.signed.push(await makeSignedState(rng, ch, domain, s)); ch.nextNonce = m + 1; channels.push(ch); tally.opens++; } async function assertSolvency(tag) { const bal = await token.balanceOf(scAddr); let sum = 0n; for (const ch of channels) sum += owed(ch); expect(bal, `[sc SOLVENCY] token balance != sum owed after ${tag} (c${c} s${steps}) bal=${bal} owed=${sum}`).to.equal(sum); tally.solvencyChecks++; } await assertSolvency("open-all"); for (let s = 0; s < N.STEPS; s++) { const ch = pick(rng, channels); const ctx = `c${c} s${s} ch=${ch.id.slice(0, 10)} status=${ch.status}`; if (ch.status === Status.Open) { // Either cooperative-close or start a challenge with some signed state. if (chance(rng, 0.4)) { // cooperativeClose with the latest signed state (both sigs). const st = ch.signed[ch.signed.length - 1]; await (await sc.connect(ch.pA).cooperativeClose(st.struct, st.sigA, st.sigB)).wait(); const [fa, fb] = coopFinal(st); ch.status = Status.Finalised; ch.finalA = fa; ch.finalB = fb; ch.remA = fa; ch.remB = fb; ch.latestNonce = st.nonce; expect(fa + fb, `[sc NO-MINT coop] over deposits — ${ctx}`).to.be.lte(ch.depA + ch.depB); tally.coopClose++; } else { // startChallenge with a possibly-stale state (random nonce index). const st = pick(rng, ch.signed); await (await sc.connect(ch.pA).startChallenge(st.struct, st.sigA, st.sigB)).wait(); ch.status = Status.Challenged; ch.challenged = st; ch.latestNonce = st.nonce; // Fresh challenge episode: on-chain the channel was never resolved. ch.htlcResolved = false; ch.resolvedHash = null; ch.challengeStart = await now(); tally.startChal++; } } else if (ch.status === Status.Challenged) { const t = await now(); const deadline = ch.challengeStart + window; const windowOpen = t < deadline; const sub = ri(rng, 0, 4); if (sub === 0 && windowOpen) { // Override attempt with a random signed state (may be stale or newer). const st = pick(rng, ch.signed); if (st.nonce > ch.latestNonce) { await (await sc.connect(ch.pB).overrideChallenge(st.struct, st.sigA, st.sigB)).wait(); ch.challenged = st; ch.latestNonce = st.nonce; // On-chain htlcResolved / settledHtlcLockHash are NOT touched by an // override, so the model must not reset them either. finalize later // reconciles: settled iff resolvedHash == new challenged lockHash. tally.override++; } else { // I4: stale (<= latest) must be rejected. await expect( sc.connect(ch.pB).overrideChallenge(st.struct, st.sigA, st.sigB), `[sc I4 STALE] stale override not rejected — ${ctx} n=${st.nonce} latest=${ch.latestNonce}` ).to.be.revertedWithCustomError(sc, "NonceNotHigher"); tally.overrideStaleBlocked++; } } else if (sub === 0 && !windowOpen) { // Window closed: any override reverts WindowExpired (checked before nonce). const st = pick(rng, ch.signed); await expect( sc.connect(ch.pB).overrideChallenge(st.struct, st.sigA, st.sigB), `[sc OVERRIDE-EXPIRED] — ${ctx}` ).to.be.revertedWithCustomError(sc, "WindowExpired"); tally.overrideExpiredBlocked++; } else if (sub === 1) { // HTLC settle probe on the challenged state. Contract check order: // status -> htlcResolved -> lockTimeout -> preimage. const cs = ch.challenged; if (ch.htlcResolved) { // Global resolve flag already set (this episode) -> always blocks. await expect( sc.settleHtlc(ch.id, cs.lockHash === ethers.ZeroHash ? "0x1234" : cs.preimage), `[sc SETTLE-ALREADY] — ${ctx}` ).to.be.revertedWithCustomError(sc, "HtlcAlreadyResolved"); tally.settleAlready++; } else if (cs.lockHash === ethers.ZeroHash) { // No active HTLC: latestLockTimeout==0 < now -> LockExpired. await expect( sc.settleHtlc(ch.id, "0x1234"), `[sc SETTLE-NOHTLC] — ${ctx}` ).to.be.revertedWithCustomError(sc, "LockExpired"); } else if (t > cs.lockTimeout) { // I5: past timeout -> not claimable (refund-only from here). await expect( sc.settleHtlc(ch.id, cs.preimage), `[sc I5 EXPIRED] settle after timeout succeeded — ${ctx} t=${t} to=${cs.lockTimeout}` ).to.be.revertedWithCustomError(sc, "LockExpired"); tally.settleExpired++; } else if (chance(rng, 0.5)) { // Wrong preimage, in time -> mismatch. let wrong = rand32(rng); while (ethers.keccak256(wrong) === cs.lockHash) wrong = rand32(rng); await expect( sc.settleHtlc(ch.id, wrong), `[sc SETTLE-WRONGPRE] — ${ctx}` ).to.be.revertedWithCustomError(sc, "PreimageMismatch"); tally.settleWrongPre++; } else { // Correct preimage, in time -> settles (once). await (await sc.settleHtlc(ch.id, cs.preimage)).wait(); ch.htlcResolved = true; ch.resolvedHash = cs.lockHash; tally.settleOk++; } } else if (sub === 2) { // Advance time (sometimes past the window / lock). const jump = chance(rng, 0.5) ? Number(window) + ri(rng, 1, 50) : ri(rng, 1, Number(window)); await time.increase(jump); } else if (sub === 3 || sub === 4) { // finalize (expect revert if window still open). const t2 = await now(); if (t2 < ch.challengeStart + window) { await expect(sc.finalize(ch.id), `[sc FINALIZE-EARLY] — ${ctx}`).to.be.revertedWithCustomError(sc, "WindowOpen"); tally.finalizeWindowOpen++; } else { await (await sc.finalize(ch.id)).wait(); const [fa, fb, kind] = finalizeFinal(ch); ch.status = Status.Finalised; ch.finalA = fa; ch.finalB = fb; ch.remA = fa; ch.remB = fb; expect(fa + fb, `[sc NO-MINT finalize] over deposits — ${ctx}`).to.be.lte(ch.depA + ch.depB); // conservation is exact for finalize (no stuck-fund path). expect(fa + fb, `[sc CONSERVE finalize] != deposits — ${ctx}`).to.equal(ch.depA + ch.depB); if (kind === "settled") tally.finalizeSettled++; else if (kind === "refund") tally.finalizeRefund++; else tally.finalizeNoHtlc++; } } } else if (ch.status === Status.Finalised) { // Withdraw / re-withdraw / non-party probe. I3 + I6. const who = ri(rng, 0, 2); if (who === 2) { await expect(sc.connect(outsider).withdraw(ch.id), `[sc NONPARTY] — ${ctx}`).to.be.revertedWithCustomError(sc, "NotParticipant"); tally.nonPartyBlocked++; } else { const party = who === 0 ? ch.pA : ch.pB; const rem = who === 0 ? ch.remA : ch.remB; const before = await token.balanceOf(party.address); await (await sc.connect(party).withdraw(ch.id)).wait(); const after = await token.balanceOf(party.address); expect(after - before, `[sc I3 WITHDRAW] payout != allocation — ${ctx} rem=${rem}`).to.equal(rem); if (rem > 0n) tally.withdraws++; else tally.reWithdrawZero++; if (who === 0) ch.remA = 0n; else ch.remB = 0n; } } await assertSolvency(`step${s}`); steps++; } // Drain everything at campaign end so a lingering over/under-credit surfaces // in the final exact-solvency check. for (const ch of channels) { if (ch.status === Status.Challenged) { const t = await now(); const need = ch.challengeStart + window; if (t < need) await time.increase(Number(need - t) + 1); await (await sc.finalize(ch.id)).wait(); const [fa, fb] = finalizeFinal(ch); ch.status = Status.Finalised; ch.remA = fa; ch.remB = fb; } if (ch.status === Status.Finalised) { if (ch.remA > 0n) { const b0 = await token.balanceOf(ch.pA.address); await (await sc.connect(ch.pA).withdraw(ch.id)).wait(); expect((await token.balanceOf(ch.pA.address)) - b0, `[sc FINALE A] c${c}`).to.equal(ch.remA); ch.remA = 0n; } if (ch.remB > 0n) { const b0 = await token.balanceOf(ch.pB.address); await (await sc.connect(ch.pB).withdraw(ch.id)).wait(); expect((await token.balanceOf(ch.pB.address)) - b0, `[sc FINALE B] c${c}`).to.equal(ch.remB); ch.remB = 0n; } } await assertSolvency("finale-drain"); } // Every fully-drained + still-open channel accounted: contract holds exactly // the deposits of channels still Open (never challenged/closed this run). let stillLocked = 0n; for (const ch of channels) if (ch.status === Status.Open) stillLocked += ch.depA + ch.depB; expect(await token.balanceOf(scAddr), `[sc END] residual != still-open deposits (c${c})`).to.equal(stillLocked); } console.log(` [sc] steps=${steps} solvencyChecks=${tally.solvencyChecks}`); console.log(` [sc] opens=${tally.opens} coopClose=${tally.coopClose} startChal=${tally.startChal} override=${tally.override}`); console.log( ` [sc] I4 staleBlocked=${tally.overrideStaleBlocked} expiredOverrideBlocked=${tally.overrideExpiredBlocked}` ); console.log( ` [sc] settleOk=${tally.settleOk} wrongPre=${tally.settleWrongPre} expiredSettle=${tally.settleExpired} already=${tally.settleAlready}` ); console.log( ` [sc] finalize settled=${tally.finalizeSettled} refund=${tally.finalizeRefund} noHtlc=${tally.finalizeNoHtlc} windowOpen=${tally.finalizeWindowOpen}` ); console.log(` [sc] withdraws=${tally.withdraws} reWithdraw0=${tally.reWithdrawZero} nonPartyBlocked=${tally.nonPartyBlocked}`); // Coverage floors: prove the sequences actually exercised the hard paths. expect(steps).to.be.greaterThan(700); expect(tally.solvencyChecks).to.be.greaterThan(700); expect(tally.startChal).to.be.greaterThan(20); expect(tally.overrideStaleBlocked, "stale-override (I4) never exercised").to.be.greaterThan(0); expect(tally.settleOk, "HTLC settle-ok never exercised").to.be.greaterThan(0); expect(tally.settleExpired + tally.settleWrongPre, "HTLC negative paths never exercised").to.be.greaterThan(0); expect(tally.finalizeSettled + tally.finalizeRefund, "HTLC finalize resolution never exercised").to.be.greaterThan(0); expect(tally.withdraws, "no successful withdrawals").to.be.greaterThan(10); }); // Cooperative-close final allocation (model mirror of the contract). function coopFinal(st) { if (st.lockHash !== ethers.ZeroHash) return [st.balanceA + st.htlcA, st.balanceB + st.htlcB]; return [st.balanceA, st.balanceB]; } // finalize final allocation (model mirror), returns [a, b, kind]. function finalizeFinal(ch) { const st = ch.challenged; if (st.lockHash === ethers.ZeroHash) return [st.balanceA, st.balanceB, "nohtlc"]; // Mirror the contract: settled iff a recorded resolve hash matches the CURRENT // challenged lockHash (an override to a different hash falls back to refund). const settled = ch.htlcResolved && ch.resolvedHash === st.lockHash; if (settled) return [st.balanceA + st.htlcB, st.balanceB + st.htlcA, "settled"]; return [st.balanceA + st.htlcA, st.balanceB + st.htlcB, "refund"]; } // ------------------------------------------------------------------------ // DIRECTED: HTLC claim-XOR-refund exclusivity, both directions, both outcomes. // ------------------------------------------------------------------------ it("HTLC: preimage-before-timeout claims to receiver; timeout refunds funder; never both", async function () { const token = await tokenF.deploy("Mock", "MCK", 18); await token.waitForDeployment(); const tokenAddr = await token.getAddress(); const window = 3600n; const [_, A, B] = signers; for (const a of [A, B]) { await (await token.mint(a.address, E("1000000"))).wait(); } async function scenario(settleInTime) { const sc = await scF.deploy(window); await sc.waitForDeployment(); const scAddr = await sc.getAddress(); await (await token.connect(A).approve(scAddr, ethers.MaxUint256)).wait(); await (await token.connect(B).approve(scAddr, ethers.MaxUint256)).wait(); const domain = domainFor(scAddr); const depA = E(100), depB = E(60); const rc = await (await sc.connect(A).open(A.address, B.address, tokenAddr, depA, depB)).wait(); let id = null; for (const log of rc.logs) { try { const p = sc.interface.parseLog(log); if (p && p.name === "Opened") id = p.args.channelId; } catch (_) {} } // State nonce 1: A pays B 10 via HTLC (htlcLockedFromA = 10), rest split. const preimage = ethers.keccak256("0x1337beef"); const lockHash = ethers.keccak256(preimage); const lockTimeout = (await now()) + 1800n; // inside the window const st = { channelId: id, nonce: 1n, balanceA: E(90), balanceB: E(60), htlcA: E(10), htlcB: 0n, lockHash, lockTimeout, preimage, }; const signed = await signState(domain, A, B, st); st.struct = signed.struct; st.sigA = signed.sigA; st.sigB = signed.sigB; await (await sc.connect(A).startChallenge(st.struct, st.sigA, st.sigB)).wait(); if (settleInTime) { await (await sc.connect(B).settleHtlc(id, preimage)).wait(); // A second settle is rejected. await expect(sc.settleHtlc(id, preimage)).to.be.revertedWithCustomError(sc, "HtlcAlreadyResolved"); } else { // Jump past lockTimeout; settle now reverts (refund-only from here). await time.increaseTo(Number(lockTimeout) + 1); await expect(sc.settleHtlc(id, preimage)).to.be.revertedWithCustomError(sc, "LockExpired"); } // Finalize after the challenge window. await time.increase(Number(window) + 5); await (await sc.finalize(id)).wait(); const b0A = await token.balanceOf(A.address); const b0B = await token.balanceOf(B.address); await (await sc.connect(A).withdraw(id)).wait(); await (await sc.connect(B).withdraw(id)).wait(); const gotA = (await token.balanceOf(A.address)) - b0A; const gotB = (await token.balanceOf(B.address)) - b0B; // Conservation: exactly the deposits are paid out, no more. expect(gotA + gotB, `[HTLC CONSERVE settle=${settleInTime}]`).to.equal(depA + depB); // Contract fully drained. expect(await token.balanceOf(scAddr), `[HTLC DRAIN settle=${settleInTime}]`).to.equal(0n); if (settleInTime) { // Receiver B got the 10 HTLC on top of its 60. expect(gotB, "[HTLC settled -> receiver]").to.equal(E(70)); expect(gotA, "[HTLC settled -> funder loses lock]").to.equal(E(90)); } else { // Refund: funder A keeps its 10 back. expect(gotA, "[HTLC expired -> funder refunded]").to.equal(E(100)); expect(gotB, "[HTLC expired -> receiver gets nothing extra]").to.equal(E(60)); } } await scenario(true); await scenario(false); }); // ------------------------------------------------------------------------ // DIRECTED: a strictly-newer signed state ALWAYS beats a stale one submitted // first, and a stale override can never roll a channel back (I4). // ------------------------------------------------------------------------ it("stale signed state cannot override a strictly-newer one during the challenge window", async function () { const token = await tokenF.deploy("Mock", "MCK", 18); await token.waitForDeployment(); const tokenAddr = await token.getAddress(); const window = 7200n; const [_, A, B] = signers; await (await token.mint(A.address, E("1000000"))).wait(); await (await token.mint(B.address, E("1000000"))).wait(); const sc = await scF.deploy(window); await sc.waitForDeployment(); const scAddr = await sc.getAddress(); await (await token.connect(A).approve(scAddr, ethers.MaxUint256)).wait(); await (await token.connect(B).approve(scAddr, ethers.MaxUint256)).wait(); const domain = domainFor(scAddr); const depA = E(100), depB = E(100); const rc = await (await sc.connect(A).open(A.address, B.address, tokenAddr, depA, depB)).wait(); let id = null; for (const log of rc.logs) { try { const p = sc.interface.parseLog(log); if (p && p.name === "Opened") id = p.args.channelId; } catch (_) {} } async function mk(nonce, a, b) { const st = { channelId: id, nonce: BigInt(nonce), balanceA: E(a), balanceB: E(b), htlcA: 0n, htlcB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n }; const s = await signState(domain, A, B, st); st.struct = s.struct; st.sigA = s.sigA; st.sigB = s.sigB; return st; } const stStale = await mk(1, 150, 50); // A-favouring stale const stFresh = await mk(5, 20, 180); // B-favouring newer const stMid = await mk(3, 90, 110); // B starts the challenge with the FRESH (nonce 5) state. await (await sc.connect(B).startChallenge(stFresh.struct, stFresh.sigA, stFresh.sigB)).wait(); // A tries to roll back to the stale nonce-1 state -> rejected. await expect(sc.connect(A).overrideChallenge(stStale.struct, stStale.sigA, stStale.sigB)).to.be.revertedWithCustomError(sc, "NonceNotHigher"); // Even an equal or in-between older nonce is rejected. await expect(sc.connect(A).overrideChallenge(stMid.struct, stMid.sigA, stMid.sigB)).to.be.revertedWithCustomError(sc, "NonceNotHigher"); await time.increase(Number(window) + 1); await (await sc.finalize(id)).wait(); const b0A = await token.balanceOf(A.address); const b0B = await token.balanceOf(B.address); await (await sc.connect(A).withdraw(id)).wait(); await (await sc.connect(B).withdraw(id)).wait(); // The NEWER (nonce 5) allocation wins: A=20, B=180. expect((await token.balanceOf(A.address)) - b0A).to.equal(E(20)); expect((await token.balanceOf(B.address)) - b0B).to.equal(E(180)); expect(await token.balanceOf(scAddr)).to.equal(0n); }); });