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.
668 lines
30 KiB
Solidity
668 lines
30 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";
|
|
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
|
|
import "./IERC7683.sol";
|
|
import {PQCGaslessOrder, IAerePQCOrderAuthorizer} from "./IAerePQCOrder.sol";
|
|
|
|
/**
|
|
* @title AereSpokePool — ERC-7683 OriginSettler + Across V3 compatible
|
|
* intent-based bridging endpoint on AERE Network.
|
|
*
|
|
* @notice Users deposit input tokens and emit an Open event encoding their
|
|
* desired output token, amount, recipient, and destination chain.
|
|
* An allowed solver (Phase-1 permissioned set; Phase-2 bonded
|
|
* permissionless) fills the order on the destination chain and
|
|
* later submits proof-of-fill back here for settlement.
|
|
*
|
|
* @dev PHASE 1 (Q3 2026 ship):
|
|
* - Foundation-governed solver allowlist via setSolverAllowed().
|
|
* - Settlement is optimistic with a 6-hour challenge window
|
|
* during which anyone can submit a fraud proof referencing the
|
|
* destination-chain inclusion. Phase-1 fraud proofs are off-
|
|
* chain (Foundation arbitration; bond is forfeit on validated
|
|
* challenge); Phase-2 replaces with UMA optimistic oracle
|
|
* + 2 WETH refundable bond per onboarding.
|
|
*
|
|
* - Each solver deposits a bond at onboarding (configurable;
|
|
* default 1000 AERE). Bond is slashed on fraud.
|
|
*
|
|
* PHASE 2 (Q4 2026):
|
|
* - UMA optimistic oracle plug-in via setOptimisticAdjudicator.
|
|
* - Permissionless solver entry with on-chain bond.
|
|
*
|
|
* PROTOCOL FEE:
|
|
* A fixed 0.05% (50 bps, immutable) of every deposit is routed
|
|
* to AereSink for the protocol-revenue flywheel. Solvers
|
|
* internalise this cost in their quote.
|
|
*
|
|
* ERC-7683 compliance:
|
|
* - open(OnchainCrossChainOrder)
|
|
* - openFor(GaslessCrossChainOrder, sig, fillerData) via EIP-712
|
|
* - resolve(...) / resolveFor(...) view shape resolvers
|
|
* - Open(orderId, resolvedOrder) event with FillInstructions
|
|
*
|
|
* Cross-chain proof flow:
|
|
* 1. User opens order on AERE; AereSpokePool locks input + emits
|
|
* Open. Solver decodes the orderData.
|
|
* 2. Solver fills via IDestinationSettler.fill on the dest chain.
|
|
* 3. Solver submits a "claim" tx on AERE referencing the dest-
|
|
* chain inclusion + a 6-hour challenge timer.
|
|
* 4. If no challenge, AereSpokePool releases the locked input
|
|
* token plus an integer-rounded sliver of AERE as relay
|
|
* incentive (default 1 AERE per claim).
|
|
*/
|
|
contract AereSpokePool is Ownable, ReentrancyGuard, IOriginSettler {
|
|
using ECDSA for bytes32;
|
|
|
|
/* ------------------------------- immutable ------------------------------- */
|
|
|
|
/// @notice WAERE address (used to wrap inbound native value if needed,
|
|
/// and as the bond / relay incentive token).
|
|
address public immutable WAERE_ADDR;
|
|
|
|
/// @notice AereSink — receives the 50 bps protocol fee on every deposit.
|
|
address public immutable SINK;
|
|
|
|
/// @notice Protocol fee in basis points (50 = 0.5%). Immutable.
|
|
uint16 public constant PROTOCOL_FEE_BPS = 50;
|
|
|
|
/// @notice Challenge window for solver claims before settlement is final.
|
|
uint256 public constant CHALLENGE_WINDOW = 6 hours;
|
|
|
|
/// @notice This chain's id (used as origin chain id in resolved orders).
|
|
uint256 public immutable LOCAL_CHAIN_ID;
|
|
|
|
/// @notice The orderDataType discriminator AERE uses. Solvers expect this.
|
|
bytes32 public constant AERE_ORDER_DATA_TYPE = keccak256("AereV3Order");
|
|
|
|
/* --------------------------------- state -------------------------------- */
|
|
|
|
struct Order {
|
|
address user;
|
|
address inputToken;
|
|
uint256 inputAmount;
|
|
address outputToken; // bytes20 of the bytes32 token field
|
|
uint256 outputAmount;
|
|
bytes32 recipient;
|
|
uint64 destinationChainId;
|
|
uint32 fillDeadline;
|
|
bool open;
|
|
bool settled;
|
|
address solver; // who claimed
|
|
uint64 claimedAt; // when claim was submitted (0 if unclaimed)
|
|
}
|
|
|
|
/// @notice orderId → Order
|
|
mapping(bytes32 => Order) public orders;
|
|
|
|
/// @notice Permissioned solver allowlist (Phase 1).
|
|
mapping(address => bool) public solverAllowed;
|
|
|
|
/// @notice Per-solver bond amount.
|
|
mapping(address => uint256) public solverBond;
|
|
|
|
/// @notice Default bond required for new solver onboarding.
|
|
uint256 public defaultBond = 1000 ether;
|
|
|
|
/// @notice Nonce for openFor gasless orders, per user.
|
|
mapping(address => uint256) public gaslessNonce;
|
|
|
|
/// @notice ROUND-3 FIX: per-user open nonce for direct opens (non-gasless).
|
|
/// Two identical opens in the same block previously produced the
|
|
/// same orderId → collision. Now each open consumes a fresh nonce.
|
|
mapping(address => uint256) public openNonce;
|
|
|
|
/// @notice ROUND-3 FIX: total tokens locked across all unsettled orders
|
|
/// per inputToken. sweepResidual subtracts this to prevent
|
|
/// draining user-locked funds even with a mis-set sweepFloor.
|
|
mapping(address => uint256) public totalLocked;
|
|
|
|
/// @notice BOND-ACCOUNTING FIX (SMT-found composition): total solver bonds
|
|
/// held per token. Bonds are always posted in WAERE, but because
|
|
/// WAERE is also an allowed bridge input token, a WAERE-input order
|
|
/// and solver bonds share the same balance. sweepResidual reserves
|
|
/// this ON TOP of totalLocked so bonded funds can never be swept to
|
|
/// SINK. Without it, sweepResidual(WAERE) swept the bonds while the
|
|
/// solverBond ledger stayed stale, and a later slashSolverBond then
|
|
/// drove balanceOf(WAERE) below totalLocked, denying a user's
|
|
/// settle(). Maintained in lockstep by depositSolverBond (+) and
|
|
/// slashSolverBond (-). Invariant: balance[T] >= totalLocked[T] + totalBond[T].
|
|
mapping(address => uint256) public totalBond;
|
|
|
|
/// @notice EIP-712 domain separator (computed in constructor).
|
|
bytes32 public immutable DOMAIN_SEPARATOR;
|
|
bytes32 public constant GASLESS_ORDER_TYPEHASH = keccak256(
|
|
"GaslessCrossChainOrder(address originSettler,address user,uint256 nonce,uint256 originChainId,uint32 openDeadline,uint32 fillDeadline,bytes32 orderDataType,bytes orderData)"
|
|
);
|
|
|
|
/* ---------------------------- PQC intent path --------------------------- */
|
|
|
|
/// @notice The post-quantum order authorizer this pool routes PQC orders through.
|
|
/// It is a crypto-agility CONSUMER over AereCryptoRegistry, so a Foundation
|
|
/// scheme swap (deprecate -> successor -> new ACTIVE) migrates the accepted
|
|
/// PQC scheme with ZERO redeploy of this pool. Optional: unset (address(0))
|
|
/// disables the PQC path and leaves the classical ECDSA paths untouched.
|
|
IAerePQCOrderAuthorizer public pqcAuthorizer;
|
|
|
|
/// @notice Per-user replay nonce for openForPQC. A PQC order is accepted only when
|
|
/// its nonce equals the user's current value, which is then incremented, so
|
|
/// a captured order can never be replayed.
|
|
mapping(address => uint256) public pqcNonce;
|
|
|
|
/// @notice keccak256 commitment of the post-quantum public key a user has bound to
|
|
/// their funding address. openForPQC pulls a user's approved input tokens
|
|
/// only when the presented pubKey matches this commitment, so a third party's
|
|
/// own PQC key can never spend a victim's approval. 0 means "no key bound".
|
|
mapping(address => bytes32) public boundPQCKey;
|
|
|
|
/* --------------------------------- events ------------------------------- */
|
|
|
|
event Opened(bytes32 indexed orderId, address indexed user, uint256 inputAmount, bytes32 recipient, uint64 destinationChainId);
|
|
event Claimed(bytes32 indexed orderId, address indexed solver, uint256 claimedAt);
|
|
event Settled(bytes32 indexed orderId, address indexed solver, uint256 amountReleased);
|
|
event Challenged(bytes32 indexed orderId, address indexed challenger);
|
|
event SolverAllowedSet(address indexed solver, bool allowed);
|
|
event SolverBondDeposited(address indexed solver, uint256 amount);
|
|
event SolverBondSlashed(address indexed solver, uint256 amount);
|
|
event Cancelled(bytes32 indexed orderId, address indexed user, uint256 amountReturned);
|
|
event PQCAuthorizerSet(address indexed authorizer);
|
|
event PQCKeyBound(address indexed user, bytes32 indexed pubKeyCommit);
|
|
event PQCOrderOpened(bytes32 indexed orderId, address indexed user, uint256 nonce, uint256 algorithmId, bytes32 pubKeyCommit);
|
|
|
|
/* --------------------------------- errors ------------------------------- */
|
|
|
|
error ZeroAmount();
|
|
error ZeroAddress();
|
|
error InvalidOrderDataType();
|
|
error FillDeadlineExceeded();
|
|
error UnknownOrder();
|
|
error AlreadySettled();
|
|
error AlreadyClaimed();
|
|
error NotAllowedSolver();
|
|
error InsufficientBond();
|
|
error ChallengeWindowNotElapsed();
|
|
error WithinChallengeWindow();
|
|
error TransferFailed();
|
|
error InvalidSignature();
|
|
error NonceMismatch();
|
|
error DeadlinePassed();
|
|
error FillDeadlineNotPassed();
|
|
error PQCAuthorizerUnset();
|
|
error PQCKeyNotBound();
|
|
error EmptyPubKey();
|
|
|
|
/* ------------------------------- constructor ---------------------------- */
|
|
|
|
constructor(address waere, address sink) {
|
|
if (waere == address(0) || sink == address(0)) revert ZeroAddress();
|
|
WAERE_ADDR = waere;
|
|
SINK = sink;
|
|
LOCAL_CHAIN_ID = block.chainid;
|
|
DOMAIN_SEPARATOR = keccak256(
|
|
abi.encode(
|
|
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
|
|
keccak256("AereSpokePool"),
|
|
keccak256("1"),
|
|
block.chainid,
|
|
address(this)
|
|
)
|
|
);
|
|
}
|
|
|
|
/* ------------------------------- solver admin --------------------------- */
|
|
|
|
function setSolverAllowed(address solver, bool allowed) external onlyOwner {
|
|
if (solver == address(0)) revert ZeroAddress();
|
|
solverAllowed[solver] = allowed;
|
|
emit SolverAllowedSet(solver, allowed);
|
|
}
|
|
|
|
function setDefaultBond(uint256 amount) external onlyOwner {
|
|
defaultBond = amount;
|
|
}
|
|
|
|
/// @notice Wire (or rewire) the post-quantum order authorizer. Owner only. Setting
|
|
/// it to address(0) disables the PQC intent path; the classical open/openFor
|
|
/// paths are unaffected either way.
|
|
function setPQCAuthorizer(address authorizer) external onlyOwner {
|
|
pqcAuthorizer = IAerePQCOrderAuthorizer(authorizer);
|
|
emit PQCAuthorizerSet(authorizer);
|
|
}
|
|
|
|
/// @notice Solver deposits their bond. Permissionless funding.
|
|
function depositSolverBond(address solver, uint256 amount) external nonReentrant {
|
|
if (amount == 0) revert ZeroAmount();
|
|
bool ok = IERC20(WAERE_ADDR).transferFrom(msg.sender, address(this), amount);
|
|
if (!ok) revert TransferFailed();
|
|
solverBond[solver] += amount;
|
|
totalBond[WAERE_ADDR] += amount; // BOND-ACCOUNTING FIX: reserve bonds from sweepResidual
|
|
emit SolverBondDeposited(solver, amount);
|
|
}
|
|
|
|
/// @notice Foundation slashes a solver's bond on validated fraud.
|
|
/// Phase-1: Foundation arbitration; Phase-2: UMA settlement.
|
|
function slashSolverBond(address solver, uint256 amount, address toRecipient) external onlyOwner nonReentrant {
|
|
if (solverBond[solver] < amount) revert InsufficientBond();
|
|
solverBond[solver] -= amount;
|
|
totalBond[WAERE_ADDR] -= amount; // BOND-ACCOUNTING FIX: keep the sweep reserve in lockstep
|
|
// Slash → routes to AereSink so the burn flywheel benefits.
|
|
address dest = toRecipient == address(0) ? SINK : toRecipient;
|
|
bool ok = IERC20(WAERE_ADDR).transfer(dest, amount);
|
|
if (!ok) revert TransferFailed();
|
|
emit SolverBondSlashed(solver, amount);
|
|
}
|
|
|
|
/* -------------------------------- open paths ---------------------------- */
|
|
|
|
function open(OnchainCrossChainOrder calldata order) external override nonReentrant {
|
|
_open(msg.sender, order);
|
|
}
|
|
|
|
function openFor(
|
|
GaslessCrossChainOrder calldata order,
|
|
bytes calldata signature,
|
|
bytes calldata /* originFillerData */
|
|
) external override nonReentrant {
|
|
if (block.timestamp > order.openDeadline) revert DeadlinePassed();
|
|
if (order.originSettler != address(this)) revert InvalidSignature();
|
|
if (order.originChainId != LOCAL_CHAIN_ID) revert InvalidSignature();
|
|
if (order.nonce != gaslessNonce[order.user]) revert NonceMismatch();
|
|
|
|
bytes32 structHash = keccak256(abi.encode(
|
|
GASLESS_ORDER_TYPEHASH,
|
|
order.originSettler,
|
|
order.user,
|
|
order.nonce,
|
|
order.originChainId,
|
|
order.openDeadline,
|
|
order.fillDeadline,
|
|
order.orderDataType,
|
|
keccak256(order.orderData)
|
|
));
|
|
bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
|
|
address signer = digest.recover(signature);
|
|
if (signer != order.user) revert InvalidSignature();
|
|
|
|
gaslessNonce[order.user] = order.nonce + 1;
|
|
|
|
_openInternal(order.user, order.fillDeadline, order.orderDataType, order.orderData);
|
|
}
|
|
|
|
function _open(address user, OnchainCrossChainOrder calldata order) internal {
|
|
_openInternal(user, order.fillDeadline, order.orderDataType, order.orderData);
|
|
}
|
|
|
|
/* ----------------------------- PQC open path ---------------------------- */
|
|
|
|
/// @notice Bind a post-quantum public key to msg.sender's funding address. After
|
|
/// binding, msg.sender's approved input tokens can be spent by openForPQC
|
|
/// only when the caller presents THIS pubKey and a valid PQC signature over
|
|
/// the order. Re-calling rotates the bound key. Stores the keccak256
|
|
/// commitment (the full key is supplied and checked at open time).
|
|
/// @dev This one-time designation is the only ECDSA-signed step; every subsequent
|
|
/// cross-chain order is authorized purely by a Falcon/ML-DSA signature and
|
|
/// may be relayed gaslessly by anyone.
|
|
function bindPQCKey(bytes calldata pubKey) external {
|
|
if (pubKey.length == 0) revert EmptyPubKey();
|
|
bytes32 commit = keccak256(pubKey);
|
|
boundPQCKey[msg.sender] = commit;
|
|
emit PQCKeyBound(msg.sender, commit);
|
|
}
|
|
|
|
/// @notice Open a gasless ERC-7683 order authorized by a POST-QUANTUM signature
|
|
/// (Falcon-512/1024, ML-DSA-44, SLH-DSA-128s), quantum-durable end to end.
|
|
/// Permissionless relayer: anyone may submit the order; the input tokens are
|
|
/// pulled from order.user (who bound `pubKey` and approved this pool).
|
|
///
|
|
/// Verification routes through {pqcAuthorizer} -> AereCryptoRegistry ->
|
|
/// the live native PQC precompile under whatever scheme is currently ACTIVE
|
|
/// (resolveActive), so a Foundation scheme migration is picked up with no
|
|
/// redeploy here. A bad signature, a wrong/hash-only/revoked scheme, a wrong
|
|
/// bound key, an expired open deadline, or a replayed nonce all revert.
|
|
/// @param order the PQC gasless order (see {PQCGaslessOrder}).
|
|
/// @param pubKey the authorized post-quantum public key (must match the user's
|
|
/// bound commitment).
|
|
/// @param signature the PQC signature envelope over the order digest, in the
|
|
/// registry's wire format for the scheme.
|
|
function openForPQC(PQCGaslessOrder calldata order, bytes calldata pubKey, bytes calldata signature)
|
|
external
|
|
nonReentrant
|
|
{
|
|
IAerePQCOrderAuthorizer authorizer = pqcAuthorizer;
|
|
if (address(authorizer) == address(0)) revert PQCAuthorizerUnset();
|
|
if (block.timestamp > order.openDeadline) revert DeadlinePassed();
|
|
if (order.originChainId != LOCAL_CHAIN_ID) revert InvalidSignature();
|
|
if (order.nonce != pqcNonce[order.user]) revert NonceMismatch();
|
|
|
|
// Bind: only the funder's own registered PQC key can spend their approval.
|
|
bytes32 commit = boundPQCKey[order.user];
|
|
if (commit == bytes32(0)) revert PQCKeyNotBound();
|
|
if (keccak256(pubKey) != commit) revert PQCKeyNotBound();
|
|
|
|
// Fail-closed PQC verification through the crypto-agility registry.
|
|
if (!authorizer.checkOrder(address(this), order, pubKey, signature)) revert InvalidSignature();
|
|
|
|
// Consume the nonce BEFORE opening (replay protection, checks-effects-interactions).
|
|
pqcNonce[order.user] = order.nonce + 1;
|
|
|
|
_openInternal(order.user, order.fillDeadline, order.orderDataType, order.orderData);
|
|
|
|
// Recompute the orderId the same way _openInternal did to tag the PQC event.
|
|
// (openNonce was incremented inside _openInternal, so subtract 1 here.)
|
|
emit PQCOrderOpened(
|
|
_lastOrderIdFor(order.user, order.fillDeadline, order.orderData),
|
|
order.user,
|
|
order.nonce,
|
|
order.algorithmId,
|
|
commit
|
|
);
|
|
}
|
|
|
|
/// @dev Reproduce the orderId that _openInternal just stored, for event tagging.
|
|
function _lastOrderIdFor(address user, uint32 fillDeadline_, bytes calldata orderData)
|
|
internal
|
|
view
|
|
returns (bytes32)
|
|
{
|
|
(
|
|
address inputToken,
|
|
uint256 inputAmount,
|
|
address outputToken,
|
|
uint256 outputAmount,
|
|
bytes32 recipient,
|
|
uint64 destinationChainId
|
|
) = abi.decode(orderData, (address, uint256, address, uint256, bytes32, uint64));
|
|
uint256 protocolFee = (inputAmount * PROTOCOL_FEE_BPS) / 10_000;
|
|
uint256 solverPortion = inputAmount - protocolFee;
|
|
// openNonce[user] was post-incremented in _openInternal, so the consumed nonce is (current - 1).
|
|
uint256 consumedOpenNonce = openNonce[user] - 1;
|
|
return keccak256(abi.encode(
|
|
user, inputToken, solverPortion, outputToken, outputAmount, recipient, destinationChainId, fillDeadline_, block.number, consumedOpenNonce
|
|
));
|
|
}
|
|
|
|
function _openInternal(
|
|
address user,
|
|
uint32 fillDeadline_,
|
|
bytes32 orderDataType,
|
|
bytes memory orderData
|
|
) internal {
|
|
if (orderDataType != AERE_ORDER_DATA_TYPE) revert InvalidOrderDataType();
|
|
if (block.timestamp > fillDeadline_) revert FillDeadlineExceeded();
|
|
|
|
// orderData decoding:
|
|
// (address inputToken, uint256 inputAmount, address outputToken,
|
|
// uint256 outputAmount, bytes32 recipient, uint64 destinationChainId)
|
|
(
|
|
address inputToken,
|
|
uint256 inputAmount,
|
|
address outputToken,
|
|
uint256 outputAmount,
|
|
bytes32 recipient,
|
|
uint64 destinationChainId
|
|
) = abi.decode(orderData, (address, uint256, address, uint256, bytes32, uint64));
|
|
|
|
if (inputAmount == 0) revert ZeroAmount();
|
|
if (inputToken == address(0) || outputToken == address(0)) revert ZeroAddress();
|
|
|
|
// Compute the protocol fee bucket and the solver-fillable amount.
|
|
uint256 protocolFee = (inputAmount * PROTOCOL_FEE_BPS) / 10_000;
|
|
uint256 solverPortion = inputAmount - protocolFee;
|
|
|
|
// Pull full inputAmount.
|
|
bool ok = IERC20(inputToken).transferFrom(user, address(this), inputAmount);
|
|
if (!ok) revert TransferFailed();
|
|
|
|
// Forward the protocol fee to AereSink (route to flywheel).
|
|
if (protocolFee > 0) {
|
|
IERC20(inputToken).approve(SINK, protocolFee);
|
|
(bool sinkOk, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", inputToken, protocolFee));
|
|
if (!sinkOk) {
|
|
// If the sink call reverts (e.g. unknown asset), the fee stays
|
|
// in this contract as residual — sweepable to sink later via a
|
|
// permissionless call. Phase-1 doesn't gate user flow on this.
|
|
}
|
|
}
|
|
|
|
// ROUND-3 FIX: include per-user openNonce to prevent orderId collisions
|
|
// across identical orders in the same block.
|
|
uint256 thisOpenNonce = openNonce[user]++;
|
|
bytes32 orderId = keccak256(abi.encode(
|
|
user, inputToken, solverPortion, outputToken, outputAmount, recipient, destinationChainId, fillDeadline_, block.number, thisOpenNonce
|
|
));
|
|
// Track locked tokens for safe-sweep accounting.
|
|
totalLocked[inputToken] += solverPortion;
|
|
|
|
orders[orderId] = Order({
|
|
user: user,
|
|
inputToken: inputToken,
|
|
inputAmount: solverPortion,
|
|
outputToken: outputToken,
|
|
outputAmount: outputAmount,
|
|
recipient: recipient,
|
|
destinationChainId: destinationChainId,
|
|
fillDeadline: fillDeadline_,
|
|
open: true,
|
|
settled: false,
|
|
solver: address(0),
|
|
claimedAt: 0
|
|
});
|
|
|
|
emit Opened(orderId, user, solverPortion, recipient, destinationChainId);
|
|
// ResolvedCrossChainOrder emission deferred to view path (resolve / resolveFor);
|
|
// emitting the full struct from _openInternal would require duplicating
|
|
// the assembly used in _buildResolved. Phase-2 will refactor.
|
|
}
|
|
|
|
/* ------------------------------- claim + settle ------------------------- */
|
|
|
|
/// @notice Allowed solver claims an order after filling on destination.
|
|
/// Settlement is delayed by CHALLENGE_WINDOW.
|
|
function claim(bytes32 orderId) external nonReentrant {
|
|
if (!solverAllowed[msg.sender]) revert NotAllowedSolver();
|
|
|
|
Order storage o = orders[orderId];
|
|
if (!o.open) revert UnknownOrder();
|
|
if (o.settled) revert AlreadySettled();
|
|
if (o.claimedAt != 0) revert AlreadyClaimed();
|
|
|
|
o.solver = msg.sender;
|
|
o.claimedAt = uint64(block.timestamp);
|
|
emit Claimed(orderId, msg.sender, block.timestamp);
|
|
}
|
|
|
|
/// @notice Permissionless settlement after the challenge window with no
|
|
/// outstanding challenge. Releases solverPortion of input token
|
|
/// to the solver.
|
|
function settle(bytes32 orderId) external nonReentrant {
|
|
Order storage o = orders[orderId];
|
|
if (!o.open) revert UnknownOrder();
|
|
if (o.settled) revert AlreadySettled();
|
|
if (o.claimedAt == 0) revert UnknownOrder();
|
|
if (block.timestamp < uint256(o.claimedAt) + CHALLENGE_WINDOW) revert WithinChallengeWindow();
|
|
|
|
o.settled = true;
|
|
// ROUND-3 FIX: decrement totalLocked when funds leave the contract.
|
|
totalLocked[o.inputToken] -= o.inputAmount;
|
|
bool ok = IERC20(o.inputToken).transfer(o.solver, o.inputAmount);
|
|
if (!ok) revert TransferFailed();
|
|
emit Settled(orderId, o.solver, o.inputAmount);
|
|
}
|
|
|
|
/// @notice REFUND PATH (adversarial-review-found HIGH, 2026-07-12).
|
|
/// Without this, a user's locked input principal is stranded
|
|
/// forever whenever no allowed solver ever claims the order — the
|
|
/// routine case for an unprofitable, stale, or deadline-missed
|
|
/// order. settle() can only pay a solver (it reverts unless
|
|
/// claimedAt != 0), and there was no other exit for the locked
|
|
/// funds. After the fillDeadline passes with the order still
|
|
/// unclaimed, this returns the locked principal to the original
|
|
/// user. Mirrors Across V3 / ERC-7683 expired-deposit refund.
|
|
/// Permissionless (funds always go to o.user), so a depositor who
|
|
/// has lost gas access can still be made whole by any keeper.
|
|
/// Keeps the balance >= totalLocked + totalBond invariant: it
|
|
/// decrements totalLocked by exactly the amount transferred out.
|
|
function cancel(bytes32 orderId) external nonReentrant {
|
|
Order storage o = orders[orderId];
|
|
if (!o.open) revert UnknownOrder();
|
|
if (o.settled) revert AlreadySettled();
|
|
if (o.claimedAt != 0) revert AlreadyClaimed();
|
|
if (block.timestamp <= uint256(o.fillDeadline)) revert FillDeadlineNotPassed();
|
|
|
|
o.settled = true;
|
|
// Decrement totalLocked in lockstep with the outbound transfer.
|
|
totalLocked[o.inputToken] -= o.inputAmount;
|
|
bool ok = IERC20(o.inputToken).transfer(o.user, o.inputAmount);
|
|
if (!ok) revert TransferFailed();
|
|
emit Cancelled(orderId, o.user, o.inputAmount);
|
|
}
|
|
|
|
/// @notice Phase-1: anyone can mark a claim as challenged off-chain;
|
|
/// the Foundation reviews and either slashes the solver's bond
|
|
/// (via slashSolverBond) or dismisses. Phase-2 replaces with
|
|
/// UMA optimistic oracle.
|
|
function challenge(bytes32 orderId) external {
|
|
Order storage o = orders[orderId];
|
|
if (!o.open) revert UnknownOrder();
|
|
if (o.settled) revert AlreadySettled();
|
|
if (o.claimedAt == 0) revert UnknownOrder();
|
|
if (block.timestamp >= uint256(o.claimedAt) + CHALLENGE_WINDOW) revert ChallengeWindowNotElapsed();
|
|
|
|
// Phase-1: emit only. Foundation off-chain handles the rest.
|
|
// Phase-2 hook: call into the optimistic adjudicator.
|
|
emit Challenged(orderId, msg.sender);
|
|
}
|
|
|
|
/* ------------------------------- view: resolve -------------------------- */
|
|
|
|
function resolve(OnchainCrossChainOrder calldata order)
|
|
external
|
|
view
|
|
override
|
|
returns (ResolvedCrossChainOrder memory)
|
|
{
|
|
return _resolveShape(msg.sender, order);
|
|
}
|
|
|
|
function resolveFor(GaslessCrossChainOrder calldata order, bytes calldata /* originFillerData */)
|
|
external
|
|
view
|
|
override
|
|
returns (ResolvedCrossChainOrder memory)
|
|
{
|
|
return _resolveShapeMem(order.user, order.fillDeadline, order.orderData);
|
|
}
|
|
|
|
function _resolveShape(address user, OnchainCrossChainOrder calldata order)
|
|
internal
|
|
view
|
|
returns (ResolvedCrossChainOrder memory)
|
|
{
|
|
return _resolveShapeMem(user, order.fillDeadline, order.orderData);
|
|
}
|
|
|
|
function _resolveShapeMem(address user, uint32 fillDeadline_, bytes memory orderData)
|
|
internal
|
|
view
|
|
returns (ResolvedCrossChainOrder memory)
|
|
{
|
|
(
|
|
address inputToken,
|
|
uint256 inputAmount,
|
|
address outputToken,
|
|
uint256 outputAmount,
|
|
bytes32 recipient,
|
|
uint64 destinationChainId
|
|
) = abi.decode(orderData, (address, uint256, address, uint256, bytes32, uint64));
|
|
|
|
uint256 protocolFee = (inputAmount * PROTOCOL_FEE_BPS) / 10_000;
|
|
uint256 solverPortion = inputAmount - protocolFee;
|
|
|
|
bytes32 orderId = keccak256(abi.encode(
|
|
user, inputToken, solverPortion, outputToken, outputAmount, recipient, destinationChainId, fillDeadline_, block.number
|
|
));
|
|
|
|
return _buildResolved(user, fillDeadline_, inputToken, solverPortion, outputToken, outputAmount, recipient, destinationChainId, orderId);
|
|
}
|
|
|
|
function _buildResolved(
|
|
address user,
|
|
uint32 fillDeadline_,
|
|
address inputToken,
|
|
uint256 solverPortion,
|
|
address outputToken,
|
|
uint256 outputAmount,
|
|
bytes32 recipient,
|
|
uint64 destinationChainId,
|
|
bytes32 orderId
|
|
) internal view returns (ResolvedCrossChainOrder memory) {
|
|
Input[] memory minReceived = new Input[](1);
|
|
minReceived[0] = Input({ token: bytes32(uint256(uint160(inputToken))), amount: solverPortion });
|
|
|
|
Output[] memory maxSpent = new Output[](1);
|
|
maxSpent[0] = Output({
|
|
token: bytes32(uint256(uint160(outputToken))),
|
|
amount: outputAmount,
|
|
recipient: recipient,
|
|
chainId: destinationChainId
|
|
});
|
|
|
|
Output[] memory receivers = new Output[](1);
|
|
receivers[0] = maxSpent[0];
|
|
|
|
FillInstruction[] memory fills = new FillInstruction[](1);
|
|
fills[0] = FillInstruction({
|
|
destinationChainId: destinationChainId,
|
|
destinationSettler: bytes32(0), // resolved off-chain via the registry
|
|
originData: abi.encode(orderId, outputToken, outputAmount, recipient)
|
|
});
|
|
|
|
return ResolvedCrossChainOrder({
|
|
user: user,
|
|
originChainId: LOCAL_CHAIN_ID,
|
|
openDeadline: uint32(block.timestamp + 1 hours),
|
|
fillDeadline: fillDeadline_,
|
|
orderId: orderId,
|
|
minReceived: minReceived,
|
|
maxSpent: maxSpent,
|
|
receivers: receivers,
|
|
fillInstructions: fills
|
|
});
|
|
}
|
|
|
|
/* ------------------------------- dust sweep ----------------------------- */
|
|
|
|
/// @notice Anyone can flush leftover input tokens (e.g. from failed sink
|
|
/// flush calls) through to AereSink. Permissionless.
|
|
function sweepResidual(address token) external nonReentrant {
|
|
uint256 bal = IERC20(token).balanceOf(address(this));
|
|
|
|
// ROUND-3 FIX: subtract totalLocked[token] AND the Foundation-set
|
|
// sweepFloor. The previous logic relied solely on sweepFloor, which
|
|
// a mis-setting Foundation could underset and accidentally drain
|
|
// active user orders. Now even with sweepFloor=0, the contract
|
|
// refuses to sweep tokens currently locked in open orders.
|
|
// BOND-ACCOUNTING FIX: reserve solver bonds (totalBond) ON TOP of locked
|
|
// user principal (totalLocked), so bonded WAERE can never be swept to SINK.
|
|
// For non-bond tokens totalBond is 0, so behavior is unchanged there.
|
|
uint256 locked = totalLocked[token] + totalBond[token];
|
|
uint256 floor = sweepFloor[token];
|
|
uint256 reserved = locked > floor ? locked : floor;
|
|
if (bal <= reserved) revert ZeroAmount();
|
|
uint256 amount = bal - reserved;
|
|
|
|
IERC20(token).approve(SINK, amount);
|
|
(bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", token, amount));
|
|
if (!ok) revert TransferFailed();
|
|
}
|
|
|
|
mapping(address => uint256) public sweepFloor;
|
|
function setSweepFloor(address token, uint256 amount) external onlyOwner {
|
|
sweepFloor[token] = amount;
|
|
}
|
|
}
|