// 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); } /// Aave v3's pool. /// /// Note the argument orders, which are NOT uniform across these four. supply /// takes onBehalfOf third and referralCode fourth; borrow takes referralCode /// fourth and onBehalfOf FIFTH; repay has no referral code at all; and withdraw's /// third argument is not an onBehalfOf but a RECEIVER. Copying one to make /// another is how a withdrawal ends up sent somewhere nobody chose, which is /// exactly the mistake the Go builders carry a comment about. interface IAavePool { function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external; function borrow( address asset, uint256 amount, uint256 interestRateMode, uint16 referralCode, address onBehalfOf ) external; function repay(address asset, uint256 amount, uint256 interestRateMode, address onBehalfOf) external returns (uint256); function withdraw(address asset, uint256 amount, address to) external returns (uint256); /// Aave 3.2. Used so a withdraw can be capped at what the account actually /// holds — `balanceOf` on the aToken rounds down, so withdrawing the exact /// figure that was supplied reverts NotEnoughAvailableUserBalance. /// /// Base's pool has it; Arbitrum's classic V3 pool (0x794a6135…) does not and /// answers `getReserveData` instead, whose aToken sits at offset 288 of the /// returned struct. Both are tried, in that order. function getReserveAToken(address asset) external view returns (address); function getUserAccountData(address user) external view returns ( uint256 totalCollateralBase, uint256 totalDebtBase, uint256 availableBorrowsBase, uint256 currentLiquidationThreshold, uint256 ltv, uint256 healthFactor ); } /// @title AaveV3Adapter — the four lending operations, none of which can name a /// beneficiary. /// /// One adapter for the venue rather than four for the operations, because an /// account should not need four modules installed to use one protocol. Which of /// the four an account actually authorised is a separate statement A makes at /// install: an owner who wants an agent that can supply and repay but never /// borrow says so once, and no signed intent widens it. /// /// Every one of the four writes the beneficiary itself. `onBehalfOf` decides /// whose position grows or shrinks; `to` on a withdrawal decides where the /// collateral lands. Both are `address(account)` and neither appears in a signed /// message, which is the same control the supply adapter had and the reason all /// four belong behind it. /// /// Borrow carries one more thing: a loan-to-value ceiling, checked AFTER the /// borrow against Aave's own view of the position. That is PLAN.md's "leverage /// cap enforced on-chain", and until now it existed only in the off-chain /// validator — which bounds what this system asks for and says nothing about /// what the position actually became. contract AaveV3Adapter is BaseAdapter { bytes32 public constant OP_SUPPLY = "supply"; bytes32 public constant OP_BORROW = "borrow"; bytes32 public constant OP_REPAY = "repay"; bytes32 public constant OP_WITHDRAW = "withdraw"; /// Aave's variable rate. Stable rate was deprecated protocol-wide, so this is /// fixed here rather than exposed — a signed field selecting a mode that no /// longer exists is a field with one valid value and a failure mode. uint256 internal constant RATE_MODE_VARIABLE = 2; bytes32 internal constant LEND_TYPEHASH = keccak256( "Lend(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 operation," "address asset,bytes32 venue,uint256 amount,address amountFrom,bytes32 amountFromTask," "uint16 maxLtvBps)" ); struct Lend { address account; address owner; bytes32 taskId; uint256 deadline; /// WHICH of the four. In the signed message, so a signature authorising a /// repayment is not a signature authorising a borrow. bytes32 operation; address asset; bytes32 venue; uint256 amount; /// WHERE THE AMOUNT COMES FROM. Zero means "amount above" — every plan /// naming its own numbers is unchanged. Non-zero names the ADAPTER of an /// earlier step and amountFromTask names WHICH of its steps, because one /// adapter address serves many steps. /// /// This is how "supply all the cbBTC the swap bought" is written: the /// number does not exist when the owner signs, and the alternative is a /// second transaction. address amountFrom; bytes32 amountFromTask; /// The ceiling the position may not exceed after a borrow or a /// withdrawal. Meaningless for supply and repay, which only ever improve /// it. uint16 maxLtvBps; } event Lent( address indexed account, bytes32 indexed operation, address indexed asset, uint256 amount, uint256 ltvBps ); error ApproveFailed(); error UnknownOperation(bytes32 operation); error MissingLtvCap(); // `LtvCapExceeded` now lives on BaseAdapter — see there, H-1. error NothingArrived(address asset); /// The pool answered neither getReserveAToken nor getReserveData for this /// reserve, so there is no balance to clamp a withdraw against. error NoAToken(address asset); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsAaveV3Adapter", "1"); } function digest(Lend calldata l) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( LEND_TYPEHASH, l.account, l.owner, l.taskId, l.deadline, l.operation, l.asset, l.venue, l.amount, l.amountFrom, l.amountFromTask, l.maxLtvBps ) ) ); } function lend(Lend calldata l, bytes calldata signature) external { _consume(l.account, l.owner, l.taskId, l.deadline, digest(l), signature); _requireOperation(l.account, l.operation); address pool = _venueOf(l.account, l.venue); // The number the owner signed, or the one the step they named delivered. // Resolved once, BEFORE anything is charged, so the cap sees what will // actually move rather than a literal that is about to be replaced. uint256 amount = _amountToSpend(l.account, l.amount, l.amountFrom, l.amountFromTask, l.asset); if (l.operation == OP_SUPPLY) { _charge(l.account, l.asset, amount); _pull(l.account, l.asset, pool, amount); _exec( l.account, pool, 0, abi.encodeCall(IAavePool.supply, (l.asset, amount, l.account, uint16(0))) ); _requireNoResidual(l.account, l.asset, pool); } else if (l.operation == OP_REPAY) { _charge(l.account, l.asset, amount); uint256 before = IERC20(l.asset).balanceOf(l.account); _pull(l.account, l.asset, pool, amount); _exec( l.account, pool, 0, abi.encodeCall(IAavePool.repay, (l.asset, amount, RATE_MODE_VARIABLE, l.account)) ); _requireNoResidual(l.account, l.asset, pool); uint256 paid = before - IERC20(l.asset).balanceOf(l.account); if (paid < amount) _refund(l.account, l.asset, amount - paid, amount); } else if (l.operation == OP_BORROW) { // Borrowed value arrives IN the account, so no cap is charged: the // cap bounds what leaves, and this is the opposite. What bounds a // borrow is the loan-to-value ceiling below. // // BUT THE ASSET STILL HAS TO BE ONE THE OWNER LISTED (M-7). The cap // table is v3's ONLY asset allowlist — there is no reserve map and // no curated market here, unlike v4's spokes or Morpho's markets — // and only supply and repay reached `_spendCap`, so borrow and // withdraw consulted it on no path at all. B could create debt in // any Aave reserve the owner never listed, denominated in a token // the account has no policy for; "an asset with no cap is refused" // held for supply and was silently absent here. _requireCuratedAsset(l.asset); _delivered( l.account, l.taskId, l.asset, abi.encodeCall( IAavePool.borrow, (l.asset, amount, RATE_MODE_VARIABLE, uint16(0), l.account) ), pool ); } else if (l.operation == OP_WITHDRAW) { // Same as borrow: value arrives, so nothing is charged, and the // asset still has to be one the guardian curated (M-7). _requireCuratedAsset(l.asset); // CAPPED AT WHAT THE aTOKEN SAYS, because the ordinary request is // the one that fails without it. // // An aToken balance is a scaled balance multiplied by the liquidity // index, and it ROUNDS DOWN: an account that supplied exactly // 10 ether holds 9999999999999999999. "Withdraw what I supplied" // names the figure that was supplied, because that is the only // figure a planner knows — and Aave refuses it with // NotEnoughAvailableUserBalance, one wei short, every time. // `type(uint256).max` worked and the honest number did not. // // FlashEngine._aaveWithdraw, LoanShiftAdapter._unwindAave and // LoanCloseAdapter._repayAndFreeAave all carry this cap. This // adapter — the plain, most-used one — was the last without it. // // MAX IS LEFT ALONE: Aave reads it as "all of it" and works the // figure out itself, which is exactly what the clamp would compute. if (amount != type(uint256).max) { uint256 have = IERC20(_aTokenOf(pool, l.asset)).balanceOf(l.account); if (have < amount) amount = have; } // `to`, not onBehalfOf. The account, written here. _delivered( l.account, l.taskId, l.asset, abi.encodeCall(IAavePool.withdraw, (l.asset, amount, l.account)), pool ); } else { revert UnknownOperation(l.operation); } uint256 ltvBps = _ltvBps(pool, l.account); // Both operations that can make a position WORSE carry a ceiling, and // both are checked after the fact against Aave's own numbers rather than // against what we predicted. A cap that only bounded the request would // say nothing about the position that resulted from it. if (l.operation == OP_BORROW || l.operation == OP_WITHDRAW) { if (l.maxLtvBps == 0) revert MissingLtvCap(); // THE TIGHTER OF THE TWO, because until 2026-09-10 there was only // one and B chose it. `_effectiveLtvCap` takes the minimum of the // signed field and the account's own ceiling, so B may still ask // for something safer than the owner allows and can no longer ask // for something looser. See AgentRegistry.ltvCeilingOf. uint16 cap = _effectiveLtvCap(l.account, l.maxLtvBps); if (ltvBps > cap) revert LtvCapExceeded(ltvBps, cap); } emit Lent(l.account, l.operation, l.asset, amount, ltvBps); } /// Approve the pool for exactly this amount. function _pull(address account, address asset, address pool, uint256 amount) internal { bytes memory ok = _exec(account, asset, 0, abi.encodeCall(IERC20.approve, (pool, amount))); if (ok.length != 0 && !abi.decode(ok, (bool))) revert ApproveFailed(); } /// Run a call that should CREDIT the account, and prove it did. /// /// Measured rather than trusted for the same reason the swap measures its /// output: a later step resolves its amount from what arrived, and a borrow or /// a withdrawal that returned a number without moving anything would push /// that difference through the rest of the plan. function _delivered(address account, bytes32 taskId, address asset, bytes memory call, address pool) internal { uint256 before = IERC20(asset).balanceOf(account); _exec(account, pool, 0, call); uint256 got = IERC20(asset).balanceOf(account); if (got <= before) revert NothingArrived(asset); // A borrow and a withdrawal both DELIVER, so a later step in this // transaction can spend what arrived. The measured delta, as everywhere. _publishDelivered(account, taskId, asset, got - before); } /// The position's loan-to-value, in basis points, from Aave. /// /// Derived from the debt and collateral totals rather than read from the /// `ltv` field the pool returns — that field is the CONFIGURED maximum for /// the collateral mix, not what this account is actually using, and treating /// a limit as a measurement would report a healthy position as maxed out and /// a maxed-out one as healthy. /// The aToken for a reserve, both ways Aave names it. /// /// `getReserveAToken` is Aave 3.2 and Base's pool has it; Arbitrum's classic /// V3 pool (0x794a6135…) does not and answers `getReserveData`, whose aToken /// sits at word 8 — offset 288 — of the returned struct. Reverting rather /// than returning zero: a zero address would make `balanceOf` return nothing /// and clamp every withdraw to zero, which is a silent no-op reporting /// success. The same two-step read as FlashEngine._aTokenOf and /// LoanCloseAdapter._aTokenOf. function _aTokenOf(address pool, address asset) internal view returns (address aToken) { (bool ok, bytes memory ret) = pool.staticcall(abi.encodeWithSelector(IAavePool.getReserveAToken.selector, asset)); if (ok && ret.length >= 32) { aToken = abi.decode(ret, (address)); if (aToken != address(0)) return aToken; } (ok, ret) = pool.staticcall( abi.encodeWithSelector(bytes4(keccak256("getReserveData(address)")), asset) ); if (!ok || ret.length < 288) revert NoAToken(asset); assembly { aToken := mload(add(ret, 288)) } } function _ltvBps(address pool, address account) internal view returns (uint256) { (uint256 collateral, uint256 debt,,,,) = IAavePool(pool).getUserAccountData(account); if (collateral == 0) return debt == 0 ? 0 : type(uint256).max; return (debt * 10_000 + collateral - 1) / collateral; } }