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.
417 lines
20 KiB
Solidity
417 lines
20 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.23;
|
|
|
|
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
|
|
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
|
|
|
|
/**
|
|
* @title AereStateChannels — single-contract bidirectional payment channels
|
|
* with HTLC support (Connext/Hop-style)
|
|
* @notice Two parties (A, B) lock ERC-20 funds into a channel. They
|
|
* transact off-chain by exchanging EIP-712 signed channel states
|
|
* {channelId, nonce, balanceA, balanceB, htlcLocked, lockHash, lockTimeout}.
|
|
* When done, EITHER party can close the channel:
|
|
* - Cooperative: both sign the latest state, instant settlement.
|
|
* - Challenge: one party submits the latest signed state and
|
|
* starts a 24h challenge window. Counterparty can submit a
|
|
* newer-nonce state to override. After window expires,
|
|
* anyone can call `finalize` to release funds.
|
|
*
|
|
* HTLC payments inside the channel are settled by revealing a
|
|
* hash preimage on-chain (`settleHtlc`) within the lockTimeout.
|
|
* If timeout expires unrevealed, the lock returns to the funder.
|
|
*
|
|
* IMMUTABILITY:
|
|
* - DOMAIN_SEPARATOR pinned at deploy
|
|
* - CHALLENGE_WINDOW immutable at deploy
|
|
* - No admin, no owner, no upgrade path
|
|
*
|
|
* OUT OF SCOPE for V1:
|
|
* - Multi-hop routing (would need a Lightning-style HTLC graph)
|
|
* - Watchtowers (off-chain monitoring services)
|
|
* - Probabilistic micropayments
|
|
* These belong in Phase 2 contracts that USE this as the
|
|
* settlement primitive.
|
|
*
|
|
* @dev Nonce is monotonic per channel. Challenge with a higher nonce
|
|
* wins; ties + lower nonces are rejected.
|
|
*/
|
|
contract AereStateChannels is ReentrancyGuard {
|
|
using ECDSA for bytes32;
|
|
|
|
/* ------------------------------- immutable ------------------------------- */
|
|
|
|
uint64 public immutable CHALLENGE_WINDOW; // seconds
|
|
bytes32 public immutable DOMAIN_SEPARATOR;
|
|
bytes32 public constant STATE_TYPEHASH = keccak256(
|
|
"ChannelState(bytes32 channelId,uint256 nonce,uint256 balanceA,uint256 balanceB,uint256 htlcLockedFromA,uint256 htlcLockedFromB,bytes32 lockHash,uint256 lockTimeout)"
|
|
);
|
|
|
|
/// @notice Safety-valve deadline for an Open channel that is never co-signed
|
|
/// closed. After this long past `open()`, with the channel still in
|
|
/// Status.Open (no cooperative close, no challenge), EITHER party may
|
|
/// call `closeOpenTimeout` to reclaim their ORIGINAL deposit. This
|
|
/// removes the "counterparty refuses to ever co-sign" freeze — most
|
|
/// acute in a one-directional channel (depositB == 0), where B stakes
|
|
/// nothing and griefing A would otherwise be free and permanent.
|
|
/// Deliberately generous so it can never pre-empt a channel that is
|
|
/// still being used off-chain; a party owed more than its deposit by a
|
|
/// later co-signed state simply submits it via `startChallenge` before
|
|
/// the timeout, which flips the channel out of Status.Open and disables
|
|
/// this path entirely.
|
|
uint256 public constant CHANNEL_OPEN_TIMEOUT = 30 days;
|
|
|
|
/* --------------------------------- types --------------------------------- */
|
|
|
|
enum Status { None, Open, Challenged, Finalised }
|
|
|
|
struct Channel {
|
|
Status status;
|
|
address partyA;
|
|
address partyB;
|
|
address token;
|
|
uint256 depositA;
|
|
uint256 depositB;
|
|
// Latest acknowledged state (post-challenge or cooperative-close).
|
|
uint256 latestNonce;
|
|
uint256 latestBalanceA;
|
|
uint256 latestBalanceB;
|
|
uint256 latestHtlcLockedFromA;
|
|
uint256 latestHtlcLockedFromB;
|
|
bytes32 latestLockHash;
|
|
uint256 latestLockTimeout;
|
|
// Challenge window bookkeeping.
|
|
uint64 challengeStartedAt;
|
|
// HTLC settlement.
|
|
bytes32 settledHtlcLockHash; // last revealed preimage's hash
|
|
bool htlcResolved;
|
|
// Open-timeout safety valve bookkeeping (appended so the public getter's
|
|
// existing field ordering is preserved).
|
|
uint64 openedAt; // block.timestamp at open()
|
|
}
|
|
|
|
struct ChannelState {
|
|
bytes32 channelId;
|
|
uint256 nonce;
|
|
uint256 balanceA;
|
|
uint256 balanceB;
|
|
uint256 htlcLockedFromA;
|
|
uint256 htlcLockedFromB;
|
|
bytes32 lockHash; // keccak256(preimage); 0 = no active HTLC
|
|
uint256 lockTimeout; // unix seconds; 0 if no HTLC
|
|
}
|
|
|
|
/* --------------------------------- state -------------------------------- */
|
|
|
|
mapping(bytes32 => Channel) public channels;
|
|
|
|
/* --------------------------------- events ------------------------------- */
|
|
|
|
event Opened(bytes32 indexed channelId, address indexed partyA, address indexed partyB, address token, uint256 depositA, uint256 depositB);
|
|
event CooperativeClosed(bytes32 indexed channelId, uint256 finalBalanceA, uint256 finalBalanceB);
|
|
event ChallengeStarted(bytes32 indexed channelId, address indexed by, uint256 nonce, uint64 deadline);
|
|
event ChallengeOverridden(bytes32 indexed channelId, uint256 newNonce);
|
|
event Finalised(bytes32 indexed channelId, uint256 finalA, uint256 finalB);
|
|
event HtlcSettled(bytes32 indexed channelId, bytes32 indexed lockHash, address indexed beneficiary);
|
|
event HtlcExpired(bytes32 indexed channelId, bytes32 indexed lockHash);
|
|
event Withdrawn(bytes32 indexed channelId, address indexed recipient, uint256 amount);
|
|
event OpenTimeoutClosed(bytes32 indexed channelId, address indexed by, uint256 refundA, uint256 refundB);
|
|
|
|
/* --------------------------------- errors ------------------------------- */
|
|
|
|
error ZeroAddress();
|
|
error ZeroAmount();
|
|
error ChannelExists();
|
|
error ChannelNotOpen();
|
|
error ChannelNotChallenged();
|
|
error ChannelNotFinalised();
|
|
error NotParticipant();
|
|
error WrongChannelId();
|
|
error BadSignature(string which);
|
|
error NonceNotHigher(uint256 incoming, uint256 latest);
|
|
error WindowOpen();
|
|
error WindowExpired();
|
|
error OpenTimeoutNotReached();
|
|
error BalancesMismatch(uint256 sum, uint256 deposits);
|
|
error HtlcAlreadyResolved();
|
|
error PreimageMismatch();
|
|
error LockExpired();
|
|
error LockNotExpired();
|
|
error TransferFailed();
|
|
|
|
/* ----------------------------- constructor ------------------------------ */
|
|
|
|
constructor(uint64 challengeWindow) {
|
|
CHALLENGE_WINDOW = challengeWindow;
|
|
DOMAIN_SEPARATOR = keccak256(abi.encode(
|
|
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
|
|
keccak256("AereStateChannels"),
|
|
keccak256("1"),
|
|
block.chainid,
|
|
address(this)
|
|
));
|
|
}
|
|
|
|
/* --------------------------------- open --------------------------------- */
|
|
|
|
/// @notice Opens a channel between (partyA, partyB) with deterministic id.
|
|
/// BOTH parties' tokens must be `transferFrom`-able to this contract.
|
|
/// Order of (partyA, partyB) matters for channelId derivation.
|
|
/// @dev The caller MUST be one of the two channel parties. This blocks a
|
|
/// third party from using A's (or B's) standing ERC-20 approval to
|
|
/// force their tokens into a channel they never agreed to — which,
|
|
/// combined with the every-close-needs-both-signatures rule, could
|
|
/// otherwise strand those tokens until the open-timeout fallback.
|
|
function open(address partyA, address partyB, address token, uint256 depositA, uint256 depositB) external nonReentrant returns (bytes32 channelId) {
|
|
if (partyA == address(0) || partyB == address(0) || token == address(0)) revert ZeroAddress();
|
|
if (partyA == partyB) revert ZeroAddress();
|
|
if (msg.sender != partyA && msg.sender != partyB) revert NotParticipant();
|
|
channelId = keccak256(abi.encode(partyA, partyB, token, block.timestamp, block.number, msg.sender));
|
|
if (channels[channelId].status != Status.None) revert ChannelExists();
|
|
if (depositA + depositB == 0) revert ZeroAmount();
|
|
|
|
if (depositA > 0 && !IERC20(token).transferFrom(partyA, address(this), depositA)) revert TransferFailed();
|
|
if (depositB > 0 && !IERC20(token).transferFrom(partyB, address(this), depositB)) revert TransferFailed();
|
|
|
|
Channel storage c = channels[channelId];
|
|
c.status = Status.Open;
|
|
c.partyA = partyA;
|
|
c.partyB = partyB;
|
|
c.token = token;
|
|
c.depositA = depositA;
|
|
c.depositB = depositB;
|
|
// Initial state: balances equal deposits, no HTLC, nonce=0.
|
|
c.latestBalanceA = depositA;
|
|
c.latestBalanceB = depositB;
|
|
c.openedAt = uint64(block.timestamp);
|
|
|
|
emit Opened(channelId, partyA, partyB, token, depositA, depositB);
|
|
}
|
|
|
|
/* --------------------------- cooperative close --------------------------- */
|
|
|
|
function cooperativeClose(
|
|
ChannelState calldata state,
|
|
bytes calldata sigA,
|
|
bytes calldata sigB
|
|
) external nonReentrant {
|
|
Channel storage c = channels[state.channelId];
|
|
if (c.status != Status.Open) revert ChannelNotOpen();
|
|
if (state.channelId == bytes32(0)) revert WrongChannelId();
|
|
_checkBalances(c, state);
|
|
bytes32 digest = _hashState(state);
|
|
if (digest.recover(sigA) != c.partyA) revert BadSignature("A");
|
|
if (digest.recover(sigB) != c.partyB) revert BadSignature("B");
|
|
|
|
c.status = Status.Finalised;
|
|
c.latestNonce = state.nonce;
|
|
c.latestBalanceA = state.balanceA + state.htlcLockedFromA + state.htlcLockedFromB; // unlock HTLC to A on coop close
|
|
c.latestBalanceB = state.balanceB; // simplified — see NOTE below
|
|
// NOTE on cooperative close: parties agree on final off-chain.
|
|
// HTLC tokens go back to their funder unless the state already
|
|
// resolved them (state.lockHash == 0). For real production,
|
|
// parties typically settle HTLCs before cooperative close.
|
|
if (state.lockHash != bytes32(0)) {
|
|
// unresolved HTLC at coop close — refund to funder (whoever
|
|
// contributed htlcLockedFromX is credited that X amount).
|
|
c.latestBalanceA = state.balanceA + state.htlcLockedFromA;
|
|
c.latestBalanceB = state.balanceB + state.htlcLockedFromB;
|
|
} else {
|
|
c.latestBalanceA = state.balanceA;
|
|
c.latestBalanceB = state.balanceB;
|
|
}
|
|
|
|
emit CooperativeClosed(state.channelId, c.latestBalanceA, c.latestBalanceB);
|
|
}
|
|
|
|
/* --------------------------- open-timeout close -------------------------- */
|
|
|
|
/// @notice Safety valve against a non-cooperating counterparty. If a channel
|
|
/// is still Status.Open CHANNEL_OPEN_TIMEOUT after it was opened —
|
|
/// meaning NO co-signed state was ever brought on-chain (no
|
|
/// cooperativeClose, no startChallenge) — EITHER party may finalise
|
|
/// it here and reclaim their ORIGINAL deposit, then `withdraw`.
|
|
///
|
|
/// Without this, every close path requires BOTH signatures, so a
|
|
/// counterparty who simply never co-signs any closing state can
|
|
/// permanently freeze the other party's funds (costless in a
|
|
/// one-directional channel where depositB == 0).
|
|
///
|
|
/// @dev SECURITY — this can never override a legitimately-signed newer
|
|
/// state:
|
|
/// * It only runs while `status == Status.Open`. The instant any
|
|
/// co-signed state is submitted, the channel leaves Open
|
|
/// (cooperativeClose -> Finalised, startChallenge -> Challenged),
|
|
/// so this function reverts with ChannelNotOpen thereafter.
|
|
/// * While Open, `latestBalanceA/B` still equal the deposits set in
|
|
/// `open()` (no on-chain state ever mutated them), so refunding
|
|
/// the deposits is the only provable allocation.
|
|
/// * A party owed more than its deposit by a later off-chain state
|
|
/// has the full CHANNEL_OPEN_TIMEOUT window to enforce it via
|
|
/// `startChallenge`; doing so moves the channel to Challenged and
|
|
/// permanently disables this path.
|
|
function closeOpenTimeout(bytes32 channelId) external nonReentrant {
|
|
Channel storage c = channels[channelId];
|
|
if (c.status != Status.Open) revert ChannelNotOpen();
|
|
if (msg.sender != c.partyA && msg.sender != c.partyB) revert NotParticipant();
|
|
if (block.timestamp < uint256(c.openedAt) + CHANNEL_OPEN_TIMEOUT) revert OpenTimeoutNotReached();
|
|
|
|
// No co-signed state was ever recorded: refund the original deposits.
|
|
// (latestBalanceA/B already equal depositA/depositB from open(); set
|
|
// explicitly for clarity and defence in depth.)
|
|
c.status = Status.Finalised;
|
|
c.latestBalanceA = c.depositA;
|
|
c.latestBalanceB = c.depositB;
|
|
|
|
emit OpenTimeoutClosed(channelId, msg.sender, c.depositA, c.depositB);
|
|
}
|
|
|
|
/* ------------------------------ challenge close -------------------------- */
|
|
|
|
/// @notice Submit the latest signed state — starts the challenge window.
|
|
/// Counterparty has CHALLENGE_WINDOW seconds to submit a higher
|
|
/// nonce. After window expires, anyone calls finalize.
|
|
function startChallenge(
|
|
ChannelState calldata state,
|
|
bytes calldata sigA,
|
|
bytes calldata sigB
|
|
) external nonReentrant {
|
|
Channel storage c = channels[state.channelId];
|
|
if (c.status != Status.Open) revert ChannelNotOpen();
|
|
_checkBalances(c, state);
|
|
bytes32 digest = _hashState(state);
|
|
if (digest.recover(sigA) != c.partyA) revert BadSignature("A");
|
|
if (digest.recover(sigB) != c.partyB) revert BadSignature("B");
|
|
|
|
c.status = Status.Challenged;
|
|
c.latestNonce = state.nonce;
|
|
c.latestBalanceA = state.balanceA;
|
|
c.latestBalanceB = state.balanceB;
|
|
c.latestHtlcLockedFromA = state.htlcLockedFromA;
|
|
c.latestHtlcLockedFromB = state.htlcLockedFromB;
|
|
c.latestLockHash = state.lockHash;
|
|
c.latestLockTimeout = state.lockTimeout;
|
|
c.challengeStartedAt = uint64(block.timestamp);
|
|
|
|
emit ChallengeStarted(state.channelId, msg.sender, state.nonce, uint64(block.timestamp + CHALLENGE_WINDOW));
|
|
}
|
|
|
|
/// @notice Override a pending challenge with a higher-nonce state.
|
|
function overrideChallenge(
|
|
ChannelState calldata state,
|
|
bytes calldata sigA,
|
|
bytes calldata sigB
|
|
) external nonReentrant {
|
|
Channel storage c = channels[state.channelId];
|
|
if (c.status != Status.Challenged) revert ChannelNotChallenged();
|
|
if (block.timestamp >= uint256(c.challengeStartedAt) + CHALLENGE_WINDOW) revert WindowExpired();
|
|
if (state.nonce <= c.latestNonce) revert NonceNotHigher(state.nonce, c.latestNonce);
|
|
_checkBalances(c, state);
|
|
bytes32 digest = _hashState(state);
|
|
if (digest.recover(sigA) != c.partyA) revert BadSignature("A");
|
|
if (digest.recover(sigB) != c.partyB) revert BadSignature("B");
|
|
|
|
c.latestNonce = state.nonce;
|
|
c.latestBalanceA = state.balanceA;
|
|
c.latestBalanceB = state.balanceB;
|
|
c.latestHtlcLockedFromA = state.htlcLockedFromA;
|
|
c.latestHtlcLockedFromB = state.htlcLockedFromB;
|
|
c.latestLockHash = state.lockHash;
|
|
c.latestLockTimeout = state.lockTimeout;
|
|
|
|
emit ChallengeOverridden(state.channelId, state.nonce);
|
|
}
|
|
|
|
/// @notice After window expires, anyone can finalise. Resolves HTLC:
|
|
/// if lockHash is set and `settleHtlc` was called within
|
|
/// lockTimeout, HTLC tokens go to the receiver (partyB if
|
|
/// funded by A, partyA if funded by B). Otherwise (no
|
|
/// settle or settle was late), HTLC tokens return to funder.
|
|
function finalize(bytes32 channelId) external nonReentrant {
|
|
Channel storage c = channels[channelId];
|
|
if (c.status != Status.Challenged) revert ChannelNotChallenged();
|
|
if (block.timestamp < uint256(c.challengeStartedAt) + CHALLENGE_WINDOW) revert WindowOpen();
|
|
|
|
// Resolve HTLC.
|
|
uint256 htlcToA;
|
|
uint256 htlcToB;
|
|
if (c.latestLockHash != bytes32(0)) {
|
|
// ROUND-3 FIX: previously this check also required
|
|
// `block.timestamp <= latestLockTimeout + CHALLENGE_WINDOW`, which
|
|
// penalised a receiver who legitimately revealed the preimage
|
|
// inside lockTimeout (settleHtlc already enforces that) whenever
|
|
// finalize was called late. settleHtlc is the authoritative
|
|
// timeliness gate; finalize must honour its result regardless of
|
|
// when finalize itself runs.
|
|
bool settled = c.htlcResolved && c.settledHtlcLockHash == c.latestLockHash;
|
|
if (settled) {
|
|
// Funder loses, receiver gains. Convention: A funds → B
|
|
// gets, B funds → A gets.
|
|
htlcToB += c.latestHtlcLockedFromA;
|
|
htlcToA += c.latestHtlcLockedFromB;
|
|
} else {
|
|
// Refund to funder.
|
|
htlcToA += c.latestHtlcLockedFromA;
|
|
htlcToB += c.latestHtlcLockedFromB;
|
|
}
|
|
}
|
|
c.status = Status.Finalised;
|
|
c.latestBalanceA += htlcToA;
|
|
c.latestBalanceB += htlcToB;
|
|
|
|
emit Finalised(channelId, c.latestBalanceA, c.latestBalanceB);
|
|
}
|
|
|
|
/* ----------------------------- HTLC ----------------------------- */
|
|
|
|
/// @notice Anyone with the preimage can settle an HTLC during the
|
|
/// challenge window. Records on-chain that the lock was opened.
|
|
function settleHtlc(bytes32 channelId, bytes calldata preimage) external {
|
|
Channel storage c = channels[channelId];
|
|
if (c.status != Status.Challenged) revert ChannelNotChallenged();
|
|
if (c.htlcResolved) revert HtlcAlreadyResolved();
|
|
if (block.timestamp > c.latestLockTimeout) revert LockExpired();
|
|
bytes32 hash = keccak256(preimage);
|
|
if (hash != c.latestLockHash) revert PreimageMismatch();
|
|
c.htlcResolved = true;
|
|
c.settledHtlcLockHash = hash;
|
|
emit HtlcSettled(channelId, hash, msg.sender);
|
|
}
|
|
|
|
/* ------------------------------ withdraw -------------------------------- */
|
|
|
|
function withdraw(bytes32 channelId) external nonReentrant {
|
|
Channel storage c = channels[channelId];
|
|
if (c.status != Status.Finalised) revert ChannelNotFinalised();
|
|
if (msg.sender != c.partyA && msg.sender != c.partyB) revert NotParticipant();
|
|
uint256 amount;
|
|
if (msg.sender == c.partyA) {
|
|
amount = c.latestBalanceA;
|
|
c.latestBalanceA = 0;
|
|
} else {
|
|
amount = c.latestBalanceB;
|
|
c.latestBalanceB = 0;
|
|
}
|
|
if (amount == 0) return;
|
|
if (!IERC20(c.token).transfer(msg.sender, amount)) revert TransferFailed();
|
|
emit Withdrawn(channelId, msg.sender, amount);
|
|
}
|
|
|
|
/* ------------------------------- internal ------------------------------- */
|
|
|
|
function _hashState(ChannelState calldata s) internal view returns (bytes32) {
|
|
bytes32 structHash = keccak256(abi.encode(
|
|
STATE_TYPEHASH, s.channelId, s.nonce, s.balanceA, s.balanceB,
|
|
s.htlcLockedFromA, s.htlcLockedFromB, s.lockHash, s.lockTimeout
|
|
));
|
|
return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
|
|
}
|
|
|
|
function _checkBalances(Channel storage c, ChannelState calldata s) internal view {
|
|
uint256 total = s.balanceA + s.balanceB + s.htlcLockedFromA + s.htlcLockedFromB;
|
|
uint256 deposits = c.depositA + c.depositB;
|
|
if (total != deposits) revert BalancesMismatch(total, deposits);
|
|
}
|
|
}
|