aere-contracts/test/invariant-statechannels-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

658 lines
29 KiB
JavaScript

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