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.
320 lines
16 KiB
JavaScript
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");
|
|
});
|
|
});
|