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.
658 lines
29 KiB
JavaScript
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);
|
|
});
|
|
});
|