// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; import {curationOf} from "../AdapterCuration.sol"; interface IERC20 { function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address owner) external view returns (uint256); } interface IMorpho { struct MarketParams { address loanToken; address collateralToken; address oracle; address irm; uint256 lltv; } /// Note where these four END, which is the whole trap: supplyCollateral and /// repay finish with `bytes data`, borrow and withdrawCollateral finish with /// `address receiver`. Two of them take a receiver and two do not, and the /// pairs are not the ones you would guess from the names. function supplyCollateral( MarketParams calldata m, uint256 assets, address onBehalf, bytes calldata data ) external; function borrow( MarketParams calldata m, uint256 assets, uint256 shares, address onBehalf, address receiver ) external returns (uint256, uint256); function repay( MarketParams calldata m, uint256 assets, uint256 shares, address onBehalf, bytes calldata data ) external returns (uint256, uint256); function withdrawCollateral( MarketParams calldata m, uint256 assets, address onBehalf, address receiver ) external; /// The two reads the owner's LTV ceiling is computed from — H-1 of the /// round-2 audit. Both are the singleton's own accounting, and both are /// fresh by the time this adapter reads them: `borrow` and /// `withdrawCollateral` accrue interest before they do anything, so the /// totals below are already this block's. function position(bytes32 id, address user) external view returns (uint256 supplyShares, uint128 borrowShares, uint128 collateral); function market(bytes32 id) external view returns ( uint128 totalSupplyAssets, uint128 totalSupplyShares, uint128 totalBorrowAssets, uint128 totalBorrowShares, uint128 lastUpdate, uint128 fee ); function accrueInterest(MarketParams calldata m) external; } /// A Morpho market IS its oracle, and this is the only thing asked of it: a /// price scaled by 1e36 that converts a collateral quantity into loan-token /// units. Exactly what Morpho's own `_isHealthy` reads. interface IMorphoOracle { function price() external view returns (uint256); } /// @title MorphoAdapter — a market the owner curated, not one the agent picked. /// /// Morpho Blue is permissionless. A market is the hash of five parameters, and /// dozens exist for the same token pair differing only in their oracle — so a /// market IS its oracle, and choosing one by token symbol would sooner or later /// pick a market whose price feed is worthless or hostile. Collateral supplied /// into it is liquidatable the moment anything is borrowed. /// /// So the agent does not supply market parameters and cannot: it names a market, /// and A installed what that name resolves to. Five addresses the agent could /// pass would be five addresses the agent could choose. /// /// The other trap here is shape. `supplyCollateral` and `repay` end with /// `bytes data`; `borrow` and `withdrawCollateral` end with `address receiver`. /// The two that take a receiver are the two that PAY OUT, which is obvious in /// hindsight and is not the pairing the names suggest. Both receivers are /// `address(account)`, written here. /// /// Every call is sized in ASSETS, never in shares: exposing shares would let an /// intent express a quantity in units nothing else in this system uses. The one /// call that goes by shares is a repay whose amount covers the whole debt — /// see `OP_REPAY` — and the adapter derives those shares itself, from the /// position, in the block that repays. contract MorphoAdapter is BaseAdapter { uint256 internal constant BPS = 10_000; /// Morpho Blue's oracle scale. Not configurable there and not here. uint256 internal constant ORACLE_PRICE_SCALE = 1e36; bytes32 public constant OP_SUPPLY_COLLATERAL = "supply_collateral"; bytes32 public constant OP_BORROW = "borrow"; bytes32 public constant OP_REPAY = "repay"; bytes32 public constant OP_WITHDRAW_COLLATERAL = "withdraw_collateral"; bytes32 internal constant MORPHO_TYPEHASH = keccak256( "Morpho(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 operation," "bytes32 market,bytes32 venue,uint256 amount,address amountFrom,bytes32 amountFromTask," "uint16 maxLtvBps)" ); struct Act { address account; address owner; bytes32 taskId; uint256 deadline; bytes32 operation; /// WHICH market, by name, from the set A curated. Never parameters. bytes32 market; bytes32 venue; uint256 amount; /// WHERE THE AMOUNT COMES FROM. Zero means "amount above". Non-zero /// names the ADAPTER of an earlier step and amountFromTask names WHICH /// of its steps — one adapter address serves many. See BaseAdapter. address amountFrom; bytes32 amountFromTask; /// The ceiling this market's position may not exceed after a borrow or /// a withdrawal, in basis points. Ignored by supply and repay, which /// only ever lower it. Held together with the owner's own ceiling: /// the tighter of the two binds (`_effectiveLtvCap`), exactly as /// AaveV3Adapter's `Lend.maxLtvBps` does. /// /// Until generation 7 this struct had no such field. The catalogue /// demanded `max_ltv_bps` on morpho.borrow and morpho.withdraw and the /// compiler carried it, but no signature covered it and nothing read /// it: a borrow planned under a 3,000 bps cap landed at 3,997 on a /// fork of Base (the fork test now TestE2EV1LendingMorphoBorrowCapIsSigned). uint16 maxLtvBps; } // THE MARKETS ARE NOT HERE ANY MORE. They were a per-account copy of the // guardian's list, written by an install — so a v2 account, which installs // nothing, could reach none of them, and a market added later arrived only // by `syncCuration`. They are one chain-global table in AdapterCuration, // and the H-5 oracle pin this kept is rebuilt there as a version pin: an // account's first use of a name pins the version it used. event Acted( address indexed account, bytes32 indexed operation, bytes32 indexed market, uint256 amount ); error ApproveFailed(); error UnknownOperation(bytes32 operation); error NothingArrived(address asset); /// A borrow or a withdrawal signed with no ceiling. "No opinion" must not /// read as "the owner's ceiling": a plan that forgot the field is a plan /// nobody checked. AaveV3Adapter's error, for the same reason. error MissingLtvCap(); /// A repay in a market where this account owes nothing. error NothingToRepay(); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsMorphoAdapter", "1"); } /// The market `account` would act on under `name`: the version it pinned /// on first use, or the live one if it has not used the name yet. Kept with /// this signature because cmd/api, the owner-migrate path and zkexec read it. function marketOf(address account, bytes32 name) public view returns (IMorpho.MarketParams memory) { return curationOf(address(registry)).marketOf(account, name); } function digest(Act calldata a) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( MORPHO_TYPEHASH, a.account, a.owner, a.taskId, a.deadline, a.operation, a.market, a.venue, a.amount, a.amountFrom, a.amountFromTask, a.maxLtvBps ) ) ); } function act(Act calldata a, bytes calldata signature) external { _consume(a.account, a.owner, a.taskId, a.deadline, digest(a), signature); _requireOperation(a.account, a.operation); address morpho = _venueOf(a.account, a.venue); // First use pins; a retired market refuses only what OPENS exposure. IMorpho.MarketParams memory m = curationOf(address(registry)).useMarket( a.account, a.market, a.operation == OP_SUPPLY_COLLATERAL || a.operation == OP_BORROW ); // The literal, or what the step named in amountFrom delivered. uint256 amount = _amountToSpend(a.account, a.amount, a.amountFrom, a.amountFromTask, _assetFor(a, m)); if (a.operation == OP_SUPPLY_COLLATERAL) { _charge(a.account, m.collateralToken, amount); _approve(a.account, m.collateralToken, morpho, amount); _exec( a.account, morpho, 0, abi.encodeCall(IMorpho.supplyCollateral, (m, amount, a.account, bytes(""))) ); _requireNoResidual(a.account, m.collateralToken, morpho); } else if (a.operation == OP_REPAY) { // `amount` IS THE MOST THIS MAY SPEND, and it is charged in full // before anything moves; whatever the repay did not take is // refunded below. A repay of the whole debt takes less than it was // given, by construction, since the debt grows every block. _charge(a.account, m.loanToken, amount); uint256 before = IERC20(m.loanToken).balanceOf(a.account); _approve(a.account, m.loanToken, morpho, amount); _exec(a.account, morpho, 0, _repayCall(a.account, morpho, m, amount)); _requireNoResidual(a.account, m.loanToken, morpho); uint256 paid = before - IERC20(m.loanToken).balanceOf(a.account); if (paid < amount) _refund(a.account, m.loanToken, amount - paid, amount); } else if (a.operation == OP_BORROW) { _requireCuratedAsset(m.loanToken); _credits( a.account, a.taskId, m.loanToken, morpho, abi.encodeCall(IMorpho.borrow, (m, amount, 0, a.account, a.account)) ); // THE OWNER'S CEILING, ON THE WAY OUT — H-1 of the round-2 audit. // // This adapter had no LTV bound of any kind. Morpho's own LLTV is // not one: wstETH/WETH is 94.5%, so an agent could take the account // to ~9400 bps — sixteen times levered, five percent from // liquidation — while the owner's `setLtvCeiling(4000)` sat in // storage being read by nothing. A borrow is an INFLOW so // `_spendCap` never fires, and the liquidation penalty is a loss // with no token outflow and therefore no cap charge either. There // was nothing between the agent and the LLTV. // // Checked AFTER the fact against Morpho's own accounting rather // than against a prediction, exactly as AaveV3Adapter does: a bound // on the request says nothing about the position that resulted. _requireLtv(a, morpho, m); } else if (a.operation == OP_WITHDRAW_COLLATERAL) { _requireCuratedAsset(m.collateralToken); _credits( a.account, a.taskId, m.collateralToken, morpho, abi.encodeCall(IMorpho.withdrawCollateral, (m, amount, a.account, a.account)) ); // The other half of the same door. Removing collateral raises the // ratio exactly as borrowing does, which is why AaveV3Adapter // checks `OP_BORROW || OP_WITHDRAW` and not just the borrow. _requireLtv(a, morpho, m); } else { revert UnknownOperation(a.operation); } emit Acted(a.account, a.operation, a.market, amount); } /// THE REPAY, BY ASSETS OR BY SHARES — and which is the chain's answer, not /// the plan's. /// /// Morpho holds debt in SHARES and converts an asset repay into shares /// rounding DOWN, so an asset amount cannot clear a debt exactly: /// /// - at or above what the shares cost, the conversion asks for more shares /// than the position holds and Morpho's subtraction underflows (Panic /// 0x11) — the "refund the rest" in `act` was unreachable, and a plan /// that repaid 1% over the debt reverted; /// - exactly at the debt as read before the block, it is short by that /// block's interest and leaves dust shares, and dust debt blocks /// withdrawing the collateral (LoanCloseAdapter's `repayAll` note). /// /// Both were measured against the generation-6 adapter on a fork of Base /// (TestE2EV{1,2}LendingMorphoLifecycle, /// TestE2EV1LendingMorphoRepayOverTheDebtClosesIt, as they were then): a plan made of morpho.* steps /// alone could never close a Morpho position. /// /// So the debt is read HERE, accrued to this block, in Morpho's own /// arithmetic. When `amount` covers it, the repay goes BY SHARES — every /// share the account owes — and Morpho takes exactly what they cost, which /// is at most `amount`. Below the debt it goes by assets as before: an /// amount under what the shares cost converts to strictly fewer shares than /// are held, so it cannot underflow. /// /// Bounded either way by `amount`, the signed literal or what the named /// step delivered: it is what `act` charged and the exact allowance Morpho /// is given, and `act` refunds what the repay did not take. function _repayCall(address account, address morpho, IMorpho.MarketParams memory m, uint256 amount) internal returns (bytes memory) { (uint256 shares, uint256 due) = _debtOf(morpho, m, account); if (shares == 0) revert NothingToRepay(); if (amount < due) return abi.encodeCall(IMorpho.repay, (m, amount, 0, account, bytes(""))); return abi.encodeCall(IMorpho.repay, (m, 0, shares, account, bytes(""))); } /// THE TWO BOUNDS ON A BORROW OR A WITHDRAWAL: the one this step signed and /// the owner's own ceiling, whichever is tighter. A zero signed field is /// refused rather than read as "the owner's ceiling". /// /// Checked AFTER the call against Morpho's own accounting, as AaveV3Adapter /// does: a bound on the request says nothing about the position it made. function _requireLtv(Act calldata a, address morpho, IMorpho.MarketParams memory m) internal { if (a.maxLtvBps == 0) revert MissingLtvCap(); uint16 cap = _effectiveLtvCap(a.account, a.maxLtvBps); uint256 ltv = _morphoLtvBps(morpho, m, a.account); if (ltv > cap) revert LtvCapExceeded(ltv, cap); } /// What `account` owes in this market at THIS block: its borrow shares, /// and what Morpho charges to repay them. /// /// Accrued first — a read of stale totals is short by exactly the interest /// that matters. Converted with SharesMathLib's `toAssetsUp` and its /// virtual amounts (VIRTUAL_ASSETS = 1, VIRTUAL_SHARES = 1e6), which is /// what `repay` by shares charges, to the wei. function _debtOf(address morpho, IMorpho.MarketParams memory m, address account) internal returns (uint256 shares, uint256 due) { IMorpho(morpho).accrueInterest(m); bytes32 id = keccak256(abi.encode(m)); (, uint128 borrowShares,) = IMorpho(morpho).position(id, account); (,, uint128 totalBorrowAssets, uint128 totalBorrowShares,,) = IMorpho(morpho).market(id); shares = borrowShares; uint256 num = shares * (uint256(totalBorrowAssets) + 1); uint256 den = uint256(totalBorrowShares) + 1e6; due = (num + den - 1) / den; } /// This account's loan-to-value in this market, in basis points. /// /// MORPHO'S OWN ARITHMETIC, TERM FOR TERM, because a health check that /// rounds differently from the venue's is a check that disagrees with /// reality at the boundary. `_isHealthy` computes /// /// maxBorrow = collateral.mulDivDown(price, ORACLE_PRICE_SCALE).wMulDown(lltv) /// borrowed = borrowShares.toAssetsUp(totalBorrowAssets, totalBorrowShares) /// /// so the collateral's value in loan-token units is /// `collateral * price / 1e36` and the debt is the shares converted UP — /// rounding against the borrower, which is the safe direction here too. /// /// A market id is the hash of the five parameters, which is what makes the /// name-to-market curation meaningful: the id cannot be anything but a /// function of the market the owner curated. /// /// NO COLLATERAL AND NO DEBT is zero, not a division by zero. Debt with no /// collateral is `type(uint256).max`, which fails every ceiling — that is a /// position no ceiling should admit. function _morphoLtvBps(address morpho, IMorpho.MarketParams memory m, address account) internal returns (uint256) { // Accrued inside `_debtOf` — a withdraw after idle time otherwise // ceilings against stale debt and can leave the account over the cap. (uint256 shares, uint256 borrowed) = _debtOf(morpho, m, account); if (shares == 0) return 0; (,, uint128 collateral) = IMorpho(morpho).position(keccak256(abi.encode(m)), account); if (collateral == 0) return type(uint256).max; uint256 value = (uint256(collateral) * IMorphoOracle(m.oracle).price()) / ORACLE_PRICE_SCALE; if (value == 0) return type(uint256).max; return (borrowed * BPS + value - 1) / value; } function _approve(address account, address asset, address spender, uint256 amount) internal { bytes memory ok = _exec(account, asset, 0, abi.encodeCall(IERC20.approve, (spender, amount))); if (ok.length != 0 && !abi.decode(ok, (bool))) revert ApproveFailed(); } /// Run a call that should credit the account, and prove it did. /// WHICH SIDE OF THE MARKET this operation moves. /// /// A Morpho market is a PAIR, and the two operations that spend touch /// different halves: supplying puts up the collateral token, repaying pays /// back the loan token. The bus checks the asset a consumer names against /// what the producer delivered, so naming the wrong half here would turn a /// correct plan into an AssetMismatch — and, worse, naming neither would let /// a loan-token amount be supplied as collateral. /// /// Borrow and withdraw do not consume, so their answer is unused; the /// collateral token is returned for them because a wrong answer nothing /// reads is still a wrong answer somebody will one day read. function _assetFor(Act calldata a, IMorpho.MarketParams memory m) internal pure returns (address) { if (a.operation == OP_REPAY || a.operation == OP_BORROW) return m.loanToken; return m.collateralToken; } function _credits( address account, bytes32 taskId, address asset, address morpho, bytes memory call ) internal { uint256 before = IERC20(asset).balanceOf(account); _exec(account, morpho, 0, call); uint256 got = IERC20(asset).balanceOf(account); if (got <= before) revert NothingArrived(asset); // A borrow and a withdrawal DELIVER, so a later step in this // transaction can spend what arrived. The measured delta, as everywhere. _publishDelivered(account, taskId, asset, got - before); } }