aere-contracts/contracts/settlement/AereSettlementHub.sol
Aere Network acac2f00a6
Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The unpublished line of work joins the sanitized public line
The published line and the local line had no common ancestor: the public one
carried the redaction pass, the local one carried three weeks of corrections
that never shipped. This commit ports the local work onto the public line,
keeps every public redaction, and extends the same discretion to seven client
mentions that were still named in published comments.

Carried: LICENSE year and LICENSING.md; the measured burn figures replacing
the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is
a share of validator coinbase revenue, which is zero today); 'audited' removed
from next to Bouncy Castle; citation paths rewritten to published form with
CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and
ownership comments brought down to what the code does; the AerePyth repair;
the shutter test helper the tests cite; runnable package.json entries; the CI
file split into a GitHub/Gitea twin pair with a real measured test-run status;
and the .gitignore hardening written after a compiled artifact leaked a local
path in a sibling repository. A false '2-of-3 multisig' description of the
owner account is corrected to what the chain measures: an externally owned
account. The self-audit findings catalog stays unpublished pending an explicit
decision.
2026-08-15 13:59:30 +03:00

306 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);
}
/// @notice Owner slashes a solver bond. The destination is the immutable SINK
/// and the owner cannot redirect it, but delivery is BEST EFFORT: the
/// flush below is a low-level call whose failure is swallowed, so a
/// successful slash does NOT prove the amount reached the sink.
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;
// Best-effort push to the immutable SINK; see the failure branch below.
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();
}
}