// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {IMorpho} from "./adapters/MorphoAdapter.sol"; /// What the curation needs from the registry. interface ICurationRegistry { function guardian() external view returns (address); function isCuratedAsset(address asset) external view returns (bool); function isWhitelistedAdapter(address adapter) external view returns (bool); function venueTarget(bytes32 venue) external view returns (address); function activationDelayEnabled() external view returns (bool); } interface ICurationPointer { function curation() external view returns (address); } error NoCuration(); /// The curation, as an adapter finds it: through the registry, which points at /// it once. Refused while unset rather than read as an empty table. function curationOf(address registry) view returns (ICuration c) { c = ICuration(ICurationPointer(registry).curation()); if (address(c) == address(0)) revert NoCuration(); } /// What an adapter asks. `use*` is the path that acts — it pins, and it refuses /// to OPEN exposure in a retired entry; the plain views only answer. /// /// `useMarket` is NOT view: an opening use writes the account's first pin. /// Every caller is an adapter's acting path, which is a transaction anyway. interface ICuration { function useMarket(address account, bytes32 name, bool opening) external returns (IMorpho.MarketParams memory); function marketOf(address account, bytes32 name) external view returns (IMorpho.MarketParams memory); function useReserve(address spoke, address asset, bool opening) external view returns (uint256); function reserveOf(address spoke, address asset) external view returns (uint256); function useProperty(bytes32 name, bool opening) external view returns (address vault, address token, address marketplace, string memory symbol); function propertyOf(bytes32 name) external view returns (address vault, address token, address marketplace, string memory symbol, bool retired); } /// THE PROTOCOL'S CURATED MARKETS, RESERVES AND PROPERTIES — ONE TABLE PER CHAIN. /// /// WHAT THIS REPLACES. MorphoAdapter, AaveV4Adapter and PropertyAdapter each /// kept a PER-ACCOUNT copy of the guardian's list, written by an install. A v2 /// account installs nothing, so every one of its reads reverted — Morpho, /// every leverage and loan adapter, Aave v4 and property were unreachable for /// it. And the copies were snapshots: a market added after an account installed /// arrived only by `syncCuration`, which is where M-1, M-3, H-5 and M-18 lived. /// Venues made this move already (`AgentRegistry._venueTarget`); these did not. /// /// ONLY WHAT THE PROTOCOL CURATES. The guardian writes these; an owner no longer /// adds a market or a property of their own. That is the owner's decision /// (2026-09-27): only protocol-allowed markets. /// /// THE THREE RULES, the same for all three tables: /// /// A NEW NAME IS LIVE AT ONCE. It adds a choice and moves nothing an account /// already holds, and an honest agent never names a market it does not know. /// /// A CHANGE TO AN EXISTING MARKET WAITS. `proposeMarket` writes a new version /// that goes live after CHANGE_DELAY (while the registry's activation delay is /// on), so a compromised guardian cannot silently rebind a listed name to a /// hostile oracle. Reserves and properties are write-once: a changed id or /// address is a new name, never a rebind. /// /// RETIRING IS IMMEDIATE AND CLOSE-ONLY. From that block nothing can OPEN /// exposure in the entry — no supply, borrow, leverage or purchase — while /// repay, withdraw, close and sell keep working, so every position can still /// get out. It can only fail closed, which is why it needs no delay: the worst /// a compromised guardian does with it is stop new deposits, which /// `delistAdapter` can already do, and an oracle under attack is exactly when /// three more days of new money would be wrong. /// /// H-5, REBUILT HERE. An account is pinned to a market version the first time /// it OPENS exposure in that name (`useMarket` with `opening`), and a later /// rebind never moves a pinned account — its close, its deleverage and its /// health top-up all resolve to the market its position actually sits in. An /// owner moves the pin on purpose with `pinMarket`. Accounts that have never /// opened in a name follow the live version. /// /// WHY AN ADAPTER MAY PIN. An earlier draft feared a whitelisted adapter could /// "freeze every account onto the live version of its choosing". It cannot /// choose: the pin written is always `_liveOf`, the version any fresh account /// would get that block, and never one the caller names. It cannot pin an /// account it is not acting for either — every opening caller /// (MorphoAdapter.act, Leverage{Morpho,AaveV3,AaveV4}.leverage, /// LoanShift.shift) spends the owner's intent in `_consume` before it asks. /// And the owner can always re-pin with `pinMarket`. Leaving accounts unpinned /// was the worse failure: after a rebind went live, an open position resolved /// to the NEW params, so the keeper's deleverage acted on an empty market and /// left the real one unprotected. /// /// An ERC1967 proxy at PROXY_CURATION, admin'd by UpgradeTimelock; the registry /// points at it once (`AgentRegistry.setCuration`), so no adapter holds its /// address and a fix here moves no adapter. contract AdapterCuration is ICuration { ICurationRegistry public immutable registry; uint64 public constant CHANGE_DELAY = 3 days; constructor(ICurationRegistry registry_) { registry = registry_; } // ── MORPHO MARKETS ────────────────────────────────────────────────────── /// One word per name. `live` is the version accounts that have not opened in /// the name yet get; `next` goes live at `nextAt`, read lazily. struct Head { uint32 live; uint32 latest; uint32 next; uint64 nextAt; bool retired; } mapping(bytes32 name => mapping(uint32 version => IMorpho.MarketParams)) private _marketAt; mapping(bytes32 name => Head) private _heads; mapping(address account => mapping(bytes32 name => uint32 version)) private _pins; bytes32[] private _marketNames; event MarketListed(bytes32 indexed name, address loanToken, address collateralToken, address oracle); event MarketProposed(bytes32 indexed name, uint32 version, uint64 liveAt); event MarketChangeCancelled(bytes32 indexed name, uint32 version); event MarketRetired(bytes32 indexed name); event MarketPinned(address indexed account, bytes32 indexed name, uint32 version); error NotGuardian(address caller); error NotAnAdapter(address caller); error BadMarket(bytes32 name); error MarketAlreadyListed(bytes32 name); error UnknownMarket(address account, bytes32 name); error MarketIsRetired(bytes32 name); error NoPendingChange(bytes32 name); error NotAVersion(bytes32 name, uint32 version); modifier onlyGuardian() { if (msg.sender != registry.guardian()) revert NotGuardian(msg.sender); _; } /// List NEW market names, live at once. Re-sending a name with the params it /// already has is a no-op, so a re-run curate is idempotent; different /// params are refused — a change to a listed name is `proposeMarket`. function listMarkets(bytes32[] calldata names, IMorpho.MarketParams[] calldata params) external onlyGuardian { if (names.length != params.length) revert BadMarket(bytes32(0)); for (uint256 i = 0; i < names.length; i++) { Head storage h = _heads[names[i]]; if (h.latest != 0) { if (_same(_marketAt[names[i]][_liveOf(h)], params[i])) continue; revert MarketAlreadyListed(names[i]); } _validate(names[i], params[i]); _marketAt[names[i]][1] = params[i]; h.live = 1; h.latest = 1; _marketNames.push(names[i]); emit MarketListed(names[i], params[i].loanToken, params[i].collateralToken, params[i].oracle); } } /// Rebind a listed name to new params, live after CHANGE_DELAY. Accounts that /// already used the name keep the version they used. function proposeMarket(bytes32 name, IMorpho.MarketParams calldata params) external onlyGuardian { Head storage h = _heads[name]; if (h.latest == 0) revert UnknownMarket(address(0), name); if (h.retired) revert MarketIsRetired(name); _settle(h); _validate(name, params); uint32 v = h.latest + 1; _marketAt[name][v] = params; h.latest = v; h.next = v; h.nextAt = uint64(block.timestamp) + (registry.activationDelayEnabled() ? CHANGE_DELAY : 0); emit MarketProposed(name, v, h.nextAt); } /// Withdraw a rebind before it goes live. Its params are deleted, so no /// account can ever pin that version. function cancelMarketChange(bytes32 name) external onlyGuardian { Head storage h = _heads[name]; _settle(h); if (h.next == 0) revert NoPendingChange(name); delete _marketAt[name][h.next]; emit MarketChangeCancelled(name, h.next); h.next = 0; h.nextAt = 0; } /// Close-only, immediately and for good. A pending rebind is dropped with it. function retireMarket(bytes32 name) external onlyGuardian { Head storage h = _heads[name]; if (h.latest == 0) revert UnknownMarket(address(0), name); _settle(h); if (h.next != 0) { delete _marketAt[name][h.next]; h.next = 0; h.nextAt = 0; } h.retired = true; emit MarketRetired(name); } /// The owner moves their account to a version on purpose — the only way a /// pinned account follows a rebind. `version` 0 clears the pin, and the /// next opening use pins the live version. Keyed by msg.sender: the account, through /// its root validator. The keeper cannot say it for them: a call from the /// keeper or from B arrives with that caller as msg.sender, never the /// account; B holds no validator and so no `execute` with which to make the /// account call this; `Agent._callOne` reaches only whitelisted adapters, /// refusing the account, itself and the registry as targets; and an /// adapter composes calls only to its curated venues. function pinMarket(bytes32 name, uint32 version) external { Head storage h = _heads[name]; if (h.latest == 0) revert UnknownMarket(msg.sender, name); if (version != 0) { if (version > _liveOf(h) || _marketAt[name][version].oracle == address(0)) { revert NotAVersion(name, version); } } _pins[msg.sender][name] = version; emit MarketPinned(msg.sender, name, version); } /// THE ACTING PATH. An adapter, about to act for `account` on `name`: the /// version the account uses (its pin, else live); a refusal to OPEN /// exposure in a retired market; and, on the first OPENING use, the pin. /// /// FIRST OPEN PINS. Without it nothing but the owner's `pinMarket` ever /// wrote `_pins`, so once a `proposeMarket` rebind went live every unpinned /// account with an open position resolved to the new params — the /// keeper's deleverage reverted or acted on an empty market, a health /// top-up landed in the wrong one, and the position sat unprotected. /// `proposeMarket`'s promise ("accounts that already used the name keep the /// version they used") is kept here. /// /// ONLY AN OPEN. A close (`opening` false) never pins: it must resolve to /// wherever the position already is. An account that opened before /// opens pinned — unpinned, with a position — is not frozen onto some /// version by its own exit; its owner pins it with `pinMarket`. /// /// ONLY A WHITELISTED ADAPTER, which is the first line below and was /// already there for the retired-open check. A stranger therefore cannot /// pin a victim before they open. Each opening adapter has spent the /// owner's intent (`_consume`) for `account` before it calls, and the /// version written is `_liveOf`, never the caller's choice. See the /// contract natspec, H-5. function useMarket(address account, bytes32 name, bool opening) external returns (IMorpho.MarketParams memory m) { if (!registry.isWhitelistedAdapter(msg.sender)) revert NotAnAdapter(msg.sender); Head storage h = _heads[name]; if (h.latest == 0) revert UnknownMarket(account, name); if (opening && h.retired) revert MarketIsRetired(name); uint32 v = _pins[account][name]; if (v == 0) { v = _liveOf(h); if (opening) { _pins[account][name] = v; emit MarketPinned(account, name, v); } } m = _marketAt[name][v]; } /// What `useMarket` would return, without pinning or refusing. function marketOf(address account, bytes32 name) public view returns (IMorpho.MarketParams memory m) { Head storage h = _heads[name]; if (h.latest == 0) revert UnknownMarket(account, name); uint32 v = _pins[account][name]; m = _marketAt[name][v == 0 ? _liveOf(h) : v]; } function marketHead(bytes32 name) external view returns (uint32 live, uint32 latest, uint32 next, uint64 nextAt, bool retired) { Head storage h = _heads[name]; return (_liveOf(h), h.latest, h.next != 0 && block.timestamp >= h.nextAt ? 0 : h.next, h.nextAt, h.retired); } function marketAt(bytes32 name, uint32 version) external view returns (IMorpho.MarketParams memory) { return _marketAt[name][version]; } function pinOf(address account, bytes32 name) external view returns (uint32) { return _pins[account][name]; } function marketNames() external view returns (bytes32[] memory) { return _marketNames; } function _liveOf(Head storage h) private view returns (uint32) { return h.next != 0 && block.timestamp >= h.nextAt ? h.next : h.live; } function _settle(Head storage h) private { if (h.next != 0 && block.timestamp >= h.nextAt) { h.live = h.next; h.next = 0; h.nextAt = 0; } } /// The checks MorphoAdapter.curate made: both tokens present and curated, /// an oracle named. No code check on the oracle — the Aave pair names carry /// a placeholder, because four adapters use a market name only for its two /// token addresses. function _validate(bytes32 name, IMorpho.MarketParams calldata p) private view { if (p.loanToken == address(0) || p.collateralToken == address(0) || p.oracle == address(0)) { revert BadMarket(name); } if (!registry.isCuratedAsset(p.loanToken) || !registry.isCuratedAsset(p.collateralToken)) { revert BadMarket(name); } } function _same(IMorpho.MarketParams storage a, IMorpho.MarketParams calldata b) private view returns (bool) { return a.loanToken == b.loanToken && a.collateralToken == b.collateralToken && a.oracle == b.oracle && a.irm == b.irm && a.lltv == b.lltv; } // ── AAVE v4 RESERVES ──────────────────────────────────────────────────── // // KEYED BY THE SPOKE'S ADDRESS, NOT THE VENUE'S NAME. A reserve id belongs to // one spoke contract. Keyed by name, a venue repointed at another spoke // would keep the old ids and act "on whichever asset holds that id … a // position in the wrong asset rather than an error" (AaveV4Adapter). Keyed // by address, the new spoke has no ids until they are mapped for it, and // every read fails closed. struct Reserve { uint96 id; bool known; bool retired; } mapping(address spoke => mapping(address asset => Reserve)) private _reserves; event ReserveMapped(address indexed spoke, address indexed asset, uint256 id); event ReserveRetired(address indexed spoke, address indexed asset); error UnknownVenue(bytes32 venue); error UnknownReserve(address spoke, address asset); error ReserveAlreadyMapped(address spoke, address asset, uint256 id); error ReserveIsRetired(address spoke, address asset); error ReservesMismatched(); /// Map reserves on the spoke `venue` points at now. Write-once per (spoke, /// asset): the same id again is a no-op, a different one is refused. function mapReserves(bytes32 venue, address[] calldata assets, uint256[] calldata ids) external onlyGuardian { if (assets.length != ids.length) revert ReservesMismatched(); address spoke = registry.venueTarget(venue); if (spoke == address(0)) revert UnknownVenue(venue); for (uint256 i = 0; i < assets.length; i++) { address asset = assets[i]; if (asset == address(0) || !registry.isCuratedAsset(asset)) revert UnknownReserve(spoke, asset); if (ids[i] > type(uint96).max) revert UnknownReserve(spoke, asset); _requireReserveIs(spoke, asset, ids[i]); Reserve storage r = _reserves[spoke][asset]; if (r.known) { if (r.id == ids[i]) continue; revert ReserveAlreadyMapped(spoke, asset, r.id); } r.id = uint96(ids[i]); r.known = true; emit ReserveMapped(spoke, asset, ids[i]); } } function retireReserve(address spoke, address asset) external onlyGuardian { Reserve storage r = _reserves[spoke][asset]; if (!r.known) revert UnknownReserve(spoke, asset); r.retired = true; emit ReserveRetired(spoke, asset); } function useReserve(address spoke, address asset, bool opening) external view returns (uint256) { Reserve storage r = _reserves[spoke][asset]; if (!r.known) revert UnknownReserve(spoke, asset); if (opening && r.retired) revert ReserveIsRetired(spoke, asset); return r.id; } function reserveOf(address spoke, address asset) external view returns (uint256) { Reserve storage r = _reserves[spoke][asset]; if (!r.known) revert UnknownReserve(spoke, asset); return r.id; } /// The spoke confirms the id when it can answer — the check /// AaveV4Adapter._requireReserveIs made, with the same leniency for a spoke /// that has no `reserveAsset` getter. /// /// A spoke with NO CODE is refused (166827b7): it is no spoke, and an id /// mapped on it would map silently. A spoke WITH code but no getter keeps /// the leniency, because that is every real Aave v4 spoke: Ethereum's /// 0x94e7A5dC… and 0x65407b94… revert `reserveAsset(uint256)` with empty /// data (checked on a fork, 2026-10-07), so refusing it as well made /// `mapReserves` — and with it the whole v4 venue — impossible on chain. /// Confirming through the spoke's getReserve(uint256) (0x77778db3) instead /// is the open follow-up. function _requireReserveIs(address spoke, address asset, uint256 id) private view { if (spoke.code.length == 0) revert UnknownReserve(spoke, asset); (bool ok, bytes memory ret) = spoke.staticcall(abi.encodeWithSignature("reserveAsset(uint256)", id)); if (!ok || ret.length < 32) return; if (abi.decode(ret, (address)) != asset) revert UnknownReserve(spoke, asset); } // ── PROPERTIES ────────────────────────────────────────────────────────── struct Property { address vault; address token; address marketplace; bool retired; string symbol; } mapping(bytes32 name => Property) private _properties; bytes32[] private _propertyNames; event PropertyListed(bytes32 indexed name, address vault, address token, address marketplace, string symbol); event PropertyRetired(bytes32 indexed name); error BadProperty(bytes32 name); error PropertyAlreadyListed(bytes32 name); error UnknownProperty(bytes32 name); error PropertyIsRetired(bytes32 name); /// List a property, write-once. PropertyAdapter.curate checked nothing; /// these are the checks it lacked: a curated token with code, a marketplace /// with code, a vault that is either absent (primary market only) or code. function listProperty( bytes32 name, address vault, address token, address marketplace, string calldata symbol ) external onlyGuardian { Property storage p = _properties[name]; if (p.token != address(0)) { if ( p.vault == vault && p.token == token && p.marketplace == marketplace && keccak256(bytes(p.symbol)) == keccak256(bytes(symbol)) && !p.retired ) return; revert PropertyAlreadyListed(name); } if ( token == address(0) || token.code.length == 0 || !registry.isCuratedAsset(token) || marketplace == address(0) || marketplace.code.length == 0 || (vault != address(0) && vault.code.length == 0) || bytes(symbol).length == 0 ) revert BadProperty(name); p.vault = vault; p.token = token; p.marketplace = marketplace; p.symbol = symbol; _propertyNames.push(name); emit PropertyListed(name, vault, token, marketplace, symbol); } /// Close-only, immediately and for good: no purchase; a sale still works. function retireProperty(bytes32 name) external onlyGuardian { Property storage p = _properties[name]; if (p.token == address(0)) revert UnknownProperty(name); p.retired = true; emit PropertyRetired(name); } function useProperty(bytes32 name, bool opening) external view returns (address vault, address token, address marketplace, string memory symbol) { Property storage p = _properties[name]; if (p.token == address(0)) revert UnknownProperty(name); if (opening && p.retired) revert PropertyIsRetired(name); return (p.vault, p.token, p.marketplace, p.symbol); } function propertyOf(bytes32 name) external view returns (address vault, address token, address marketplace, string memory symbol, bool retired) { Property storage p = _properties[name]; if (p.token == address(0)) revert UnknownProperty(name); return (p.vault, p.token, p.marketplace, p.symbol, p.retired); } function propertyNames() external view returns (bytes32[] memory) { return _propertyNames; } }