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.
325 lines
14 KiB
Solidity
325 lines
14 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/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);
|
|
}
|
|
}
|