aere-contracts/test/state-channels.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

320 lines
16 KiB
JavaScript

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