// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; import {curationOf} from "../AdapterCuration.sol"; import {LoanHealth} from "./lib/LoanHealth.sol"; interface IERC20 { function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address owner) external view returns (uint256); } /// Aave v4's spoke. /// /// Four of these share one shape — (reserveId, amount, onBehalfOf) — which is /// worth noting precisely BECAUSE v3's four differ from each other in order and /// count. The uniformity is the exception here, not the rule, and the habit of /// checking each signature separately is the right habit to keep. interface IAaveV4Spoke { function supply(uint256 reserveId, uint256 amount, address onBehalfOf) external; function withdraw(uint256 reserveId, uint256 amount, address onBehalfOf) external; function borrow(uint256 reserveId, uint256 amount, address onBehalfOf) external; function repay(uint256 reserveId, uint256 amount, address onBehalfOf) external; /// The odd one out, and the one that makes a v4 supply COUNT. Its shape /// differs — (reserveId, bool, onBehalfOf) — because it moves no tokens. function setUsingAsCollateral(uint256 reserveId, bool useAsCollateral, address onBehalfOf) external; } /// @title AaveV4Adapter — a different protocol that happens to share a name. /// /// Aave v4 is not a newer v3. v3 has one pool per chain and names an asset by its /// ADDRESS; v4 splits into a liquidity hub and several user-facing SPOKES, and a /// spoke names an asset by a NUMERIC RESERVE ID belonging to that spoke rather /// than to the chain or the asset. USDC is reserve 7 in the Main Spoke and /// reserve 1 in the Gold one. /// /// That numbering is why the agent may not supply a reserve id. Getting one wrong /// does not revert — it acts on whichever asset holds that id, in a market that /// accepts it, and the failure is a position in the wrong asset rather than an /// error. So the agent names an ASSET, and the protocol's curation says which id /// that asset has in which spoke (AdapterCuration, keyed by the SPOKE'S ADDRESS). /// The lookup is comma-ok everywhere: reserve 0 is a real reserve, so an /// unconfigured asset must be distinguishable from one whose id is zero. /// /// The other thing v4 does differently: a supply is a DEPOSIT and nothing more /// until `setUsingAsCollateral` is called for it. Borrowing against a supply /// that was never enabled fails with a health-factor error naming a position the /// protocol does not think exists. That is why enable_collateral is an operation /// here rather than something folded into supply — it is a separate authority, /// and an owner may want an agent that can deposit without being able to put the /// deposit at risk. contract AaveV4Adapter is BaseAdapter { uint256 internal constant BPS = 10_000; bytes32 public constant OP_SUPPLY = "supply"; bytes32 public constant OP_BORROW = "borrow"; bytes32 public constant OP_REPAY = "repay"; bytes32 public constant OP_WITHDRAW = "withdraw"; bytes32 public constant OP_ENABLE_COLLATERAL = "enable_collateral"; bytes32 internal constant V4_TYPEHASH = keccak256( "AaveV4(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 operation," "address asset,bytes32 venue,uint256 amount,address amountFrom,bytes32 amountFromTask,bool enable)" ); struct Act { address account; address owner; bytes32 taskId; uint256 deadline; bytes32 operation; /// The ASSET, by address. Its reserve id is looked up per spoke. address asset; /// WHICH spoke, by name. bytes32 venue; uint256 amount; /// WHERE THE AMOUNT COMES FROM. Zero means "amount above"; non-zero /// names the producing adapter and step. See BaseAdapter. address amountFrom; bytes32 amountFromTask; /// For enable_collateral only. Signed, because switching collateral OFF /// is as consequential as switching it on. bool enable; } // THE RESERVE IDS ARE NOT HERE ANY MORE. They were a per-account copy of // the guardian's map, keyed by venue NAME and written by an install — so a // v2 account, which installs nothing, could reach none of them, and a venue // repointed at another spoke kept the old spoke's ids. They are one // chain-global table in AdapterCuration, keyed by the spoke's ADDRESS, so a // new spoke has no ids until they are mapped for it and every read fails // closed. event Acted( address indexed account, bytes32 indexed operation, address indexed asset, uint256 amount ); error ApproveFailed(); error UnknownOperation(bytes32 operation); /// `enable_collateral` with `enable == false`. Disabling is a different /// authority and would need its own operation. error CollateralCanOnlyBeEnabled(); error NothingArrived(address asset); /// The spoke would not say what this account's position is, so no ceiling /// can be enforced against it. See `_ltvBps`. error NoPositionData(address spoke); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsAaveV4Adapter", "1"); } /// The reserve id `asset` has in the spoke `venue` resolves to, for this /// account. Kept with this signature because the Go side reads it; the /// account is used only to resolve the venue. function reserveOf(address account, bytes32 venue, address asset) public view returns (uint256) { return curationOf(address(registry)).reserveOf(_venueOf(account, venue), asset); } function digest(Act calldata a) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( V4_TYPEHASH, a.account, a.owner, a.taskId, a.deadline, a.operation, a.asset, a.venue, a.amount, a.amountFrom, a.amountFromTask, a.enable ) ) ); } 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 spoke = _venueOf(a.account, a.venue); // A retired reserve refuses only what OPENS exposure: supply, borrow and // enabling collateral. Repay and withdraw keep working, so every // position can still get out. uint256 reserve = curationOf(address(registry)).useReserve( spoke, a.asset, a.operation != OP_REPAY && a.operation != OP_WITHDRAW ); // The literal, or what the step named in amountFrom delivered. uint256 amount = _amountToSpend(a.account, a.amount, a.amountFrom, a.amountFromTask, a.asset); if (a.operation == OP_SUPPLY) { _charge(a.account, a.asset, amount); _approve(a.account, a.asset, spoke, amount); _exec( a.account, spoke, 0, abi.encodeCall(IAaveV4Spoke.supply, (reserve, amount, a.account)) ); _requireNoResidual(a.account, a.asset, spoke); } else if (a.operation == OP_REPAY) { // The spoke may pull less than `amount` — it repays min(amount, debt). _charge(a.account, a.asset, amount); uint256 before = IERC20(a.asset).balanceOf(a.account); _approve(a.account, a.asset, spoke, amount); _exec( a.account, spoke, 0, abi.encodeCall(IAaveV4Spoke.repay, (reserve, amount, a.account)) ); _requireNoResidual(a.account, a.asset, spoke); uint256 paid = before - IERC20(a.asset).balanceOf(a.account); if (paid < amount) _refund(a.account, a.asset, amount - paid, amount); } else if (a.operation == OP_BORROW) { _requireCuratedAsset(a.asset); _credits( a.account, a.taskId, a.asset, spoke, abi.encodeCall(IAaveV4Spoke.borrow, (reserve, amount, a.account)) ); // THE OWNER'S CEILING — H-1 of the round-2 audit. See `_ltvBps` // below for why it can be computed here at all. _requireLtvCeiling(a.account, _ltvBps(spoke, a.account)); } else if (a.operation == OP_WITHDRAW) { _requireCuratedAsset(a.asset); _credits( a.account, a.taskId, a.asset, spoke, abi.encodeCall(IAaveV4Spoke.withdraw, (reserve, amount, a.account)) ); // Withdrawing raises the ratio exactly as borrowing does. _requireLtvCeiling(a.account, _ltvBps(spoke, a.account)); } else if (a.operation == OP_ENABLE_COLLATERAL) { // Moves nothing, so no cap and nothing to measure. What it changes is // whether the deposit is at risk. // // ENABLE ONLY, AND THE OPERATION'S NAME IS NOW ITS MEANING. // // `a.enable` is a bool B fills in, so one allowlist entry called // `enable_collateral` also authorised DISABLING collateral — which // is the opposite act, and the allowlist exists precisely so an // owner can authorise one thing and not its opposite. The off-chain // side never asked for it either: `digest.go` writes // `in.Operation == OpEnableCollateral` and its comment says // "Turning it off is not something a plan can express, so there is // no step that could carry false" — true of the planner, and B does // not go through the planner. // // So `false` is refused rather than ignored. A signed field that is // read and then discarded is worse than one that is not offered: it // makes a plan believe it stated something. If disabling is ever // wanted it gets its OWN operation, so the allowlist can say which. if (!a.enable) revert CollateralCanOnlyBeEnabled(); _exec( a.account, spoke, 0, abi.encodeCall(IAaveV4Spoke.setUsingAsCollateral, (reserve, true, a.account)) ); } else { revert UnknownOperation(a.operation); } emit Acted(a.account, a.operation, a.asset, a.amount); } /// THIS ACCOUNT'S LTV AT THIS SPOKE, IN BASIS POINTS, FROM THE ONLY TWO /// WORDS THIS REPO HAS ESTABLISHED THE MEANING OF. /// /// H-1 of the round-2 audit had this adapter with no LTV bound at all, and /// noted there is no `maxLtvBps` field in the `Act` and none in /// `internal/adapters/digest.go` either — so a signed bound was not /// available without moving the typehash and every account address derived /// from it. The owner's ceiling needs nothing signed, so it is the half /// worth having in any case. See `BaseAdapter._requireLtvCeiling`. /// /// A v4 spoke answers a `getUserAccountData(address)`-shaped call, and /// `internal/lending/positions.go:400-410` records what was actually /// confirmed against a live position of known size — 1,000 of gold against /// 500 borrowed reports 1.5012, and collateral x 0.75 / debt is 1.5: /// /// word 2 (index 1) — liquidation threshold, as a wad (0.75e18 = 75%) /// word 3 (index 2) — health factor, as a wad /// /// The other four are deliberately unused, there and here. Those two are /// enough, because HF = collateral x threshold / debt, so /// /// debt / collateral = threshold / HF /// /// which is the LTV, in the same units, with no amounts needed — and the /// amounts are exactly what this interface does not expose. /// /// A RAW STATICCALL decoded by offset, for the reason `_position` gives in /// FlashEngine: six static words, and a generated decoder is bytes this /// contract does not need to spend. /// /// FAILS CLOSED. A spoke that does not answer, or answers short, reverts /// rather than reading as zero — "the venue told us nothing" must not pass /// for "the position is safe". function _ltvBps(address spoke, address account) internal view returns (uint256) { (bool ok, bytes memory ret) = spoke.staticcall( abi.encodeWithSelector(bytes4(keccak256("getUserAccountData(address)")), account) ); if (!ok || ret.length < 192) revert NoPositionData(spoke); uint256 thresholdWad; uint256 hfWad; assembly { thresholdWad := mload(add(ret, 64)) hfWad := mload(add(ret, 96)) } // An empty spoke reports a zero threshold and the maximum uint256 as a // health factor — "no debt, infinitely safe". The threshold is the tell // that this account has never used this spoke, and both readings are // genuinely zero LTV. // `LoanHealth.v4LtvBps` is internal, so this inlines and links nothing. return LoanHealth.v4LtvBps(thresholdWad, hfWad); } 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(); } function _credits( address account, bytes32 taskId, address asset, address spoke, bytes memory call ) internal { uint256 before = IERC20(asset).balanceOf(account); _exec(account, spoke, 0, call); uint256 got = IERC20(asset).balanceOf(account); if (got <= before) revert NothingArrived(asset); // Borrow and withdraw DELIVER, so a later step in this transaction can // spend what arrived. The measured delta, as everywhere. _publishDelivered(account, taskId, asset, got - before); } }