// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; interface IERC20 { function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address owner) external view returns (uint256); } /// 0xequity's ZeroXAggregationRouter, selector 0xb31c29db. /// /// It replaces a direct Uniswap V3 SwapRouter02 call. The difference that /// matters here is not the venue count: it is that the ROUTE is now an argument. /// V3's exactInputSingle named one pool by fee tier; this takes a list of pools /// and the split between them, computed off chain against a block. /// /// The selector was 0x3bcb56df before per-hop price limits were added. Old /// calldata is not REJECTED by the new router — it is decoded as something else. interface IAggregationRouter { struct Hop { address pool; bool zeroForOne; /// Q64.96 sqrt price at which this hop stops. Zero means no limit, and /// zero is what this adapter always sends: a limit makes a hop consume /// less than it was offered, and a partial fill is a different operation /// from the one the owner signed. uint160 sqrtPriceLimitX96; } struct Path { uint16 portionBps; Hop[] hops; } struct SwapDescription { address tokenIn; address tokenOut; address recipient; uint256 amountIn; uint256 minAmountOut; uint256 deadline; /// The protocol fee ceiling the caller accepts, in bps. A fee raised /// between quoting and mining reverts legibly instead of arriving as an /// unexplained shortfall. uint16 maxFeeBps; /// False is fill-or-kill. Always false here, for the same reason /// sqrtPriceLimitX96 is zero: a step whose output a later step spends is /// broken by a partial fill. bool partiallyFillable; } function swap(SwapDescription calldata desc, Path[] calldata paths) external payable returns (uint256 amountOut); } /// @title SwapAdapter — trade, at a price the agent signed, into the account. /// /// The first adapter where the VENUE could lie. A supply either moves the asset /// or reverts; a swap returns a number, and a number is a claim. So this is the /// adapter that forces PLAN.md's "output measured as balance delta": the router's /// return value is read and then ignored in favour of what the account's balance /// actually did. /// /// The ROUTE is not signed, and that is deliberate. /// /// It is the one argument here the owner does not cover, because it cannot: a /// route is priced against a block, and a signature is prepared before one. /// Requiring it in the digest would mean re-signing per block or signing a route /// that is already stale — and neither buys anything, because a route cannot /// steal. Every pool it names must be on the router's own allowlist, the output /// lands at an address written here, and the floor it must clear is signed. /// /// So what a wrong route can do is produce a worse fill, and `minOut` is exactly /// the bound on that. What it cannot do is change the pair, the size, the /// recipient, or the least the account will accept. This is the same argument /// the router's own documentation makes for its route being untrusted, and it is /// why the balance-delta check below is the one that decides — not the amount /// the router reports. /// /// It does not decide where the output goes. `recipient` is `address(account)`, /// written here. The router takes an arbitrary recipient and would happily send /// a whole trade somewhere else. contract SwapAdapter is BaseAdapter { /// What A authorises at install to enable this adapter's one operation. /// Installing a module and authorising what it does are separate statements. bytes32 public constant OP_SWAP = "swap"; bytes32 internal constant SWAP_TYPEHASH = keccak256( "Swap(address account,address owner,bytes32 taskId,uint256 deadline,address assetIn,address assetOut," "uint16 maxFeeBps,bytes32 venue,uint256 amountIn,address amountFrom,bytes32 amountFromTask," "uint256 minOut,bool partiallyFillable)" ); struct Swap { address account; /// WHOSE account. Signed and checked against what the account itself /// says, so a plan that named the wrong one is refused rather than /// authorised by a signature that happens to be valid for it. address owner; bytes32 taskId; uint256 deadline; address assetIn; address assetOut; /// The protocol fee ceiling the owner accepts, in bps. /// /// Replaces the Uniswap fee tier, which named WHICH pool of a pair and /// is meaningless to a router that is handed the pools. Signed for the /// same reason minOut is: it bounds what the venue may take, and a venue /// that could raise it after quoting could take the difference. uint16 maxFeeBps; /// WHICH router, by name, from the set A installed. bytes32 venue; uint256 amountIn; /// WHERE THE AMOUNT COMES FROM. Zero means "amountIn above", which is /// every plan that names its own numbers and is unchanged by this field /// existing. /// /// Non-zero names the ADAPTER of an earlier step, and amountFromTask /// names WHICH of its steps — swap.router is one address and a plan may /// swap twice. The pair is what makes "spend what that step delivered" /// mean one specific step rather than whatever that contract last /// published. /// /// Signed, because whose output this spends is a decision, not a detail. address amountFrom; bytes32 amountFromTask; /// The floor. Signed, and enforced twice — once by the router and once /// here against the balance, which is the one the router cannot fake. uint256 minOut; /// Whether a short fill is acceptable. FALSE is fill-or-kill and is the /// default every caller should keep. /// /// Signed, because it decides what the account may end up holding. A /// partial fill returns less of the output AND consumes less of the /// input, so a step whose amount a later step spends is left referring to /// a number nobody planned — and the plan has no way to notice, because /// a partial fill is a success. Someone who genuinely wants one has to /// say so in the message the owner signed. bool partiallyFillable; } event Swapped( address indexed account, address indexed assetIn, address indexed assetOut, uint256 amountIn, uint256 delivered, uint256 reported ); error ApproveFailed(); error NoFloor(); error NoRoute(); error SameAsset(); error DeliveredLessThanFloor(uint256 floor, uint256 delivered); error PaidMoreThanAuthorised(uint256 authorised, uint256 paid); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsSwapAdapter", "1"); } function digest(Swap calldata s) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( SWAP_TYPEHASH, s.account, s.owner, s.taskId, s.deadline, s.assetIn, s.assetOut, s.maxFeeBps, s.venue, s.amountIn, s.amountFrom, s.amountFromTask, s.minOut, s.partiallyFillable ) ) ); } /// Approves the router for exactly the input and calls it. /// /// A separate function, and not for tidiness: the description is eight /// fields and the route is a second dynamic argument, which together run the /// stack out inside `swap`. Nothing it computes has to survive it — every /// check afterwards reads balances back rather than trusting anything from /// here — so the only thing returned is the amount the router CLAIMS, kept /// solely to be emitted beside the amount that actually arrived. function _approveAndSwap( Swap calldata s, uint256 amountIn, address router, IAggregationRouter.Path[] calldata route ) internal returns (uint256 reported) { bytes memory ok = _exec( s.account, s.assetIn, 0, abi.encodeCall(IERC20.approve, (router, amountIn)) ); if (ok.length != 0 && !abi.decode(ok, (bool))) revert ApproveFailed(); bytes memory ret = _exec( s.account, router, 0, abi.encodeCall( IAggregationRouter.swap, ( IAggregationRouter.SwapDescription({ tokenIn: s.assetIn, tokenOut: s.assetOut, // Not a parameter. The router would take any address. recipient: s.account, amountIn: amountIn, minAmountOut: s.minOut, deadline: s.deadline, maxFeeBps: s.maxFeeBps, partiallyFillable: s.partiallyFillable }), route ) ) ); return ret.length >= 32 ? abi.decode(ret, (uint256)) : 0; } /// @param route The pools to trade through and how to split between them. /// NOT covered by the signature — see the note above the contract. /// Supplied by whoever submits, priced against a recent block. function swap( Swap calldata s, bytes calldata signature, IAggregationRouter.Path[] calldata route ) external returns (uint256 delivered) { // A route is required. An empty one is not "let the router decide" — the // router has nothing to decide with, and the revert would come from // inside it rather than from the thing that is actually missing. if (route.length == 0) revert NoRoute(); // A swap with no floor is a swap at any price. Refused rather than // defaulted, because the safe default does not exist: zero means "take // whatever comes back". if (s.minOut == 0) revert NoFloor(); if (s.assetIn == s.assetOut) revert SameAsset(); _consume(s.account, s.owner, s.taskId, s.deadline, digest(s), signature); _requireOperation(s.account, OP_SWAP); // THE ASSET BOUGHT MUST BE ONE THE OWNER LISTED. Only `assetIn` needed // a cap row, so B could name any token at all as the destination — and // the cap is charged on the input while the loss lands on the output. // See BaseAdapter._requireCuratedAsset for the token-B-deploys-itself // shape this closes. _requireCuratedAsset(s.assetOut); // WHAT TO SPEND, decided before anything is charged or approved. A // literal is the number the owner signed; a producer is the amount an // earlier step in this transaction actually delivered, which is the only // way to write "swap all of it" when "it" does not exist yet. uint256 amountIn = _amountToSpend(s.account, s.amountIn, s.amountFrom, s.amountFromTask, s.assetIn); _charge(s.account, s.assetIn, amountIn); address router = _venueOf(s.account, s.venue); uint256 inBefore = IERC20(s.assetIn).balanceOf(s.account); uint256 outBefore = IERC20(s.assetOut).balanceOf(s.account); uint256 reported = _approveAndSwap(s, amountIn, router, route); _requireNoResidual(s.account, s.assetIn, router); // What ARRIVED, which is the only number that is not a claim. The router // already enforced amountOutMinimum internally and this looks redundant; // it is not. A router that returns a number without delivering, an output // token that takes a fee on transfer, and a rebasing one all produce a // reported amount that the balance does not support — and every step // downstream resolves its own amount from this figure, so believing the // report would propagate the difference through the rest of the plan. delivered = IERC20(s.assetOut).balanceOf(s.account) - outBefore; if (delivered < s.minOut) revert DeliveredLessThanFloor(s.minOut, delivered); uint256 paid = inBefore - IERC20(s.assetIn).balanceOf(s.account); if (paid > amountIn) revert PaidMoreThanAuthorised(amountIn, paid); if (paid < amountIn) _refund(s.account, s.assetIn, amountIn - paid, amountIn); // Partial fill (and anything else that consumes less than the signed // ceiling) must not burn the unused epoch budget. Repay already refunds; // a later swap in the same epoch would otherwise be refused against money // that never left. // `reported` is emitted beside `delivered` rather than dropped: when the // two differ, the difference is the evidence, and it is not recoverable // afterwards from a receipt that only carries one of them. // WHAT ARRIVED, handed to later steps in this transaction. The measured // delta, never `reported` — the paragraph above is the whole reason. _publishDelivered(s.account, s.taskId, s.assetOut, delivered); emit Swapped(s.account, s.assetIn, s.assetOut, amountIn, delivered, reported); } }