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

331 lines
17 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IDestinationSettler} from "./IERC7683.sol";
/// @notice The finality prover this settler's repayment path composes with. It is the
/// deployed AERE zk QBFT light client (contracts/interop/AereZkQbftLightClient.sol,
/// live on consuming chains at 0xCaDA54FA...6488), which verifies an SP1 Groth16
/// proof that ONE AERE block header is FINAL under Hyperledger Besu QBFT and, on
/// success, records the finalized (blockNumber, blockHash) for THIS pinned validator
/// set. This interface is declared here as the documented reference type so an
/// integrator reads the exact surface the origin-side verifier relies on. The
/// light client is deployed on the ORIGIN/consuming chain, not on AERE (AERE is
/// native there), so this settler does NOT call it on-chain; see the repayment
/// section of the NatSpec below and docs/AERE-ERC7683-DESTINATION.md.
interface IAereQbftFinalityLightClient {
/// @notice Highest QBFT-final AERE block number advanced so far.
function finalizedNumber() external view returns (uint64);
/// @notice On-chain block hash of the block at finalizedNumber().
function finalizedBlockHash() external view returns (bytes32);
/// @notice keccak256 over the trusted AERE validator set, in stored order. Every
/// accepted finality proof must attest THIS set. A consumer MUST check this
/// still equals keccak256 over the live qbft_getValidatorsByBlockNumber set.
function trustedValidatorSetRoot() external view returns (bytes32);
/// @notice Stateless finality check: reverts on an invalid proof or a mismatched
/// anchor, otherwise returns the proven block fields. Records nothing.
function verify(bytes calldata publicValues, bytes calldata proof)
external
view
returns (
bytes32 blockHash,
uint64 blockNumber,
bytes32 committedSealHash,
bytes32 validatorSetRoot,
bool finalized,
bool falconVerified
);
}
/**
* @title AereDestinationSettler. ERC-7683 IDestinationSettler (the FILLING side).
*
* @notice AERE Network already ships the ORIGIN side of the ERC-7683 cross-chain intent
* standard (contracts/intents/AereSpokePool.sol, an IOriginSettler): a user locks
* input on the origin chain and emits an Open event that declares the OUTPUT leg
* (token, amount, recipient) they want on the destination chain. This contract is
* the missing DESTINATION half. A solver calls {fill} to deliver those promised
* output tokens to the recipient on AERE, and this contract records the fill so the
* ORIGIN chain can later repay the solver against a proof of AERE's QBFT finality
* (see "Repayment proof path" below) instead of trusting a relayer.
*
* WHY THIS REMOVES THE BRIDGE-LIQUIDITY BOOTSTRAP. A classic lock-and-mint bridge
* must pre-fund destination-side liquidity before it can serve a single transfer.
* An intent design does not: the SOLVER fronts their own liquidity to fill the
* order here, and is repaid the locked input on the origin chain after the fill is
* proven final. AERE therefore never has to seed or custody bridge liquidity; the
* market of solvers supplies it per intent.
*
* @dev SCOPE. This is a settlement / interop-layer contract. It does not touch, gate, or
* change AERE consensus, which remains classical secp256k1 ECDSA QBFT. The finality
* proof referenced below is a proof ABOUT that QBFT finality, verified classically
* today; that is the honest and intended posture, not a post-quantum consensus claim.
*
* @dev ON-CHAIN GUARANTEES (fail-closed). {fill}:
* 1. binds the standard's orderId argument to the orderId carried inside originData
* (OrderIdMismatch otherwise);
* 2. rejects a zero declared recipient (ZeroRecipient);
* 3. rejects a second fill of an order already filled (AlreadyFilled), so an order
* can be filled at most once, so a solver cannot be repaid twice for one intent;
* 4. rejects a delivered output that does not satisfy the order's declared output
* (OutputMismatch): the delivered token and recipient must match exactly and the
* delivered amount must be at least the declared amount. A fill that delivers the
* wrong token, to the wrong recipient, or short can never be recorded;
* 5. delivers the output atomically (SafeERC20 pull from the solver straight to the
* recipient, so this contract never custodies user output), then records the fill
* and emits {Filled}.
*
* @dev REPAYMENT PROOF PATH (how the origin chain repays the solver).
* After {fill}, the fill lives in this contract's storage and in the {Filled} log on
* AERE (chain 2800). To repay the solver, the origin chain must learn "this exact
* fill was included in a block that is FINAL on AERE" WITHOUT trusting a relayer. AERE
* already has the machinery for this, and this settler is built to plug into it:
*
* * FINALITY: contracts/interop/AereZkQbftLightClient.sol proves, in the SP1 zkVM
* (guest `qbft-lightclient-guest`), that an AERE header is QBFT-final: it decodes
* the Besu QBFT extraData, recomputes both Besu hash pre-images, ecrecovers each
* committed seal, and asserts the distinct-validator count reaches the QBFT quorum
* ceil(2N/3) (5-of-7 for AERE's live N=7 set), binding the proof to an IMMUTABLE
* validator-set anchor. See {IAereQbftFinalityLightClient} above.
*
* * INCLUSION + DELIVERY: contracts/interop/AereOutboundVerifierV2.sol is the
* origin-side pattern that CONSUMES such a proof. It accepts an SP1 proof that a
* specific AERE log was included in a QBFT-final AERE block (finality + receipt
* inclusion, one guest), binds the proof's committed validatorSetRoot to its own
* immutable anchor, enforces exactly-once replay, and only then acts. The origin
* chain runs an AereOutboundVerifierV2-style verifier whose trusted "source"
* address is THIS settler and whose event signature is {FILLED_EVENT_SIG}; it
* re-derives {fillCommitment} from the proven log and repays the solver the input
* AereSpokePool locked, against `repaymentAddress` from the fill record.
*
* So the proof shape is exactly the one AereOutboundVerifierV2 already documents (an
* SP1 Groth16 proof over AERE QBFT finality + log inclusion, validator-set-anchored).
* The on-chain surface THIS contract must provide for that path is: the {Filled}
* event, the persisted {FillRecord}, and the {fillCommitment} binder. It provides all
* three. The end-to-end origin-side repayment (off-chain SP1 prover run over the
* `qbft-lightclient-guest` ELF, plus the origin-chain verifier deployment) is NOT
* exercised by this contract or its unit test and is marked [MEASURE]: it needs the
* separate off-chain prover and a second chain. The exact receipt-trie inclusion
* encoding is [VERIFY: confirm against the final AereOutboundVerifierV2 guest].
*
* @dev TRUST MODEL. Read before quoting.
* * Recording a fill here is permissionless and does not, by itself, move the
* origin's locked input. Repayment happens ONLY on the origin chain and ONLY
* behind the finality+inclusion proof above. A forged or unproven fill record
* therefore cannot extract origin funds; the origin verifier's validator-set
* anchor is the gate.
* * Delivery correctness on AERE is enforced on-chain here (the four fail-closed
* checks). Repayment correctness on the origin chain rests on the honesty of
* >= 2f+1 of AERE's QBFT validators (5-of-7 today, all Foundation-operated: an
* operator assumption, not an economic one) plus a sound SP1/Groth16 verifier.
*/
contract AereDestinationSettler is ReentrancyGuard, IDestinationSettler {
using SafeERC20 for IERC20;
/* ------------------------------- constants ------------------------------ */
/// @notice AERE mainnet chain id. The repayment commitment binds to this constant
/// (matching AereOutboundVerifierV2.AERE_CHAIN_ID) rather than block.chainid, so
/// the value the origin-side verifier re-derives is fixed to AERE mainnet.
uint256 public constant AERE_CHAIN_ID = 2800;
/// @notice topic0 preimage of the {Filled} event. An origin-side
/// AereOutboundVerifierV2-style verifier uses this to select this settler's
/// fill log out of a proven-final AERE block.
bytes32 public constant FILLED_EVENT_SIG =
keccak256("Filled(bytes32,address,address,uint256,address,bytes32,uint64,uint64)");
/* --------------------------------- state -------------------------------- */
struct FillRecord {
address filler; // solver who delivered the output (repaid on origin)
address outputToken; // token actually delivered on AERE
uint256 outputAmount; // amount actually delivered
address recipient; // who received the output on AERE
uint64 filledAt; // block.timestamp of the fill
uint64 filledBlock; // block.number of the fill (the inclusion height)
bytes32 repaymentAddress; // solver's repayment address on the ORIGIN chain (bytes32)
}
/// @notice orderId => recorded fill. A record with filler == address(0) means "unfilled".
mapping(bytes32 => FillRecord) public fills;
/// @notice Number of orders filled through this settler.
uint64 public fillCount;
/* --------------------------------- events ------------------------------- */
/// @notice Emitted once per successful fill. This is the log an origin-side finality +
/// inclusion proof carries; its signature is {FILLED_EVENT_SIG}.
event Filled(
bytes32 indexed orderId,
address indexed filler,
address outputToken,
uint256 outputAmount,
address recipient,
bytes32 repaymentAddress,
uint64 filledAt,
uint64 filledBlock
);
/* --------------------------------- errors ------------------------------- */
error OrderIdMismatch(bytes32 fillArg, bytes32 inOriginData);
error ZeroRecipient();
error ZeroAmount();
error ZeroAddress();
error AlreadyFilled(bytes32 orderId);
error OutputMismatch();
error NotFilled(bytes32 orderId);
/* ---------------------------------- fill -------------------------------- */
/// @notice ERC-7683 IDestinationSettler. A solver (msg.sender, the filler) delivers the
/// order's promised output to the recipient on AERE and records the fill.
/// @param orderId the canonical order id (must equal the id inside originData).
/// @param originData the origin settler's opaque output leg, produced by AereSpokePool
/// as abi.encode(bytes32 orderId, address outputToken,
/// uint256 outputAmount, bytes32 recipient), the DECLARED output.
/// @param fillerData the solver's supplied fill data, abi.encode(address deliveredToken,
/// uint256 deliveredAmount, address deliveredRecipient,
/// bytes32 repaymentAddress). deliveredToken / deliveredRecipient must
/// match the declared output exactly and deliveredAmount must be >= the
/// declared amount; repaymentAddress is where the solver is repaid on
/// the origin chain.
function fill(bytes32 orderId, bytes calldata originData, bytes calldata fillerData)
external
override
nonReentrant
{
// ---- Decode the origin's declared output leg. ----
(
bytes32 declOrderId,
address declOutputToken,
uint256 declOutputAmount,
bytes32 declRecipient32
) = abi.decode(originData, (bytes32, address, uint256, bytes32));
// Bind the standard's orderId argument to the id the origin actually signed.
if (declOrderId != orderId) revert OrderIdMismatch(orderId, declOrderId);
// Fail-closed on a zero recipient (the order could never be honestly delivered).
if (declRecipient32 == bytes32(0)) revert ZeroRecipient();
if (declOutputToken == address(0)) revert ZeroAddress();
if (declOutputAmount == 0) revert ZeroAmount();
// Fail-closed on a duplicate: an order can be filled at most once.
if (fills[orderId].filler != address(0)) revert AlreadyFilled(orderId);
address declRecipient = address(uint160(uint256(declRecipient32)));
// ---- Decode the solver's actual delivery + repayment address. ----
(
address deliveredToken,
uint256 deliveredAmount,
address deliveredRecipient,
bytes32 repaymentAddress
) = abi.decode(fillerData, (address, uint256, address, bytes32));
// Fail-closed on a delivered output that does not satisfy the declared output:
// exact token, exact recipient, at-least the declared amount.
if (
deliveredToken != declOutputToken ||
deliveredRecipient != declRecipient ||
deliveredAmount < declOutputAmount
) revert OutputMismatch();
// ---- Effects before interaction (record the fill, then deliver). ----
fills[orderId] = FillRecord({
filler: msg.sender,
outputToken: deliveredToken,
outputAmount: deliveredAmount,
recipient: deliveredRecipient,
filledAt: uint64(block.timestamp),
filledBlock: uint64(block.number),
repaymentAddress: repaymentAddress
});
unchecked {
fillCount += 1;
}
emit Filled(
orderId,
msg.sender,
deliveredToken,
deliveredAmount,
deliveredRecipient,
repaymentAddress,
uint64(block.timestamp),
uint64(block.number)
);
// ---- Deliver the output atomically: solver -> recipient, no custody here. ----
IERC20(deliveredToken).safeTransferFrom(msg.sender, deliveredRecipient, deliveredAmount);
}
/* ------------------------------- fill views ----------------------------- */
/// @notice True once `orderId` has been filled through this settler.
function isFilled(bytes32 orderId) external view returns (bool) {
return fills[orderId].filler != address(0);
}
/// @notice The full recorded fill for `orderId`. Reverts if unfilled.
function getFill(bytes32 orderId)
external
view
returns (
address filler,
address outputToken,
uint256 outputAmount,
address recipient,
uint64 filledAt,
uint64 filledBlock,
bytes32 repaymentAddress
)
{
FillRecord storage r = fills[orderId];
if (r.filler == address(0)) revert NotFilled(orderId);
return (
r.filler,
r.outputToken,
r.outputAmount,
r.recipient,
r.filledAt,
r.filledBlock,
r.repaymentAddress
);
}
/// @notice The canonical repayment commitment an origin-side verifier re-derives from a
/// proven-final {Filled} log to repay the solver. It binds AERE's chain id, this
/// settler, and every economically-relevant fill field (orderId, filler, output
/// token / amount / recipient, and the solver's origin-chain repaymentAddress).
/// An AereOutboundVerifierV2-style verifier on the origin chain, gated by AERE's
/// validator-set anchor, checks a finality+inclusion proof for this fill and then
/// repays `filler`/`repaymentAddress` the locked input. Reverts if unfilled.
/// @dev The exact receipt-trie inclusion encoding the origin guest proves is
/// [VERIFY: confirm against the final AereOutboundVerifierV2 guest]; this
/// commitment fixes the fields that must be bound, independent of that encoding.
function fillCommitment(bytes32 orderId) external view returns (bytes32) {
FillRecord storage r = fills[orderId];
if (r.filler == address(0)) revert NotFilled(orderId);
return keccak256(
abi.encode(
AERE_CHAIN_ID,
address(this),
orderId,
r.filler,
r.outputToken,
r.outputAmount,
r.recipient,
r.repaymentAddress
)
);
}
}