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 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.
680 lines
31 KiB
Solidity
680 lines
31 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.
|
|
/// @dev OWNER-DIRECTED DESTINATION, NOT A PROTOCOL INVARIANT.
|
|
/// The slashed amount is transferred to `toRecipient` verbatim.
|
|
/// AereSink is only the DEFAULT, applied when `toRecipient` is
|
|
/// address(0). The owner may direct any slash to any address,
|
|
/// including its own. This function therefore does NOT guarantee
|
|
/// that a slashed bond is ever sunk or burned. Integrators must
|
|
/// treat the destination as an owner privilege and read the actual
|
|
/// `dest` from the transaction, not from this comment.
|
|
/// @param solver solver whose bond is reduced.
|
|
/// @param amount amount of WAERE removed from that bond.
|
|
/// @param toRecipient destination of the slashed amount; address(0) selects SINK.
|
|
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
|
|
// Destination is chosen by the owner. SINK is only the address(0) default,
|
|
// so nothing here forces the slashed bond towards the sink.
|
|
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;
|
|
}
|
|
}
|