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