aere-contracts/contracts/settlement/AereSettlementHub.sol
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

302 lines
13 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 AereSettlementHub — 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) → bonded permissionless (Phase 2)
* - 50bps protocol fee, immutable, routed to AereSink
* - RWA receiver allowlist for restricted assets (Foundation
* Seychelles whitelisted for BUIDL/USDY/OUSG/LBTC/SolvBTC/pumpBTC)
* - 6-hour challenge window on settlement claims
*
* @dev LIFECYCLE PER INTENT:
* 1. Counterparty calls deposit(asset, amount, intentId, recipient).
* Hub pulls the asset, routes 50bps to AereSink, parks the
* remainder under intentId.
* 2. Allowed solver calls claim(intentId, fillEvidence).
* Starts the 6h challenge window.
* 3. Anyone may call challenge(intentId, reason) during the
* window; Foundation reviews off-chain.
* 4. After window with no open challenge, anyone calls
* settle(intentId) — hub releases asset to the solver.
*
* BOND HANDLING:
* Each solver posts a bond (per-asset configurable) before being
* allowed to claim that asset class. Bond is slashable by owner
* on validated fraud — slashed amount routes to AereSink.
*
* RWA RECEIVER ALLOWLIST:
* For restricted assets, the `recipient` field on deposit must
* be on a per-asset allowlist (e.g. only the AERE Foundation
* Seychelles address can receive BUIDL on AERE). Mass-market
* assets (USDC.e, USDT.e, etc.) have an empty allowlist meaning
* any recipient is allowed.
*
* AereSink wiring is in the constructor and IMMUTABLE — no
* setSink, no upgrade proxy. Fee bps is constant 50.
*/
contract AereSettlementHub is Ownable, ReentrancyGuard {
/* ------------------------------- immutable ------------------------------- */
address public immutable SINK; // AereSink (immutable)
uint16 public constant PROTOCOL_FEE_BPS = 50;
uint256 public constant CHALLENGE_WINDOW = 6 hours;
/* --------------------------------- 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
address recipient;
bool open;
bool settled;
address solver;
uint64 claimedAt;
bytes fillEvidence;
}
/* --------------------------------- state -------------------------------- */
/// @notice asset → config
mapping(address => AssetConfig) public assetConfig;
/// @notice asset → allowed recipient → true
mapping(address => mapping(address => bool)) public recipientAllowed;
/// @notice intentId → 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 → solver → bool
/// @notice solver bonds, per (asset, solver).
mapping(address => mapping(address => uint256)) public solverBond;
/* --------------------------------- 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 SolverBondDeposited(address indexed asset, address indexed solver, uint256 amount);
event SolverBondSlashed(address indexed asset, address indexed solver, uint256 amount);
/* --------------------------------- 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 IntentUnknown();
error IntentAlreadyClaimed();
error IntentAlreadySettled();
error WithinChallengeWindow();
error ChallengeWindowElapsed();
error TransferFailed();
error DuplicateIntent();
/* ------------------------------- 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.
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;
emit SolverBondDeposited(asset, solver, 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;
// Slash → 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).
// Residual sits in the contract; permissionless sweepResidual handles it.
}
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 — sweepResidual handles it.
if (fee > 0) {
IERC20(asset).approve(SINK, fee);
(bool sinkOk, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, fee));
// Intentional: ignore sinkOk; user flow continues regardless.
sinkOk;
}
intents[intentId] = Intent({
depositor: msg.sender,
asset: asset,
amount: amountAfterFee,
recipient: recipient,
open: true,
settled: false,
solver: address(0),
claimedAt: 0,
fillEvidence: ""
});
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];
if (bond < cfg.minBond) revert InsufficientBond(bond, 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;
bool ok = IERC20(i.asset).transfer(i.solver, i.amount);
if (!ok) revert TransferFailed();
emit Settled(intentId, i.solver, i.amount);
}
/* ----------------------------- dust / residual -------------------------- */
/// @notice Anyone may sweep residual balances (fees that couldn't reach
/// the sink on first try) by re-attempting the flush. Permissionless.
function sweepResidual(address asset, uint256 amount) external nonReentrant {
if (amount == 0) revert ZeroAmount();
IERC20(asset).approve(SINK, amount);
(bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount));
if (!ok) revert TransferFailed();
}
}