aere-contracts/contracts/bridge/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

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