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.
447 lines
21 KiB
Solidity
447 lines
21 KiB
Solidity
// SPDX-License-Identifier: MIT
|
|
pragma solidity 0.8.23;
|
|
|
|
import "@openzeppelin/contracts/access/Ownable.sol";
|
|
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
|
|
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
|
|
interface IAereSinkSettlement {
|
|
function flush(address token, uint256 amount) external;
|
|
}
|
|
|
|
/**
|
|
* @title AereSettlementHubV2 - institutional cross-asset settlement venue
|
|
* @notice Counterparties deposit tokenised collateral (BUIDL, USDY, OUSG,
|
|
* LBTC, SolvBTC, pumpBTC, USDC.e, etc.) for batched solver-driven
|
|
* settlement on AERE Chain 2800. The hub enforces:
|
|
* - per-asset configuration (bond, perTxLimit, paused, decimals)
|
|
* - solver allowlist (Phase 1) then bonded permissionless (Phase 2)
|
|
* - 50bps protocol fee, immutable, routed to AereSink
|
|
* - RWA receiver allowlist for restricted assets
|
|
* - 6-hour challenge window on settlement claims
|
|
*
|
|
* @dev CORRECTED FORK of AereSettlementHub (canonical
|
|
* 0x2a02fD80c16293D2B5D8a295F31D1a6E6a582c02). This is a FRESH INERT
|
|
* redeploy (deployer-owned, holds nothing, not wired live). It fixes
|
|
* two audited defects in the V1 logic:
|
|
*
|
|
* F-SWEEP (HIGH griefing) - V1 sweepResidual() was permissionless and
|
|
* took an arbitrary amount with no residual accounting, so any EOA
|
|
* could push a live intent's parked settlement funds and posted
|
|
* solver bonds into the sink, making settle() revert TransferFailed
|
|
* and permanently bricking the settlement (depositor funds burned,
|
|
* solver unpaid). V2 tracks a per-asset committed-liabilities total
|
|
* (parked intent remainders + posted bonds) and gates sweepResidual
|
|
* to the TRUE residual only: balance minus committed. A permissionless
|
|
* sweep can therefore only ever take genuine surplus (fees or slashed
|
|
* bonds that could not reach the sink) and can never touch funds owed
|
|
* to a solver or a depositor.
|
|
*
|
|
* F-LOCK (liveness) - V1 had no refund/cancel/timeout, so an unclaimed
|
|
* deposit was locked forever. V2 records the deposit timestamp and
|
|
* adds cancelIntent(), a depositor-only refund path for an intent
|
|
* that no solver ever claimed, callable after REFUND_TIMEOUT. It
|
|
* returns the parked remainder to the depositor and closes the intent.
|
|
*
|
|
* AereSink wiring is in the constructor and IMMUTABLE. Fee bps is
|
|
* constant 50.
|
|
*/
|
|
contract AereSettlementHubV2 is Ownable, ReentrancyGuard {
|
|
|
|
/* ------------------------------- immutable ------------------------------- */
|
|
|
|
address public immutable SINK; // AereSink (immutable)
|
|
uint16 public constant PROTOCOL_FEE_BPS = 50;
|
|
uint256 public constant CHALLENGE_WINDOW = 6 hours;
|
|
|
|
/// @notice How long after deposit an unclaimed intent may be refunded by
|
|
/// its depositor. Generous so a legitimate in-flight settlement is
|
|
/// never at risk; a solver claims long before this elapses.
|
|
uint256 public constant REFUND_TIMEOUT = 7 days;
|
|
|
|
/* --------------------------------- structs ------------------------------ */
|
|
|
|
struct AssetConfig {
|
|
bool listed;
|
|
bool paused;
|
|
uint8 decimals;
|
|
uint256 minBond; // bond required to claim this asset
|
|
uint256 perTxLimit; // max single-deposit amount, 0 = no cap
|
|
bool restrictedRecipient; // if true, recipient must be on allowlist
|
|
}
|
|
|
|
struct Intent {
|
|
address depositor;
|
|
address asset;
|
|
uint256 amount; // post-fee solverPortion (parked remainder)
|
|
address recipient;
|
|
bool open; // true while the intent is live; false once terminal
|
|
bool settled; // true once released to the solver
|
|
address solver;
|
|
uint64 claimedAt;
|
|
uint64 createdAt; // deposit timestamp, drives the refund timeout
|
|
bytes fillEvidence;
|
|
uint256 bondLocked; // solver bond reserved at claim, released at settle
|
|
}
|
|
|
|
/* --------------------------------- state -------------------------------- */
|
|
|
|
/// @notice asset then config
|
|
mapping(address => AssetConfig) public assetConfig;
|
|
|
|
/// @notice asset then allowed recipient then true
|
|
mapping(address => mapping(address => bool)) public recipientAllowed;
|
|
|
|
/// @notice intentId then Intent
|
|
mapping(bytes32 => Intent) public intents;
|
|
|
|
/// @notice solver allowlist per asset (Phase-1). Phase-2 will switch
|
|
/// to bond-only permissionless gating.
|
|
mapping(address => mapping(address => bool)) public solverAllowed; // asset then solver then bool
|
|
|
|
/// @notice solver bonds, per (asset, solver).
|
|
mapping(address => mapping(address => uint256)) public solverBond;
|
|
|
|
/// @notice Portion of a solver's bond currently RESERVED to back its own
|
|
/// outstanding claimed-but-unsettled intents, per (asset, solver).
|
|
/// At claim() we lock the asset's minBond; at settle() we release the
|
|
/// exact amount locked for that intent. A solver may only withdraw the
|
|
/// FREE remainder (solverBond - lockedBond), so a claimed intent's
|
|
/// backing stays slashable through settlement and a solver can never
|
|
/// withdraw to dodge an imminent slash.
|
|
mapping(address => mapping(address => uint256)) public lockedBond;
|
|
|
|
/// @notice Per-asset committed liabilities: the exact amount the hub OWES,
|
|
/// equal to the sum of every open intent's parked remainder plus
|
|
/// every posted solver bond for that asset. Maintained in lockstep
|
|
/// with deposit / bond / settle / cancel / slash. The genuine
|
|
/// residual sweepResidual may touch is balanceOf(hub) minus this.
|
|
mapping(address => uint256) public committedLiabilities;
|
|
|
|
/* --------------------------------- events ------------------------------- */
|
|
|
|
event AssetListed(address indexed asset, uint8 decimals, uint256 minBond, uint256 perTxLimit, bool restrictedRecipient);
|
|
event AssetPausedSet(address indexed asset, bool paused);
|
|
event RecipientAllowedSet(address indexed asset, address indexed recipient, bool allowed);
|
|
event SolverAllowedSet(address indexed asset, address indexed solver, bool allowed);
|
|
|
|
event Deposited(bytes32 indexed intentId, address indexed depositor, address indexed asset, uint256 amountAfterFee, uint256 feeAmount, address recipient);
|
|
event Claimed(bytes32 indexed intentId, address indexed solver, uint64 claimedAt);
|
|
event Settled(bytes32 indexed intentId, address indexed solver, uint256 amount);
|
|
event Challenged(bytes32 indexed intentId, address indexed challenger, string reason);
|
|
event Refunded(bytes32 indexed intentId, address indexed depositor, uint256 amount);
|
|
event SolverBondDeposited(address indexed asset, address indexed solver, uint256 amount);
|
|
event SolverBondWithdrawn(address indexed asset, address indexed solver, uint256 amount);
|
|
event SolverBondSlashed(address indexed asset, address indexed solver, uint256 amount);
|
|
event ResidualSwept(address indexed asset, uint256 amount, address indexed caller);
|
|
|
|
/* --------------------------------- errors ------------------------------- */
|
|
|
|
error ZeroAddress();
|
|
error ZeroAmount();
|
|
error AssetNotListed();
|
|
error AssetPausedErr();
|
|
error PerTxLimitExceeded(uint256 amount, uint256 limit);
|
|
error RecipientNotAllowed();
|
|
error NotAllowedSolver();
|
|
error InsufficientBond(uint256 have, uint256 need);
|
|
error InsufficientFreeBond(uint256 free, uint256 requested);
|
|
error IntentUnknown();
|
|
error IntentAlreadyClaimed();
|
|
error IntentAlreadySettled();
|
|
error WithinChallengeWindow();
|
|
error ChallengeWindowElapsed();
|
|
error TransferFailed();
|
|
error DuplicateIntent();
|
|
error NotDepositor();
|
|
error RefundTimeoutNotReached(uint256 nowTs, uint256 unlockTs);
|
|
error ExceedsResidual(uint256 amount, uint256 residual);
|
|
|
|
/* ------------------------------- constructor ---------------------------- */
|
|
|
|
constructor(address sink) {
|
|
if (sink == address(0)) revert ZeroAddress();
|
|
SINK = sink;
|
|
}
|
|
|
|
/* ------------------------------- asset admin ---------------------------- */
|
|
|
|
function listAsset(
|
|
address asset,
|
|
uint8 decimals,
|
|
uint256 minBond,
|
|
uint256 perTxLimit,
|
|
bool restrictedRecipient
|
|
) external onlyOwner {
|
|
if (asset == address(0)) revert ZeroAddress();
|
|
assetConfig[asset] = AssetConfig({
|
|
listed: true,
|
|
paused: false,
|
|
decimals: decimals,
|
|
minBond: minBond,
|
|
perTxLimit: perTxLimit,
|
|
restrictedRecipient: restrictedRecipient
|
|
});
|
|
emit AssetListed(asset, decimals, minBond, perTxLimit, restrictedRecipient);
|
|
}
|
|
|
|
function setAssetPaused(address asset, bool paused) external onlyOwner {
|
|
if (!assetConfig[asset].listed) revert AssetNotListed();
|
|
assetConfig[asset].paused = paused;
|
|
emit AssetPausedSet(asset, paused);
|
|
}
|
|
|
|
function setRecipientAllowed(address asset, address recipient, bool allowed) external onlyOwner {
|
|
if (!assetConfig[asset].listed) revert AssetNotListed();
|
|
recipientAllowed[asset][recipient] = allowed;
|
|
emit RecipientAllowedSet(asset, recipient, allowed);
|
|
}
|
|
|
|
/* ------------------------------ solver admin ---------------------------- */
|
|
|
|
function setSolverAllowed(address asset, address solver, bool allowed) external onlyOwner {
|
|
if (!assetConfig[asset].listed) revert AssetNotListed();
|
|
if (solver == address(0)) revert ZeroAddress();
|
|
solverAllowed[asset][solver] = allowed;
|
|
emit SolverAllowedSet(asset, solver, allowed);
|
|
}
|
|
|
|
/// @notice Permissionless solver bond top-up. The posted bond becomes a
|
|
/// committed liability so sweepResidual can never touch it.
|
|
function depositSolverBond(address asset, address solver, uint256 amount) external nonReentrant {
|
|
if (!assetConfig[asset].listed) revert AssetNotListed();
|
|
if (amount == 0) revert ZeroAmount();
|
|
bool ok = IERC20(asset).transferFrom(msg.sender, address(this), amount);
|
|
if (!ok) revert TransferFailed();
|
|
solverBond[asset][solver] += amount;
|
|
committedLiabilities[asset] += amount;
|
|
emit SolverBondDeposited(asset, solver, amount);
|
|
}
|
|
|
|
/// @notice Solver-callable withdrawal of its OWN FREE bond. This is the F-BOND
|
|
/// fix: V2 previously had no path to return an honest bond, so a
|
|
/// solver's posted bond was permanently locked (only slashSolverBond,
|
|
/// owner-only, could ever reduce it, and that sends to the sink not the
|
|
/// solver). Now a solver reclaims the portion of its bond that is NOT
|
|
/// reserved to back an outstanding claimed-but-unsettled intent.
|
|
///
|
|
/// @dev Guarantees:
|
|
/// - only msg.sender's own bond is ever touched (ownership);
|
|
/// - only the FREE bond (solverBond - lockedBond) is withdrawable, so
|
|
/// a claimed intent's minBond backing stays slashable until settle
|
|
/// and a solver cannot dodge an imminent slash;
|
|
/// - solverBond and committedLiabilities decrement in lockstep with
|
|
/// the outbound transfer, preserving the sweep-reserve invariant
|
|
/// (balance >= committed) so sweepResidual can never reach a bond;
|
|
/// - nonReentrant.
|
|
function withdrawSolverBond(address asset, uint256 amount) external nonReentrant {
|
|
if (amount == 0) revert ZeroAmount();
|
|
uint256 bonded = solverBond[asset][msg.sender];
|
|
uint256 locked = lockedBond[asset][msg.sender];
|
|
uint256 free = bonded > locked ? bonded - locked : 0;
|
|
if (amount > free) revert InsufficientFreeBond(free, amount);
|
|
|
|
// Decrement bookkeeping BEFORE the transfer; both drop in lockstep so the
|
|
// committed reserve stays exactly balance-backed.
|
|
solverBond[asset][msg.sender] = bonded - amount;
|
|
committedLiabilities[asset] -= amount;
|
|
|
|
bool ok = IERC20(asset).transfer(msg.sender, amount);
|
|
if (!ok) revert TransferFailed();
|
|
emit SolverBondWithdrawn(asset, msg.sender, amount);
|
|
}
|
|
|
|
function slashSolverBond(address asset, address solver, uint256 amount) external onlyOwner nonReentrant {
|
|
uint256 cur = solverBond[asset][solver];
|
|
if (cur < amount) revert InsufficientBond(cur, amount);
|
|
solverBond[asset][solver] = cur - amount;
|
|
// The slashed bond is no longer owed to the solver, so it stops being a
|
|
// committed liability. It is destined for the sink.
|
|
committedLiabilities[asset] -= amount;
|
|
// Slash then AereSink to feed the burn flywheel.
|
|
IERC20(asset).approve(SINK, amount);
|
|
(bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount));
|
|
if (!ok) {
|
|
// Sink couldn't accept (e.g. no swap route for an exotic asset). The
|
|
// tokens remain in the hub as GENUINE residual (no longer committed),
|
|
// which permissionless sweepResidual can later re-flush. Clear the
|
|
// dangling approval so it cannot be reused.
|
|
IERC20(asset).approve(SINK, 0);
|
|
}
|
|
emit SolverBondSlashed(asset, solver, amount);
|
|
}
|
|
|
|
/* --------------------------------- core --------------------------------- */
|
|
|
|
function deposit(
|
|
address asset,
|
|
uint256 amount,
|
|
bytes32 intentId,
|
|
address recipient
|
|
) external nonReentrant returns (uint256 amountAfterFee) {
|
|
AssetConfig memory cfg = assetConfig[asset];
|
|
if (!cfg.listed) revert AssetNotListed();
|
|
if (cfg.paused) revert AssetPausedErr();
|
|
if (amount == 0) revert ZeroAmount();
|
|
if (recipient == address(0)) revert ZeroAddress();
|
|
if (cfg.perTxLimit != 0 && amount > cfg.perTxLimit) revert PerTxLimitExceeded(amount, cfg.perTxLimit);
|
|
if (cfg.restrictedRecipient && !recipientAllowed[asset][recipient]) revert RecipientNotAllowed();
|
|
if (intents[intentId].depositor != address(0)) revert DuplicateIntent();
|
|
|
|
uint256 fee = (amount * PROTOCOL_FEE_BPS) / 10_000;
|
|
amountAfterFee = amount - fee;
|
|
|
|
// Pull full amount.
|
|
bool pulled = IERC20(asset).transferFrom(msg.sender, address(this), amount);
|
|
if (!pulled) revert TransferFailed();
|
|
|
|
// Route the fee to the sink. If the sink rejects (unsupported asset),
|
|
// the fee stays in the contract as GENUINE residual (never committed)
|
|
// and sweepResidual handles it.
|
|
if (fee > 0) {
|
|
IERC20(asset).approve(SINK, fee);
|
|
(bool sinkOk, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, fee));
|
|
if (!sinkOk) {
|
|
// Clear the dangling approval; the fee is now genuine residual.
|
|
IERC20(asset).approve(SINK, 0);
|
|
}
|
|
}
|
|
|
|
// The parked remainder is owed to whichever solver settles this intent
|
|
// (or refunded to the depositor on timeout): it is a committed liability.
|
|
committedLiabilities[asset] += amountAfterFee;
|
|
|
|
intents[intentId] = Intent({
|
|
depositor: msg.sender,
|
|
asset: asset,
|
|
amount: amountAfterFee,
|
|
recipient: recipient,
|
|
open: true,
|
|
settled: false,
|
|
solver: address(0),
|
|
claimedAt: 0,
|
|
createdAt: uint64(block.timestamp),
|
|
fillEvidence: "",
|
|
bondLocked: 0
|
|
});
|
|
|
|
emit Deposited(intentId, msg.sender, asset, amountAfterFee, fee, recipient);
|
|
}
|
|
|
|
function claim(bytes32 intentId, bytes calldata fillEvidence) external nonReentrant {
|
|
Intent storage i = intents[intentId];
|
|
if (!i.open) revert IntentUnknown();
|
|
if (i.settled) revert IntentAlreadySettled();
|
|
if (i.claimedAt != 0) revert IntentAlreadyClaimed();
|
|
if (!solverAllowed[i.asset][msg.sender]) revert NotAllowedSolver();
|
|
|
|
AssetConfig memory cfg = assetConfig[i.asset];
|
|
uint256 bond = solverBond[i.asset][msg.sender];
|
|
// The solver must have at least minBond FREE (not already reserved to back
|
|
// another of its outstanding claimed intents). Reserving minBond here keeps
|
|
// this intent's backing slashable until it settles, so a solver cannot
|
|
// withdrawSolverBond to escape a slash of a claimed-but-unsettled intent.
|
|
uint256 lockedNow = lockedBond[i.asset][msg.sender];
|
|
// Defensive against a prior slash having pushed the bond below the locked
|
|
// amount; treat a shortfall as zero free bond rather than underflowing.
|
|
uint256 free = bond > lockedNow ? bond - lockedNow : 0;
|
|
if (free < cfg.minBond) revert InsufficientBond(free, cfg.minBond);
|
|
|
|
lockedBond[i.asset][msg.sender] += cfg.minBond;
|
|
i.bondLocked = cfg.minBond;
|
|
i.solver = msg.sender;
|
|
i.claimedAt = uint64(block.timestamp);
|
|
i.fillEvidence = fillEvidence;
|
|
emit Claimed(intentId, msg.sender, uint64(block.timestamp));
|
|
}
|
|
|
|
function challenge(bytes32 intentId, string calldata reason) external {
|
|
Intent storage i = intents[intentId];
|
|
if (!i.open) revert IntentUnknown();
|
|
if (i.settled) revert IntentAlreadySettled();
|
|
if (i.claimedAt == 0) revert IntentUnknown();
|
|
if (block.timestamp >= uint256(i.claimedAt) + CHALLENGE_WINDOW) revert ChallengeWindowElapsed();
|
|
emit Challenged(intentId, msg.sender, reason);
|
|
}
|
|
|
|
function settle(bytes32 intentId) external nonReentrant {
|
|
Intent storage i = intents[intentId];
|
|
if (!i.open) revert IntentUnknown();
|
|
if (i.settled) revert IntentAlreadySettled();
|
|
if (i.claimedAt == 0) revert IntentUnknown();
|
|
if (block.timestamp < uint256(i.claimedAt) + CHALLENGE_WINDOW) revert WithinChallengeWindow();
|
|
|
|
i.settled = true;
|
|
uint256 amt = i.amount;
|
|
// The parked remainder leaves to the solver, so it is no longer committed.
|
|
committedLiabilities[i.asset] -= amt;
|
|
// Release the bond this intent reserved at claim: it is now free to withdraw
|
|
// (or to keep backing other claims). Guard against underflow if the bond was
|
|
// slashed below the locked amount in the interim.
|
|
uint256 bl = i.bondLocked;
|
|
if (bl > 0) {
|
|
i.bondLocked = 0;
|
|
uint256 lb = lockedBond[i.asset][i.solver];
|
|
lockedBond[i.asset][i.solver] = lb > bl ? lb - bl : 0;
|
|
}
|
|
bool ok = IERC20(i.asset).transfer(i.solver, amt);
|
|
if (!ok) revert TransferFailed();
|
|
emit Settled(intentId, i.solver, amt);
|
|
}
|
|
|
|
/// @notice Depositor refund for an intent that no solver ever claimed.
|
|
/// Callable only by the depositor, only once REFUND_TIMEOUT has
|
|
/// elapsed since deposit, and only while the intent is unclaimed and
|
|
/// unsettled. Returns the parked remainder to the depositor and
|
|
/// closes the intent. This is the F-LOCK liveness fix: a never-claimed
|
|
/// deposit can no longer be trapped forever.
|
|
function cancelIntent(bytes32 intentId) external nonReentrant {
|
|
Intent storage i = intents[intentId];
|
|
if (!i.open) revert IntentUnknown();
|
|
if (i.settled) revert IntentAlreadySettled();
|
|
if (i.claimedAt != 0) revert IntentAlreadyClaimed();
|
|
if (msg.sender != i.depositor) revert NotDepositor();
|
|
uint256 unlockTs = uint256(i.createdAt) + REFUND_TIMEOUT;
|
|
if (block.timestamp < unlockTs) revert RefundTimeoutNotReached(block.timestamp, unlockTs);
|
|
|
|
i.open = false;
|
|
uint256 amt = i.amount;
|
|
// The parked remainder returns to the depositor, so it is no longer committed.
|
|
committedLiabilities[i.asset] -= amt;
|
|
bool ok = IERC20(i.asset).transfer(i.depositor, amt);
|
|
if (!ok) revert TransferFailed();
|
|
emit Refunded(intentId, i.depositor, amt);
|
|
}
|
|
|
|
/* ----------------------------- dust / residual -------------------------- */
|
|
|
|
/// @notice Genuine residual for an asset: hub balance minus committed
|
|
/// liabilities (parked intent remainders + posted bonds). This is
|
|
/// the only value sweepResidual may ever move.
|
|
function residualOf(address asset) public view returns (uint256) {
|
|
uint256 bal = IERC20(asset).balanceOf(address(this));
|
|
uint256 committed = committedLiabilities[asset];
|
|
// committed can never exceed the balance under correct accounting; guard
|
|
// against underflow defensively.
|
|
return bal > committed ? bal - committed : 0;
|
|
}
|
|
|
|
/// @notice Anyone may sweep GENUINE residual balances (fees or slashed bonds
|
|
/// that could not reach the sink on their first try) by re-attempting
|
|
/// the flush. Permissionless, but strictly bounded to residualOf(asset)
|
|
/// so it can NEVER touch a live intent's parked funds or a posted
|
|
/// solver bond. This is the F-SWEEP griefing fix.
|
|
function sweepResidual(address asset, uint256 amount) external nonReentrant {
|
|
if (amount == 0) revert ZeroAmount();
|
|
uint256 residual = residualOf(asset);
|
|
if (amount > residual) revert ExceedsResidual(amount, residual);
|
|
IERC20(asset).approve(SINK, amount);
|
|
(bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount));
|
|
if (!ok) {
|
|
IERC20(asset).approve(SINK, 0);
|
|
revert TransferFailed();
|
|
}
|
|
emit ResidualSwept(asset, amount, msg.sender);
|
|
}
|
|
}
|