// 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/utils/SafeERC20.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "./AereMessenger.sol"; /** * @title AereSpokePool * @notice Across-protocol-compatible spoke pool for AERE Network. * Implements the intent-based bridging model: * * 1. User deposits inputToken on this chain via `deposit(...)`. * Emits a `V3FundsDeposited` event with intent parameters. * 2. Solver observes the event, sees a profitable spread, and calls * `fillRelay(...)` on the destination chain's SpokePool, transferring * outputToken to the user's recipient address. * 3. After fill, the destination SpokePool emits `FilledV3Relay` and * sends a settlement message via AereMessenger to the origin * SpokePool. The origin SpokePool then releases the locked * inputToken to the solver's repayment address. * * If fillDeadline expires without a fill, user calls `claimRefund(...)` * to recover their input tokens. * * This is the canonical Across v3 flow. By using AereMessenger for the * settlement message layer, AERE's SpokePool is fully Hyperlane-compatible * and ready for the public Across solver network once registered. */ contract AereSpokePool is Ownable, ReentrancyGuard, IMessageRecipient { using SafeERC20 for IERC20; AereMessenger public immutable messenger; uint32 public immutable localDomain; /// Grace period, added on top of fillDeadline, before a depositor may claim /// a refund. /// /// A solver may legitimately fill on the destination at any t < fillDeadline /// (fillRelay irreversibly delivers outputToken to the recipient), while the /// authenticated settlement that credits the solver only reaches this origin /// chain after a cross-chain relay delay L (destination finality + validator /// signing + relayer submission). If a refund were gated on fillDeadline /// alone, a depositor could front-run a still-in-flight settlement in the /// window [fillDeadline, fillDeadline + L]: pull the locked input back, after /// which handle() reverts on the `refunded` flag and the solver who already /// delivered the output is never repaid. /// /// REFUND_BUFFER must generously exceed the worst-case L for the messenger's /// validator set (including finality, signer polling, relayer congestion and /// outage-driven retries). 3 hours is deliberately conservative: it is orders /// of magnitude above the seconds-to-minutes of a healthy relay yet still /// bounds how long a genuinely unfilled deposit's principal is held before the /// depositor can recover it. A settlement for a fill dispatched at (or just /// before) the deadline is therefore delivered, setting `settled`, before any /// refund becomes possible. /// /// NOTE: a timer-based refund cannot fully adjudicate the refund-vs-fill /// outcome; if the relay were delayed beyond REFUND_BUFFER a refund could /// still front-run it. Fully closing that would require an authenticated /// "not filled" attestation over the same messenger path (as Across does). /// While the messenger only carries fill-settlements, this buffer is the /// pragmatic mitigation. uint256 public constant REFUND_BUFFER = 3 hours; /// Auto-incrementing deposit counter — used as the unique deposit ID. uint32 public depositCounter; /// Each enrolled remote SpokePool we can talk to. mapping(uint32 => bytes32) public remoteSpokePool; /// Deposit record: locked input tokens by depositId. /// The requested output leg (outputToken / outputAmount / recipient) is /// stored in full so the origin can verify, at settlement time, that the /// output a solver actually delivered on the destination satisfies the /// order before any locked principal is released. struct Deposit { address depositor; address inputToken; uint256 inputAmount; address outputToken; uint256 outputAmount; address recipient; uint32 destinationChainId; uint32 fillDeadline; bool settled; bool refunded; } mapping(uint32 => Deposit) public deposits; /// Filled relays — replay protection on the destination side. mapping(bytes32 => bool) public filled; /// Pending refunds for solvers awaiting settlement on the origin chain. /// solver => inputToken => amount mapping(address => mapping(address => uint256)) public pendingSettlement; bool public paused; event V3FundsDeposited( uint32 indexed depositId, address indexed depositor, address inputToken, uint256 inputAmount, uint32 destinationChainId, address outputToken, uint256 outputAmount, address recipient, uint32 fillDeadline ); event FilledV3Relay( bytes32 indexed relayHash, address indexed solver, uint32 originChainId, uint32 depositId, address recipient, uint256 outputAmount ); event SettlementClaimed(address indexed solver, address indexed token, uint256 amount); event Refunded(uint32 indexed depositId, address indexed depositor, uint256 amount); event RemoteSpokePoolEnrolled(uint32 indexed domain, bytes32 spokePool); event Paused(bool paused); constructor(AereMessenger _messenger) { messenger = _messenger; localDomain = _messenger.localDomain(); } modifier whenNotPaused() { require(!paused, "SpokePool: paused"); _; } // ───────────────────── Admin ───────────────────── function enrollRemoteSpokePool(uint32 destChainId, bytes32 spokePool) external onlyOwner { remoteSpokePool[destChainId] = spokePool; emit RemoteSpokePoolEnrolled(destChainId, spokePool); } function setPaused(bool _paused) external onlyOwner { paused = _paused; emit Paused(_paused); } // ───────────────────── Deposit (origin-chain user flow) ───────────────────── /** * @notice User deposits inputToken on this chain, committing to receive * outputAmount of outputToken on destinationChainId before fillDeadline. */ function deposit( address inputToken, uint256 inputAmount, address outputToken, uint256 outputAmount, uint32 destinationChainId, address recipient, uint32 fillDeadline ) external nonReentrant whenNotPaused returns (uint32 depositId) { require(remoteSpokePool[destinationChainId] != bytes32(0), "SpokePool: dest not enrolled"); require(fillDeadline > block.timestamp, "SpokePool: deadline passed"); require(inputAmount > 0 && outputAmount > 0, "SpokePool: zero amount"); IERC20(inputToken).safeTransferFrom(msg.sender, address(this), inputAmount); depositId = ++depositCounter; deposits[depositId] = Deposit({ depositor: msg.sender, inputToken: inputToken, inputAmount: inputAmount, outputToken: outputToken, outputAmount: outputAmount, recipient: recipient, destinationChainId: destinationChainId, fillDeadline: fillDeadline, settled: false, refunded: false }); emit V3FundsDeposited( depositId, msg.sender, inputToken, inputAmount, destinationChainId, outputToken, outputAmount, recipient, fillDeadline ); } /// User reclaims input tokens if fill deadline has passed without delivery. /// /// The refund is gated on fillDeadline + REFUND_BUFFER, not fillDeadline /// alone, so an authenticated settlement for a fill dispatched at/just-before /// the deadline always lands (setting `settled`) before a refund is possible. /// This closes the theft-of-principal race where a depositor front-runs an /// in-flight settlement to pull their input back while the solver has already /// delivered the output on the destination. See REFUND_BUFFER above. /// /// A settlement that somehow arrives after a refund still cannot double-pay: /// handle() rejects it on the `refunded` flag. A refund after a genuine /// expiry (no fill) still works once the buffer has elapsed. function claimRefund(uint32 depositId) external nonReentrant { Deposit storage d = deposits[depositId]; require(d.depositor == msg.sender, "SpokePool: not depositor"); require(!d.refunded && !d.settled, "SpokePool: already resolved"); require(block.timestamp > uint256(d.fillDeadline) + REFUND_BUFFER, "SpokePool: refund buffer not elapsed"); d.refunded = true; IERC20(d.inputToken).safeTransfer(msg.sender, d.inputAmount); emit Refunded(depositId, msg.sender, d.inputAmount); } // ───────────────────── Fill (destination-chain solver flow) ───────────────────── /// Relay struct sent from origin chain in the V3FundsDeposited event. struct RelayData { uint32 originChainId; uint32 depositId; address depositor; address inputToken; uint256 inputAmount; address outputToken; uint256 outputAmount; address recipient; uint32 fillDeadline; address solverRepaymentAddress; } /** * @notice Solver delivers outputToken to recipient on this chain. * Solver MUST hold the outputToken in advance (it's their working capital). */ function fillRelay(RelayData calldata r) external nonReentrant whenNotPaused { require(r.fillDeadline > block.timestamp, "SpokePool: fill window closed"); // A fill must actually deliver something. A zero-output "fill" delivers // nothing and must never be able to unlock the origin principal. require(r.outputAmount > 0, "SpokePool: zero output"); bytes32 relayHash = _relayHash(r); require(!filled[relayHash], "SpokePool: already filled"); filled[relayHash] = true; // Solver delivers output tokens directly to recipient IERC20(r.outputToken).safeTransferFrom(msg.sender, r.recipient, r.outputAmount); // Solver accrues a claim on the origin-chain SpokePool's locked input. // The settlement message carries a commitment to the OUTPUT that was // actually delivered here (token / amount / recipient) so the origin can // verify it satisfies the deposit order before releasing any principal. bytes memory body = abi.encode( r.depositId, r.inputToken, r.inputAmount, r.outputToken, r.outputAmount, r.recipient, msg.sender, // solver who filled r.solverRepaymentAddress ); messenger.dispatch(r.originChainId, remoteSpokePool[r.originChainId], body); emit FilledV3Relay(relayHash, msg.sender, r.originChainId, r.depositId, r.recipient, r.outputAmount); } function _relayHash(RelayData calldata r) internal pure returns (bytes32) { return keccak256(abi.encode( r.originChainId, r.depositId, r.depositor, r.inputToken, r.inputAmount, r.outputToken, r.outputAmount, r.recipient, r.fillDeadline, r.solverRepaymentAddress )); } // ───────────────────── Settlement (origin-chain inbound) ───────────────────── /// Called by AereMessenger when a destination chain confirms a fill. function handle(uint32 origin, bytes32 sender, bytes calldata body) external payable override { require(msg.sender == address(messenger), "SpokePool: caller not messenger"); require(sender == remoteSpokePool[origin], "SpokePool: unknown remote spoke"); ( uint32 depositId, address inputToken, uint256 inputAmount, address outputToken, uint256 outputAmount, address recipient, address solver, address solverRepayment ) = abi.decode(body, (uint32, address, uint256, address, uint256, address, address, address)); Deposit storage d = deposits[depositId]; require(!d.settled, "SpokePool: settled"); require(!d.refunded, "SpokePool: refunded"); require(d.inputToken == inputToken && d.inputAmount == inputAmount, "SpokePool: settle mismatch"); // Bind the release of principal to the OUTPUT that was actually // delivered on the destination. Repay only if the fill satisfied the // depositor's order: the correct output token, delivered to the intended // recipient, in at least the requested amount. This closes the theft // where a fill that delivered nothing (or the wrong output, or to the // wrong recipient) could still unlock and steal the locked principal. require(outputToken == d.outputToken, "SpokePool: output token mismatch"); require(recipient == d.recipient, "SpokePool: recipient mismatch"); require(outputAmount >= d.outputAmount, "SpokePool: output below requested"); d.settled = true; // Use repayment address from the message — solver may want to receive on a different address address payoutTo = solverRepayment != address(0) ? solverRepayment : solver; pendingSettlement[payoutTo][inputToken] += inputAmount; } /// Solver withdraws accumulated settlements. function claimSettlement(address token) external nonReentrant { uint256 amount = pendingSettlement[msg.sender][token]; require(amount > 0, "SpokePool: nothing to claim"); pendingSettlement[msg.sender][token] = 0; IERC20(token).safeTransfer(msg.sender, amount); emit SettlementClaimed(msg.sender, token, amount); } }