// AereStateChannels tests. const { expect } = require("chai"); const { ethers } = require("hardhat"); const ONE6 = 10n ** 6n; async function deployStack() { const [deployer, alice, bob, mallory] = await ethers.getSigners(); const ERC20 = await ethers.getContractFactory("MockERC20Lending"); const token = await ERC20.deploy("USDC.e", "USDC.e", 6); await token.waitForDeployment(); const Channels = await ethers.getContractFactory("AereStateChannels"); const channels = await Channels.deploy(24 * 3600); // 24h window await channels.waitForDeployment(); // Mint and approve. await token.mint(alice.address, 10_000n * ONE6); await token.mint(bob.address, 10_000n * ONE6); await token.connect(alice).approve(await channels.getAddress(), 10_000n * ONE6); await token.connect(bob).approve(await channels.getAddress(), 10_000n * ONE6); return { deployer, alice, bob, mallory, token, channels }; } async function signState(wallet, channels, state) { const domain = { name: "AereStateChannels", version: "1", chainId: (await ethers.provider.getNetwork()).chainId, verifyingContract: await channels.getAddress(), }; 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" }, ], }; return wallet.signTypedData(domain, types, state); } async function openChannel(env, depositA, depositB) { // open() now requires the caller to be a channel participant; open as partyA (alice). const tx = await env.channels.connect(env.alice).open(env.alice.address, env.bob.address, await env.token.getAddress(), depositA, depositB); const rcpt = await tx.wait(); const id = rcpt.logs.find(l => l.fragment?.name === "Opened").args.channelId; return id; } describe("AereStateChannels", function () { it("opens a channel + pulls deposits from both parties", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); const c = await env.channels.channels(id); expect(c.status).to.equal(1); // Open expect(c.depositA).to.equal(1000n * ONE6); expect(c.depositB).to.equal(1000n * ONE6); expect(await env.token.balanceOf(await env.channels.getAddress())).to.equal(2000n * ONE6); }); it("cooperative close: both sign + funds released on withdraw", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 500n * ONE6); const state = { channelId: id, nonce: 5n, balanceA: 800n * ONE6, balanceB: 700n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n, }; const sigA = await signState(env.alice, env.channels, state); const sigB = await signState(env.bob, env.channels, state); await env.channels.cooperativeClose(state, sigA, sigB); const c = await env.channels.channels(id); expect(c.status).to.equal(3); // Finalised expect(c.latestBalanceA).to.equal(800n * ONE6); expect(c.latestBalanceB).to.equal(700n * ONE6); // Withdraw. const aBefore = await env.token.balanceOf(env.alice.address); await env.channels.connect(env.alice).withdraw(id); expect(await env.token.balanceOf(env.alice.address) - aBefore).to.equal(800n * ONE6); }); it("challenge close: override with higher nonce; finalize after window", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); // Alice submits an OLD state (nonce 3). const old = { channelId: id, nonce: 3n, balanceA: 1500n * ONE6, balanceB: 500n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n, }; const oldA = await signState(env.alice, env.channels, old); const oldB = await signState(env.bob, env.channels, old); await env.channels.connect(env.alice).startChallenge(old, oldA, oldB); // Bob counters with newer state (nonce 7). const newer = { channelId: id, nonce: 7n, balanceA: 300n * ONE6, balanceB: 1700n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n, }; const nA = await signState(env.alice, env.channels, newer); const nB = await signState(env.bob, env.channels, newer); await env.channels.connect(env.bob).overrideChallenge(newer, nA, nB); // Advance past window + finalize. await ethers.provider.send("evm_increaseTime", [24 * 3600 + 10]); await ethers.provider.send("evm_mine", []); await env.channels.finalize(id); const c = await env.channels.channels(id); expect(c.latestBalanceA).to.equal(300n * ONE6); expect(c.latestBalanceB).to.equal(1700n * ONE6); }); it("rejects override with same or lower nonce", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); const s1 = { channelId: id, nonce: 5n, balanceA: 800n * ONE6, balanceB: 1200n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n }; const sigA1 = await signState(env.alice, env.channels, s1); const sigB1 = await signState(env.bob, env.channels, s1); await env.channels.connect(env.alice).startChallenge(s1, sigA1, sigB1); // Try override with same nonce. const s2 = { ...s1, balanceA: 100n * ONE6, balanceB: 1900n * ONE6 }; const sigA2 = await signState(env.alice, env.channels, s2); const sigB2 = await signState(env.bob, env.channels, s2); await expect(env.channels.connect(env.bob).overrideChallenge(s2, sigA2, sigB2)) .to.be.revertedWithCustomError(env.channels, "NonceNotHigher"); }); it("rejects balances that don't sum to deposits", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); const bad = { channelId: id, nonce: 1n, balanceA: 500n * ONE6, balanceB: 100n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n }; const sigA = await signState(env.alice, env.channels, bad); const sigB = await signState(env.bob, env.channels, bad); await expect(env.channels.cooperativeClose(bad, sigA, sigB)) .to.be.revertedWithCustomError(env.channels, "BalancesMismatch"); }); it("rejects bad signatures (non-party signer)", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); const s = { channelId: id, nonce: 1n, balanceA: 1000n * ONE6, balanceB: 1000n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n }; const malSig = await signState(env.mallory, env.channels, s); const goodB = await signState(env.bob, env.channels, s); await expect(env.channels.cooperativeClose(s, malSig, goodB)) .to.be.revertedWithCustomError(env.channels, "BadSignature"); }); it("HTLC: settle within timeout → funds flow to receiver", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); const preimage = ethers.toUtf8Bytes("the-secret"); const lockHash = ethers.keccak256(preimage); const lockTimeout = (await ethers.provider.getBlock("latest")).timestamp + 7200; // State: A locked 200 USDC.e in an HTLC; payment will route to B if revealed. const s = { channelId: id, nonce: 1n, balanceA: 800n * ONE6, balanceB: 1000n * ONE6, htlcLockedFromA: 200n * ONE6, htlcLockedFromB: 0n, lockHash, lockTimeout: BigInt(lockTimeout), }; const sigA = await signState(env.alice, env.channels, s); const sigB = await signState(env.bob, env.channels, s); await env.channels.startChallenge(s, sigA, sigB); // Bob reveals the preimage in time. await env.channels.connect(env.bob).settleHtlc(id, preimage); // Advance past window. await ethers.provider.send("evm_increaseTime", [24 * 3600 + 10]); await ethers.provider.send("evm_mine", []); await env.channels.finalize(id); const c = await env.channels.channels(id); expect(c.latestBalanceB).to.equal(1200n * ONE6); // received the 200 HTLC expect(c.latestBalanceA).to.equal(800n * ONE6); }); it("HTLC: timeout expires unrevealed → funds refund to funder", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); const preimage = ethers.toUtf8Bytes("the-secret"); const lockHash = ethers.keccak256(preimage); const ts = (await ethers.provider.getBlock("latest")).timestamp; const s = { channelId: id, nonce: 1n, balanceA: 800n * ONE6, balanceB: 1000n * ONE6, htlcLockedFromA: 200n * ONE6, htlcLockedFromB: 0n, lockHash, lockTimeout: BigInt(ts + 60), // short lock }; const sigA = await signState(env.alice, env.channels, s); const sigB = await signState(env.bob, env.channels, s); await env.channels.startChallenge(s, sigA, sigB); // Advance past lock timeout AND challenge window. await ethers.provider.send("evm_increaseTime", [24 * 3600 + 100]); await ethers.provider.send("evm_mine", []); // settleHtlc now reverts (lock expired). await expect(env.channels.connect(env.bob).settleHtlc(id, preimage)) .to.be.revertedWithCustomError(env.channels, "LockExpired"); await env.channels.finalize(id); const c = await env.channels.channels(id); expect(c.latestBalanceA).to.equal(1000n * ONE6); // refunded expect(c.latestBalanceB).to.equal(1000n * ONE6); }); it("finalize before window expires reverts", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); const s = { channelId: id, nonce: 1n, balanceA: 1000n * ONE6, balanceB: 1000n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n }; const sigA = await signState(env.alice, env.channels, s); const sigB = await signState(env.bob, env.channels, s); await env.channels.startChallenge(s, sigA, sigB); await expect(env.channels.finalize(id)) .to.be.revertedWithCustomError(env.channels, "WindowOpen"); }); it("non-participant cannot withdraw", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); const s = { channelId: id, nonce: 1n, balanceA: 1000n * ONE6, balanceB: 1000n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n }; const sigA = await signState(env.alice, env.channels, s); const sigB = await signState(env.bob, env.channels, s); await env.channels.cooperativeClose(s, sigA, sigB); await expect(env.channels.connect(env.mallory).withdraw(id)) .to.be.revertedWithCustomError(env.channels, "NotParticipant"); }); /* --------------------------- open-timeout safety valve -------------------------- */ // HIGH fix: every close path needs BOTH signatures, so a counterparty who never // co-signs any state could permanently freeze the other party's deposit. The // open-timeout lets a solvent party ALWAYS eventually reclaim funds unilaterally. it("one-directional channel (depositB=0): A reclaims full deposit after open timeout when B never signs", async function () { const env = await deployStack(); // B stakes nothing — griefing A would otherwise be free and permanent. const id = await openChannel(env, 1000n * ONE6, 0n); const timeout = Number(await env.channels.CHANNEL_OPEN_TIMEOUT()); // Nothing was ever co-signed. Advance past the generous open timeout. await ethers.provider.send("evm_increaseTime", [timeout + 10]); await ethers.provider.send("evm_mine", []); const aBefore = await env.token.balanceOf(env.alice.address); // Either party can trigger; A finalises and withdraws its original deposit. await env.channels.connect(env.alice).closeOpenTimeout(id); const c = await env.channels.channels(id); expect(c.status).to.equal(3); // Finalised expect(c.latestBalanceA).to.equal(1000n * ONE6); expect(c.latestBalanceB).to.equal(0n); await env.channels.connect(env.alice).withdraw(id); expect(await env.token.balanceOf(env.alice.address) - aBefore).to.equal(1000n * ONE6); // Contract fully drained for this channel. expect(await env.token.balanceOf(await env.channels.getAddress())).to.equal(0n); }); it("open timeout cannot be called before the deadline", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 500n * ONE6); // Immediately after open — well before the deadline. await expect(env.channels.connect(env.alice).closeOpenTimeout(id)) .to.be.revertedWithCustomError(env.channels, "OpenTimeoutNotReached"); // Even close to (but before) the deadline it stays locked. const timeout = Number(await env.channels.CHANNEL_OPEN_TIMEOUT()); await ethers.provider.send("evm_increaseTime", [timeout - 60]); await ethers.provider.send("evm_mine", []); await expect(env.channels.connect(env.bob).closeOpenTimeout(id)) .to.be.revertedWithCustomError(env.channels, "OpenTimeoutNotReached"); }); it("open timeout cannot override a channel already moved to challenge/finalised", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6); // A co-signed state is brought on-chain -> channel leaves Status.Open. const s = { channelId: id, nonce: 4n, balanceA: 200n * ONE6, balanceB: 1800n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n }; const sigA = await signState(env.alice, env.channels, s); const sigB = await signState(env.bob, env.channels, s); await env.channels.connect(env.alice).startChallenge(s, sigA, sigB); // Now blow past the open timeout: it must NOT be usable to bypass the challenge. const timeout = Number(await env.channels.CHANNEL_OPEN_TIMEOUT()); await ethers.provider.send("evm_increaseTime", [timeout + 10]); await ethers.provider.send("evm_mine", []); await expect(env.channels.connect(env.alice).closeOpenTimeout(id)) .to.be.revertedWithCustomError(env.channels, "ChannelNotOpen"); // The legitimate challenge/finalize path still governs the real balances. await env.channels.finalize(id); const c = await env.channels.channels(id); expect(c.latestBalanceA).to.equal(200n * ONE6); expect(c.latestBalanceB).to.equal(1800n * ONE6); // And a finalised channel likewise rejects the timeout path. await expect(env.channels.connect(env.alice).closeOpenTimeout(id)) .to.be.revertedWithCustomError(env.channels, "ChannelNotOpen"); }); it("open timeout is restricted to channel participants", async function () { const env = await deployStack(); const id = await openChannel(env, 1000n * ONE6, 500n * ONE6); const timeout = Number(await env.channels.CHANNEL_OPEN_TIMEOUT()); await ethers.provider.send("evm_increaseTime", [timeout + 10]); await ethers.provider.send("evm_mine", []); await expect(env.channels.connect(env.mallory).closeOpenTimeout(id)) .to.be.revertedWithCustomError(env.channels, "NotParticipant"); }); it("open() rejects a non-participant caller (blocks approval-griefing)", async function () { const env = await deployStack(); // Mallory tries to force alice's & bob's approved tokens into a channel. await expect( env.channels.connect(env.mallory).open( env.alice.address, env.bob.address, await env.token.getAddress(), 1000n * ONE6, 500n * ONE6 ) ).to.be.revertedWithCustomError(env.channels, "NotParticipant"); }); });