aere-contracts/contracts/intents/AereSpokePool.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

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;
}
}