From 0d290453d73c81b0c93c70636e96c099addacf9f Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Wed, 17 Jun 2026 09:04:09 +0530 Subject: [PATCH] feat: multi-index refactor (shared AssetRegistry, per-vault curated constituents) Make the protocol a multi-index platform rather than a single index. The registry was doing two jobs at once: the global asset catalog and one index's membership. For a single index those collapse, which is why the separation read as ceremony. Split them. AssetRegistry (was ComponentRegistry): - Re-scoped to a shared global catalog of registerable assets, not an index. It is the eligible universe any index draws from; a token is registered once and any index may include it. - Renamed surface: registerAsset/removeAsset/getAsset/getAssets/assetCount, MAX_ASSETS=250 as a catalog cap (distinct from a per-index size cap), and AssetRegistry_* errors. Price reads and health checks unchanged. IndexVault now owns its curated constituents: - Holds its own _constituents set with setConstituents, validating each token is registered in the catalog, rejecting duplicates, capping at MAX_CONSTITUENTS=100, and caching decimals so the NAV loop makes no external decimals call. - NAV loops over the vault's own constituents, not the whole registry. - New public reads: getConstituents, constituentCount, isConstituent, and getHoldings (per-constituent balance, USD value, weight in bps, plus idle USD and total NAV) for transparency. - Membership is admin-curated because category membership (L1, DEX, AI, ...) is definitional, not rank-derived; weighting over the set stays autonomous. The timelock/forced-removal/rate-limit guardrails are a later stage. MarketCapMethodology points at AssetRegistry; it already took constituents as arguments. Adds 7 tests including two index vaults sharing one registry with independent NAV (the multi-index property), getHoldings value/weight checks, and setConstituents validation. 83 tests pass. --- README.md | 4 +- src/AssetRegistry.sol | 214 +++++++++++++++++++++++ src/ComponentRegistry.sol | 208 ---------------------- src/IndexVault.sol | 138 +++++++++++++-- src/interfaces/IMethodology.sol | 2 +- src/methodology/MarketCapMethodology.sol | 8 +- test/IndexVault.t.sol | 121 ++++++++++++- test/MarketCapMethodology.t.sol | 16 +- test/SupplyOracleIntegration.t.sol | 14 +- 9 files changed, 474 insertions(+), 251 deletions(-) create mode 100644 src/AssetRegistry.sol delete mode 100644 src/ComponentRegistry.sol diff --git a/README.md b/README.md index b32abeb..c8adcb9 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,9 @@ This is a research-stage codebase. It is not audited and it is not deployed. The ``` src/ - IndexVault.sol ERC-7540 two-lane vault, NAV, settlement + IndexVault.sol ERC-7540 two-lane vault, NAV, settlement, curated constituents PendingSilo.sol isolated holder of in-flight value - ComponentRegistry.sol constituents and health-checked Chainlink feeds + AssetRegistry.sol shared asset catalog and health-checked Chainlink feeds methodology/ MarketCapMethodology.sol float-adjusted capped market-cap weighting libraries/ diff --git a/src/AssetRegistry.sol b/src/AssetRegistry.sol new file mode 100644 index 0000000..76640bb --- /dev/null +++ b/src/AssetRegistry.sol @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +/// @notice Chainlink aggregator surface the registry consumes. +interface IAggregatorV3 { + function decimals() external view returns (uint8); + function latestRoundData() + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} + +// ============================================================================ +// Errors +// ============================================================================ + +/// @notice Thrown when a constructor or setter receives the zero address. +error AssetRegistry_ZeroAddress(); + +/// @notice Thrown when registering an asset that is already registered. +error AssetRegistry_AlreadyRegistered(address token); + +/// @notice Thrown when querying or removing an asset that is not registered. +error AssetRegistry_NotRegistered(address token); + +/// @notice Thrown when registering beyond the catalog cap. +error AssetRegistry_MaxAssetsReached(); + +/// @notice Thrown when a heartbeat of zero is supplied. +error AssetRegistry_ZeroHeartbeat(); + +/// @notice Thrown when a feed reports a non-positive answer. +error AssetRegistry_InvalidPrice(address feed, int256 answer); + +/// @notice Thrown when a feed has not updated within its heartbeat. +error AssetRegistry_StalePrice(address feed, uint256 updatedAt, uint256 heartbeat); + +/// @notice Thrown when the USDC feed has not been configured. +error AssetRegistry_UsdcFeedNotSet(); + +/** + * @title AssetRegistry + * @notice Shared global catalog of registerable assets and their Chainlink USD + * price feeds. This is the eligible universe an index can draw from; it is not + * itself an index. A token is registered here once, and any index vault may + * then include it in its own curated constituent set. Membership lives in the + * vault, asset metadata lives here. + * + * Each asset carries a per-feed heartbeat rather than a single global staleness + * bound, and every price read is health-checked: a stale or non-positive answer + * reverts so that price-sensitive vault operations (mint, settle, rebalance) + * fail closed instead of transacting on bad data. + * @dev Prices are normalized to 8 decimals regardless of feed decimals. + * Supply-oracle source bindings can be attached per asset later. + */ +contract AssetRegistry is Ownable2Step { + struct Asset { + address token; + address feed; + uint48 heartbeat; + uint8 tokenDecimals; + uint8 feedDecimals; + } + + /// @notice Normalized price precision for all reads (Chainlink USD standard). + uint8 public constant PRICE_DECIMALS = 8; + + /// @notice Cap on the catalog size, distinct from any one index's size cap. + /// An index picks a subset of the catalog and applies its own size cap. + uint256 public constant MAX_ASSETS = 250; + + /// @dev Registered assets in registration order. + address[] private _assetList; + + /// @dev Asset data keyed by token address. + mapping(address token => Asset) private _assets; + + /// @dev USDC is the settlement asset, priced through its own feed, never a basket constituent. + Asset private _usdc; + + event AssetRegistered(address indexed token, address indexed feed, uint48 heartbeat); + event AssetRemoved(address indexed token); + event UsdcFeedSet(address indexed usdc, address indexed feed, uint48 heartbeat); + + constructor(address initialOwner) Ownable(initialOwner) { } + + // ======================================================================== + // Admin + // ======================================================================== + + /// @notice Registers an asset with its Chainlink USD feed, adding it to the catalog. + /// @param token The ERC-20 asset address. + /// @param feed The Chainlink aggregator for the token's USD price. + /// @param heartbeat Maximum tolerated seconds since the feed's last update. + function registerAsset(address token, address feed, uint48 heartbeat) external onlyOwner { + if (token == address(0) || feed == address(0)) revert AssetRegistry_ZeroAddress(); + if (heartbeat == 0) revert AssetRegistry_ZeroHeartbeat(); + if (_assets[token].token != address(0)) revert AssetRegistry_AlreadyRegistered(token); + if (_assetList.length >= MAX_ASSETS) revert AssetRegistry_MaxAssetsReached(); + + _assets[token] = Asset({ + token: token, + feed: feed, + heartbeat: heartbeat, + tokenDecimals: IERC20Metadata(token).decimals(), + feedDecimals: IAggregatorV3(feed).decimals() + }); + _assetList.push(token); + + emit AssetRegistered(token, feed, heartbeat); + } + + /// @notice Removes an asset from the catalog. + /// @dev An index vault may still list a removed asset as a constituent; the + /// vault's own constituent governance handles forced exit of the position. + /// Removal here only stops the asset being newly includable and priced. + function removeAsset(address token) external onlyOwner { + if (_assets[token].token == address(0)) revert AssetRegistry_NotRegistered(token); + + uint256 len = _assetList.length; + for (uint256 i = 0; i < len; i++) { + if (_assetList[i] == token) { + _assetList[i] = _assetList[len - 1]; + _assetList.pop(); + break; + } + } + delete _assets[token]; + + emit AssetRemoved(token); + } + + /// @notice Configures the settlement-asset (USDC) price feed. + function setUsdcFeed(address usdc, address feed, uint48 heartbeat) external onlyOwner { + if (usdc == address(0) || feed == address(0)) revert AssetRegistry_ZeroAddress(); + if (heartbeat == 0) revert AssetRegistry_ZeroHeartbeat(); + + _usdc = Asset({ + token: usdc, + feed: feed, + heartbeat: heartbeat, + tokenDecimals: IERC20Metadata(usdc).decimals(), + feedDecimals: IAggregatorV3(feed).decimals() + }); + + emit UsdcFeedSet(usdc, feed, heartbeat); + } + + // ======================================================================== + // Views + // ======================================================================== + + /// @notice Returns the entire catalog of registered assets. + function getAssets() external view returns (Asset[] memory assets) { + uint256 len = _assetList.length; + assets = new Asset[](len); + for (uint256 i = 0; i < len; i++) { + assets[i] = _assets[_assetList[i]]; + } + } + + /// @notice Returns a single asset record. + function getAsset(address token) external view returns (Asset memory) { + Asset memory a = _assets[token]; + if (a.token == address(0)) revert AssetRegistry_NotRegistered(token); + return a; + } + + /// @notice Number of registered assets in the catalog. + function assetCount() external view returns (uint256) { + return _assetList.length; + } + + /// @notice Whether `token` is a registered asset. + function isRegistered(address token) external view returns (bool) { + return _assets[token].token != address(0); + } + + /// @notice Health-checked USD price of a registered asset, normalized to 8 decimals. + function getPriceUsd(address token) external view returns (uint256) { + Asset memory a = _assets[token]; + if (a.token == address(0)) revert AssetRegistry_NotRegistered(token); + return _readFeed(a); + } + + /// @notice Health-checked USD price of the settlement asset, normalized to 8 decimals. + function getUsdcPriceUsd() external view returns (uint256) { + Asset memory a = _usdc; + if (a.token == address(0)) revert AssetRegistry_UsdcFeedNotSet(); + return _readFeed(a); + } + + // ======================================================================== + // Internal + // ======================================================================== + + /// @dev Reads a feed, enforces answer and staleness health, normalizes to 8 decimals. + function _readFeed(Asset memory a) internal view returns (uint256) { + (, int256 answer,, uint256 updatedAt,) = IAggregatorV3(a.feed).latestRoundData(); + if (answer <= 0) revert AssetRegistry_InvalidPrice(a.feed, answer); + if (block.timestamp > updatedAt + a.heartbeat) { + revert AssetRegistry_StalePrice(a.feed, updatedAt, a.heartbeat); + } + + uint256 price = uint256(answer); + if (a.feedDecimals == PRICE_DECIMALS) return price; + if (a.feedDecimals < PRICE_DECIMALS) return price * 10 ** (PRICE_DECIMALS - a.feedDecimals); + return price / 10 ** (a.feedDecimals - PRICE_DECIMALS); + } +} diff --git a/src/ComponentRegistry.sol b/src/ComponentRegistry.sol deleted file mode 100644 index d1a7ecb..0000000 --- a/src/ComponentRegistry.sol +++ /dev/null @@ -1,208 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.28; - -import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; -import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; -import { IERC20Metadata } from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; - -/// @notice Chainlink aggregator surface the registry consumes. -interface IAggregatorV3 { - function decimals() external view returns (uint8); - function latestRoundData() - external - view - returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); -} - -// ============================================================================ -// Errors -// ============================================================================ - -/// @notice Thrown when a constructor or setter receives the zero address. -error ComponentRegistry_ZeroAddress(); - -/// @notice Thrown when registering a component that is already registered. -error ComponentRegistry_AlreadyRegistered(address token); - -/// @notice Thrown when querying or removing a component that is not registered. -error ComponentRegistry_NotRegistered(address token); - -/// @notice Thrown when registering beyond the component cap. -error ComponentRegistry_MaxComponentsReached(); - -/// @notice Thrown when a heartbeat of zero is supplied. -error ComponentRegistry_ZeroHeartbeat(); - -/// @notice Thrown when a feed reports a non-positive answer. -error ComponentRegistry_InvalidPrice(address feed, int256 answer); - -/// @notice Thrown when a feed has not updated within its heartbeat. -error ComponentRegistry_StalePrice(address feed, uint256 updatedAt, uint256 heartbeat); - -/// @notice Thrown when the USDC feed has not been configured. -error ComponentRegistry_UsdcFeedNotSet(); - -/** - * @title ComponentRegistry - * @notice Registry of index constituents and their Chainlink USD price feeds. - * Each component carries a per-feed heartbeat rather than a single global - * staleness bound, and every price read is health-checked: a stale or - * non-positive answer reverts so that price-sensitive vault operations - * (mint, settle, rebalance) fail closed instead of transacting on bad data. - * @dev Prices are normalized to 8 decimals regardless of feed decimals. - * Supply-oracle bindings and reconstitution metadata land here later. - */ -contract ComponentRegistry is Ownable2Step { - struct Component { - address token; - address feed; - uint48 heartbeat; - uint8 tokenDecimals; - uint8 feedDecimals; - } - - /// @notice Normalized price precision for all reads (Chainlink USD standard). - uint8 public constant PRICE_DECIMALS = 8; - - /// @notice Operational cap on constituent count, sized for the index-100 target. - uint256 public constant MAX_COMPONENTS = 100; - - /// @dev Registered basket constituents in registration order. - address[] private _componentList; - - /// @dev Component data keyed by token address. - mapping(address token => Component) private _components; - - /// @dev USDC is the settlement asset, priced through its own feed, never a basket constituent. - Component private _usdc; - - event ComponentRegistered(address indexed token, address indexed feed, uint48 heartbeat); - event ComponentRemoved(address indexed token); - event UsdcFeedSet(address indexed usdc, address indexed feed, uint48 heartbeat); - - constructor(address initialOwner) Ownable(initialOwner) { } - - // ======================================================================== - // Admin - // ======================================================================== - - /// @notice Registers a basket constituent with its Chainlink USD feed. - /// @param token The ERC-20 constituent address. - /// @param feed The Chainlink aggregator for the token's USD price. - /// @param heartbeat Maximum tolerated seconds since the feed's last update. - function registerComponent(address token, address feed, uint48 heartbeat) external onlyOwner { - if (token == address(0) || feed == address(0)) revert ComponentRegistry_ZeroAddress(); - if (heartbeat == 0) revert ComponentRegistry_ZeroHeartbeat(); - if (_components[token].token != address(0)) revert ComponentRegistry_AlreadyRegistered(token); - if (_componentList.length >= MAX_COMPONENTS) revert ComponentRegistry_MaxComponentsReached(); - - _components[token] = Component({ - token: token, - feed: feed, - heartbeat: heartbeat, - tokenDecimals: IERC20Metadata(token).decimals(), - feedDecimals: IAggregatorV3(feed).decimals() - }); - _componentList.push(token); - - emit ComponentRegistered(token, feed, heartbeat); - } - - /// @notice Removes a constituent from the registry. - /// @dev The vault may still hold a balance of a removed token; removal only - /// stops it being valued and traded. Deregistration policy (forced exit of - /// the position first) is enforced at the rebalancer layer. - function removeComponent(address token) external onlyOwner { - if (_components[token].token == address(0)) revert ComponentRegistry_NotRegistered(token); - - uint256 len = _componentList.length; - for (uint256 i = 0; i < len; i++) { - if (_componentList[i] == token) { - _componentList[i] = _componentList[len - 1]; - _componentList.pop(); - break; - } - } - delete _components[token]; - - emit ComponentRemoved(token); - } - - /// @notice Configures the settlement-asset (USDC) price feed. - function setUsdcFeed(address usdc, address feed, uint48 heartbeat) external onlyOwner { - if (usdc == address(0) || feed == address(0)) revert ComponentRegistry_ZeroAddress(); - if (heartbeat == 0) revert ComponentRegistry_ZeroHeartbeat(); - - _usdc = Component({ - token: usdc, - feed: feed, - heartbeat: heartbeat, - tokenDecimals: IERC20Metadata(usdc).decimals(), - feedDecimals: IAggregatorV3(feed).decimals() - }); - - emit UsdcFeedSet(usdc, feed, heartbeat); - } - - // ======================================================================== - // Views - // ======================================================================== - - /// @notice Returns all registered constituents with cached decimals, for the vault's NAV loop. - function getComponents() external view returns (Component[] memory components) { - uint256 len = _componentList.length; - components = new Component[](len); - for (uint256 i = 0; i < len; i++) { - components[i] = _components[_componentList[i]]; - } - } - - /// @notice Returns a single component record. - function getComponent(address token) external view returns (Component memory) { - Component memory c = _components[token]; - if (c.token == address(0)) revert ComponentRegistry_NotRegistered(token); - return c; - } - - /// @notice Number of registered constituents. - function componentCount() external view returns (uint256) { - return _componentList.length; - } - - /// @notice Whether `token` is a registered basket constituent. - function isRegistered(address token) external view returns (bool) { - return _components[token].token != address(0); - } - - /// @notice Health-checked USD price of a registered constituent, normalized to 8 decimals. - function getPriceUsd(address token) external view returns (uint256) { - Component memory c = _components[token]; - if (c.token == address(0)) revert ComponentRegistry_NotRegistered(token); - return _readFeed(c); - } - - /// @notice Health-checked USD price of the settlement asset, normalized to 8 decimals. - function getUsdcPriceUsd() external view returns (uint256) { - Component memory c = _usdc; - if (c.token == address(0)) revert ComponentRegistry_UsdcFeedNotSet(); - return _readFeed(c); - } - - // ======================================================================== - // Internal - // ======================================================================== - - /// @dev Reads a feed, enforces answer and staleness health, normalizes to 8 decimals. - function _readFeed(Component memory c) internal view returns (uint256) { - (, int256 answer,, uint256 updatedAt,) = IAggregatorV3(c.feed).latestRoundData(); - if (answer <= 0) revert ComponentRegistry_InvalidPrice(c.feed, answer); - if (block.timestamp > updatedAt + c.heartbeat) { - revert ComponentRegistry_StalePrice(c.feed, updatedAt, c.heartbeat); - } - - uint256 price = uint256(answer); - if (c.feedDecimals == PRICE_DECIMALS) return price; - if (c.feedDecimals < PRICE_DECIMALS) return price * 10 ** (PRICE_DECIMALS - c.feedDecimals); - return price / 10 ** (c.feedDecimals - PRICE_DECIMALS); - } -} diff --git a/src/IndexVault.sol b/src/IndexVault.sol index 755ae8d..7dd420c 100644 --- a/src/IndexVault.sol +++ b/src/IndexVault.sol @@ -9,7 +9,7 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; -import { ComponentRegistry } from "src/ComponentRegistry.sol"; +import { AssetRegistry } from "src/AssetRegistry.sol"; import { PendingSilo } from "src/PendingSilo.sol"; import { IERC7540 } from "src/interfaces/IERC7540.sol"; @@ -52,6 +52,15 @@ error IndexVault_InvalidBufferBand(); /// @notice Thrown when settlement timing parameters are inconsistent. error IndexVault_InvalidSettleParams(); +/// @notice Thrown when curating a constituent not registered in the AssetRegistry. +error IndexVault_AssetNotRegistered(address token); + +/// @notice Thrown when the same token appears twice in a constituent set. +error IndexVault_DuplicateConstituent(address token); + +/// @notice Thrown when a constituent set exceeds the per-index cap. +error IndexVault_TooManyConstituents(uint256 count, uint256 cap); + /** * @title IndexVault * @notice Pooled, single-asset (USDC) index vault. ERC-7540 asynchronous @@ -66,10 +75,10 @@ error IndexVault_InvalidSettleParams(); * afterwards. Pending value sits in an isolated PendingSilo so it never * contaminates NAV. * - * NAV is oracle-based: the USD value of basket constituents (priced through - * the ComponentRegistry's health-checked Chainlink feeds) plus the idle USDC - * buffer, expressed in USDC units. Any stale feed makes price-sensitive - * operations revert rather than transact on bad data. + * NAV is oracle-based: the USD value of this index's curated constituents + * (priced through the shared AssetRegistry's health-checked Chainlink feeds) + * plus the idle USDC buffer, expressed in USDC units. Any stale feed makes + * price-sensitive operations revert rather than transact on bad data. * * Pending redemptions are priced at settle time, not request time, which is * fairer to remaining holders. @@ -106,14 +115,26 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540 { uint256 shares; } + /// @dev One row of the live basket composition, returned by getHoldings. + struct Holding { + address token; + uint256 balance; // native token units held by the vault + uint256 valueUsd; // 8-decimal USD value of the holding + uint256 weightBps; // weight against NAV (basket plus idle) in bps + } + // ======================================================================== // Constants and immutables // ======================================================================== uint256 private constant BPS = 10_000; - /// @notice Constituent price registry used for NAV. - ComponentRegistry public immutable REGISTRY; + /// @notice Shared asset catalog this index draws constituents from and prices NAV through. + AssetRegistry public immutable REGISTRY; + + /// @notice Per-index cap on the number of constituents, distinct from the + /// catalog cap in the registry. Sized for the index-100 target. + uint256 public constant MAX_CONSTITUENTS = 100; /// @notice Isolated holder of pending and claimable value. PendingSilo public immutable SILO; @@ -164,6 +185,16 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540 { /// @dev ERC-7540 operator approvals. mapping(address controller => mapping(address operator => bool)) private _operators; + /// @dev This index's curated constituent set, a subset of the registry catalog. + address[] private _constituents; + + /// @notice Whether `token` is a constituent of this index. + mapping(address token => bool) public isConstituent; + + /// @dev Token decimals cached at curation time, so the NAV loop needs no + /// external decimals call per constituent. + mapping(address token => uint8) private _constituentDecimals; + // ======================================================================== // Events // ======================================================================== @@ -182,12 +213,13 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540 { event KeeperSet(address indexed keeper); event BufferBandSet(uint16 lowBps, uint16 targetBps, uint16 highBps); event SettleParamsSet(uint48 minInterval, uint48 maxDelay); + event ConstituentsSet(address[] tokens); // ======================================================================== // Construction // ======================================================================== - constructor(IERC20 usdc, ComponentRegistry registry, address keeper_, address initialOwner) + constructor(IERC20 usdc, AssetRegistry registry, address keeper_, address initialOwner) ERC4626(usdc) ERC20("Index Vault Share", "IDXV") Ownable(initialOwner) @@ -216,13 +248,14 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540 { function totalAssets() public view override returns (uint256) { uint256 usdcPrice = REGISTRY.getUsdcPriceUsd(); - ComponentRegistry.Component[] memory components = REGISTRY.getComponents(); + address[] memory cons = _constituents; uint256 basketUsd = 0; - for (uint256 i = 0; i < components.length; i++) { - uint256 balance = IERC20(components[i].token).balanceOf(address(this)); + for (uint256 i = 0; i < cons.length; i++) { + address token = cons[i]; + uint256 balance = IERC20(token).balanceOf(address(this)); if (balance == 0) continue; - uint256 price = REGISTRY.getPriceUsd(components[i].token); - basketUsd += balance.mulDiv(price, 10 ** components[i].tokenDecimals, Math.Rounding.Floor); + uint256 price = REGISTRY.getPriceUsd(token); + basketUsd += balance.mulDiv(price, 10 ** _constituentDecimals[token], Math.Rounding.Floor); } // basketUsd has 8 decimals (registry PRICE_DECIMALS); dividing the USD @@ -236,6 +269,85 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540 { return IERC20(asset()).balanceOf(address(this)); } + // ======================================================================== + // Constituents (curated membership) + // ======================================================================== + + /// @notice Replaces this index's constituent set. Curated by the admin or + /// multisig, because category membership is a definitional choice, not a + /// rank. Every token must be registered in the shared AssetRegistry. + /// @dev Stage 1 wholesale setter under simple owner gating. The timelock, + /// forced-versus-discretionary removal, rate-limit, and minimum-count + /// guardrails are layered on later; weighting over this set stays autonomous. + function setConstituents(address[] calldata tokens) external onlyOwner { + if (tokens.length > MAX_CONSTITUENTS) revert IndexVault_TooManyConstituents(tokens.length, MAX_CONSTITUENTS); + + // Clear the previous set. + address[] memory prev = _constituents; + for (uint256 i = 0; i < prev.length; i++) { + isConstituent[prev[i]] = false; + delete _constituentDecimals[prev[i]]; + } + delete _constituents; + + // Install the new set, validating registration and rejecting duplicates. + for (uint256 i = 0; i < tokens.length; i++) { + address token = tokens[i]; + if (!REGISTRY.isRegistered(token)) revert IndexVault_AssetNotRegistered(token); + if (isConstituent[token]) revert IndexVault_DuplicateConstituent(token); + isConstituent[token] = true; + _constituentDecimals[token] = REGISTRY.getAsset(token).tokenDecimals; + _constituents.push(token); + } + + emit ConstituentsSet(tokens); + } + + /// @notice This index's current constituent set. + function getConstituents() external view returns (address[] memory) { + return _constituents; + } + + /// @notice Number of constituents in this index. + function constituentCount() external view returns (uint256) { + return _constituents.length; + } + + /** + * @notice Live basket composition: per-constituent balance, USD value, and + * weight against NAV, plus the idle USDC value and total NAV in USD. One + * call answers both what is in the index and in what proportion right now. + * @dev Weights are bps of total NAV (basket USD plus idle USD), 8-decimal + * USD throughout. Prices are health-checked, so this reverts on a stale feed. + */ + function getHoldings() external view returns (Holding[] memory holdings, uint256 idleUsd, uint256 totalUsd) { + uint256 usdcPrice = REGISTRY.getUsdcPriceUsd(); + + address[] memory cons = _constituents; + holdings = new Holding[](cons.length); + uint256 basketUsd = 0; + for (uint256 i = 0; i < cons.length; i++) { + address token = cons[i]; + uint256 balance = IERC20(token).balanceOf(address(this)); + uint256 valueUsd = balance == 0 + ? 0 + : balance.mulDiv(REGISTRY.getPriceUsd(token), 10 ** _constituentDecimals[token], Math.Rounding.Floor); + holdings[i] = Holding({ token: token, balance: balance, valueUsd: valueUsd, weightBps: 0 }); + basketUsd += valueUsd; + } + + // Idle USDC (6 decimals) valued into 8-decimal USD via the USDC price. + uint256 idle = IERC20(asset()).balanceOf(address(this)); + idleUsd = idle.mulDiv(usdcPrice, _ASSET_UNIT, Math.Rounding.Floor); + totalUsd = basketUsd + idleUsd; + + if (totalUsd > 0) { + for (uint256 i = 0; i < holdings.length; i++) { + holdings[i].weightBps = holdings[i].valueUsd.mulDiv(BPS, totalUsd, Math.Rounding.Floor); + } + } + } + // ======================================================================== // Two-lane gating (ERC-4626 max overrides) // ======================================================================== diff --git a/src/interfaces/IMethodology.sol b/src/interfaces/IMethodology.sol index 0b9fef0..263ee0d 100644 --- a/src/interfaces/IMethodology.sol +++ b/src/interfaces/IMethodology.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.28; /** * @title IMethodology * @notice Pluggable weighting strategy, keyed by token address to match the - * ComponentRegistry. The rebalancer treats the methodology as a black box + * AssetRegistry. The rebalancer treats the methodology as a black box * that maps a constituent set to target weights, so weighting schemes can be * swapped without touching vault or rebalancer code. */ diff --git a/src/methodology/MarketCapMethodology.sol b/src/methodology/MarketCapMethodology.sol index 90318d8..d26a15d 100644 --- a/src/methodology/MarketCapMethodology.sol +++ b/src/methodology/MarketCapMethodology.sol @@ -5,7 +5,7 @@ import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; -import { ComponentRegistry } from "src/ComponentRegistry.sol"; +import { AssetRegistry } from "src/AssetRegistry.sol"; import { IMethodology } from "src/interfaces/IMethodology.sol"; import { ISupplyOracle } from "src/interfaces/ISupplyOracle.sol"; import { WeightMath } from "src/libraries/WeightMath.sol"; @@ -44,8 +44,8 @@ error MarketCapMethodology_InvalidParams(); contract MarketCapMethodology is IMethodology, Ownable2Step { using Math for uint256; - /// @notice Price registry for constituent USD prices (8 decimals). - ComponentRegistry public immutable REGISTRY; + /// @notice Shared asset catalog for constituent USD prices (8 decimals). + AssetRegistry public immutable REGISTRY; /// @notice Float-adjusted circulating supply source, in whole tokens. ISupplyOracle public immutable SUPPLY_ORACLE; @@ -65,7 +65,7 @@ contract MarketCapMethodology is IMethodology, Ownable2Step { event WeightParamsSet(uint256 capWad, uint256 floorWad); - constructor(ComponentRegistry registry, ISupplyOracle supplyOracle, address initialOwner) Ownable(initialOwner) { + constructor(AssetRegistry registry, ISupplyOracle supplyOracle, address initialOwner) Ownable(initialOwner) { if (address(registry) == address(0) || address(supplyOracle) == address(0)) { revert MarketCapMethodology_ZeroAddress(); } diff --git a/test/IndexVault.t.sol b/test/IndexVault.t.sol index 996d126..267704b 100644 --- a/test/IndexVault.t.sol +++ b/test/IndexVault.t.sol @@ -6,7 +6,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ERC4626 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; import { IndexVault } from "src/IndexVault.sol"; -import { ComponentRegistry } from "src/ComponentRegistry.sol"; +import { AssetRegistry } from "src/AssetRegistry.sol"; import { IndexVault_NotKeeper, IndexVault_SettleIntervalNotPassed, @@ -14,9 +14,11 @@ import { IndexVault_InsufficientSettlementLiquidity, IndexVault_RequestNotSettled, IndexVault_NotAuthorized, - IndexVault_InvalidBufferBand + IndexVault_InvalidBufferBand, + IndexVault_AssetNotRegistered, + IndexVault_DuplicateConstituent } from "src/IndexVault.sol"; -import { ComponentRegistry_StalePrice } from "src/ComponentRegistry.sol"; +import { AssetRegistry_StalePrice } from "src/AssetRegistry.sol"; import { MockERC20 } from "test/mocks/MockERC20.sol"; import { MockAggregator } from "test/mocks/MockAggregator.sol"; @@ -29,7 +31,7 @@ contract IndexVaultTest is Test { MockAggregator internal wbtcFeed; MockAggregator internal wethFeed; - ComponentRegistry internal registry; + AssetRegistry internal registry; IndexVault internal vault; address internal keeper = makeAddr("keeper"); @@ -50,13 +52,19 @@ contract IndexVaultTest is Test { wbtcFeed = new MockAggregator(8, 100_000e8); // $100k wethFeed = new MockAggregator(8, 5_000e8); // $5k - registry = new ComponentRegistry(address(this)); + registry = new AssetRegistry(address(this)); registry.setUsdcFeed(address(usdc), address(usdcFeed), HEARTBEAT); - registry.registerComponent(address(wbtc), address(wbtcFeed), HEARTBEAT); - registry.registerComponent(address(weth), address(wethFeed), HEARTBEAT); + registry.registerAsset(address(wbtc), address(wbtcFeed), HEARTBEAT); + registry.registerAsset(address(weth), address(wethFeed), HEARTBEAT); vault = new IndexVault(IERC20(address(usdc)), registry, keeper, address(this)); + // Curate this index's constituents (a subset of the catalog). + address[] memory constituents = new address[](2); + constituents[0] = address(wbtc); + constituents[1] = address(weth); + vault.setConstituents(constituents); + usdc.mint(alice, 1_000_000e6); usdc.mint(bob, 1_000_000e6); vm.prank(alice); @@ -114,7 +122,7 @@ contract IndexVaultTest is Test { vm.warp(block.timestamp + HEARTBEAT + 1); vm.expectRevert( abi.encodeWithSelector( - ComponentRegistry_StalePrice.selector, address(usdcFeed), block.timestamp - HEARTBEAT - 1, HEARTBEAT + AssetRegistry_StalePrice.selector, address(usdcFeed), block.timestamp - HEARTBEAT - 1, HEARTBEAT ) ); vault.totalAssets(); @@ -483,4 +491,101 @@ contract IndexVaultTest is Test { assertLe(usdc.balanceOf(alice) - balBefore, assets); } + + // ======================================================================== + // Constituents (curated membership) and multi-index + // ======================================================================== + + function _constituentArray(address a, address b) internal pure returns (address[] memory arr) { + arr = new address[](2); + arr[0] = a; + arr[1] = b; + } + + function test_GetConstituents_ReflectsCuratedSet() public view { + address[] memory cons = vault.getConstituents(); + assertEq(cons.length, 2); + assertEq(cons[0], address(wbtc)); + assertEq(cons[1], address(weth)); + assertEq(vault.constituentCount(), 2); + assertTrue(vault.isConstituent(address(wbtc))); + assertFalse(vault.isConstituent(address(usdc))); + } + + function test_SetConstituents_RevertsOnUnregisteredAsset() public { + MockERC20 stray = new MockERC20("Stray", "STR", 18); + vm.expectRevert(abi.encodeWithSelector(IndexVault_AssetNotRegistered.selector, address(stray))); + vault.setConstituents(_constituentArray(address(wbtc), address(stray))); + } + + function test_SetConstituents_RevertsOnDuplicate() public { + vm.expectRevert(abi.encodeWithSelector(IndexVault_DuplicateConstituent.selector, address(wbtc))); + vault.setConstituents(_constituentArray(address(wbtc), address(wbtc))); + } + + function test_SetConstituents_ReplacesPreviousSet() public { + // Re-curate to WETH only; WBTC should drop out of the set entirely. + address[] memory one = new address[](1); + one[0] = address(weth); + vault.setConstituents(one); + + assertEq(vault.constituentCount(), 1); + assertTrue(vault.isConstituent(address(weth))); + assertFalse(vault.isConstituent(address(wbtc))); + + // A WBTC balance is now invisible to NAV; only WETH is valued. + _seedBasket(); // mints 1 WBTC + 20 WETH + assertEq(vault.totalAssets(), 100_000e6); // only the $100k WETH counts + } + + function test_GetHoldings_ValuesAndWeightsSumToBasket() public { + _seedBasket(); // 1 WBTC ($100k) + 20 WETH ($100k), no idle + (IndexVault.Holding[] memory holdings, uint256 idleUsd, uint256 totalUsd) = vault.getHoldings(); + + assertEq(holdings.length, 2); + assertEq(idleUsd, 0); + assertEq(totalUsd, 200_000e8); // $200k in 8-decimal USD + assertEq(holdings[0].token, address(wbtc)); + assertEq(holdings[0].valueUsd, 100_000e8); + assertEq(holdings[1].valueUsd, 100_000e8); + // Equal value, so 5000 bps each; weights sum to 1e4. + assertEq(holdings[0].weightBps, 5000); + assertEq(holdings[1].weightBps, 5000); + assertEq(holdings[0].weightBps + holdings[1].weightBps, 10_000); + } + + function test_GetHoldings_AccountsForIdleInWeights() public { + _seedBasket(); // $200k basket + usdc.mint(address(vault), 200_000e6); // add $200k idle + + (IndexVault.Holding[] memory holdings, uint256 idleUsd, uint256 totalUsd) = vault.getHoldings(); + assertEq(idleUsd, 200_000e8); + assertEq(totalUsd, 400_000e8); + // Each constituent is now 25% of NAV; idle is the other 50%. + assertEq(holdings[0].weightBps, 2500); + assertEq(holdings[1].weightBps, 2500); + } + + /// @notice Two index vaults share one registry but curate different sets + /// and keep fully independent NAV. This is the multi-index property. + function test_MultiIndex_TwoVaultsShareRegistryIndependently() public { + // Second index on the same catalog, WETH-only. + IndexVault vault2 = new IndexVault(IERC20(address(usdc)), registry, keeper, address(this)); + address[] memory one = new address[](1); + one[0] = address(weth); + vault2.setConstituents(one); + + // Fund each vault's basket differently. + _seedBasket(); // vault: 1 WBTC + 20 WETH = $200k + weth.mint(address(vault2), 10e18); // vault2: 10 WETH = $50k + + assertEq(vault.totalAssets(), 200_000e6); + assertEq(vault2.totalAssets(), 50_000e6); + + // Their constituent sets are independent. + assertEq(vault.constituentCount(), 2); + assertEq(vault2.constituentCount(), 1); + assertTrue(vault.isConstituent(address(wbtc))); + assertFalse(vault2.isConstituent(address(wbtc))); + } } diff --git a/test/MarketCapMethodology.t.sol b/test/MarketCapMethodology.t.sol index a78e82f..a1f33fc 100644 --- a/test/MarketCapMethodology.t.sol +++ b/test/MarketCapMethodology.t.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.28; import { Test } from "forge-std/Test.sol"; -import { ComponentRegistry } from "src/ComponentRegistry.sol"; +import { AssetRegistry } from "src/AssetRegistry.sol"; import { MarketCapMethodology, MarketCapMethodology_MarketCapExceedsSanityBound, @@ -19,7 +19,7 @@ contract MarketCapMethodologyTest is Test { uint256 internal constant WAD = 1e18; uint48 internal constant HEARTBEAT = 1 days; - ComponentRegistry internal registry; + AssetRegistry internal registry; MockSupplyOracle internal supplyOracle; MarketCapMethodology internal methodology; @@ -48,11 +48,11 @@ contract MarketCapMethodologyTest is Test { solFeed = new MockAggregator(8, 200e8); tailFeed = new MockAggregator(8, 1e8); - registry = new ComponentRegistry(address(this)); - registry.registerComponent(address(wbtc), address(wbtcFeed), HEARTBEAT); - registry.registerComponent(address(weth), address(wethFeed), HEARTBEAT); - registry.registerComponent(address(sol), address(solFeed), HEARTBEAT); - registry.registerComponent(address(tail), address(tailFeed), HEARTBEAT); + registry = new AssetRegistry(address(this)); + registry.registerAsset(address(wbtc), address(wbtcFeed), HEARTBEAT); + registry.registerAsset(address(weth), address(wethFeed), HEARTBEAT); + registry.registerAsset(address(sol), address(solFeed), HEARTBEAT); + registry.registerAsset(address(tail), address(tailFeed), HEARTBEAT); supplyOracle = new MockSupplyOracle(); // Whole-token units per the ISupplyOracle contract. @@ -140,7 +140,7 @@ contract MarketCapMethodologyTest is Test { function test_GetWeights_RevertsOnUnsetSupply() public { MockERC20 unknown = new MockERC20("Unknown", "UNK", 18); MockAggregator unknownFeed = new MockAggregator(8, 1e8); - registry.registerComponent(address(unknown), address(unknownFeed), HEARTBEAT); + registry.registerAsset(address(unknown), address(unknownFeed), HEARTBEAT); tokens.push(address(unknown)); vm.expectRevert(); diff --git a/test/SupplyOracleIntegration.t.sol b/test/SupplyOracleIntegration.t.sol index c52d99d..1cad188 100644 --- a/test/SupplyOracleIntegration.t.sol +++ b/test/SupplyOracleIntegration.t.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.28; import { Test } from "forge-std/Test.sol"; -import { ComponentRegistry } from "src/ComponentRegistry.sol"; +import { AssetRegistry } from "src/AssetRegistry.sol"; import { ExcludedAddressRegistry } from "src/oracle/ExcludedAddressRegistry.sol"; import { SupplyOracle } from "src/oracle/SupplyOracle.sol"; import { MarketCapMethodology } from "src/methodology/MarketCapMethodology.sol"; @@ -18,7 +18,7 @@ contract SupplyOracleIntegrationTest is Test { uint48 internal constant HEARTBEAT = 1 days; uint256 internal constant DELAY = 2 days; - ComponentRegistry internal components; + AssetRegistry internal components; ExcludedAddressRegistry internal excluded; SupplyOracle internal oracle; MarketCapMethodology internal methodology; @@ -57,11 +57,11 @@ contract SupplyOracleIntegrationTest is Test { feeds[2] = new MockAggregator(8, 2e8); feeds[3] = new MockAggregator(8, 2e8); - components = new ComponentRegistry(address(this)); - components.registerComponent(address(wbtc), address(feeds[0]), HEARTBEAT); - components.registerComponent(address(weth), address(feeds[1]), HEARTBEAT); - components.registerComponent(address(tailA), address(feeds[2]), HEARTBEAT); - components.registerComponent(address(tailB), address(feeds[3]), HEARTBEAT); + components = new AssetRegistry(address(this)); + components.registerAsset(address(wbtc), address(feeds[0]), HEARTBEAT); + components.registerAsset(address(weth), address(feeds[1]), HEARTBEAT); + components.registerAsset(address(tailA), address(feeds[2]), HEARTBEAT); + components.registerAsset(address(tailB), address(feeds[3]), HEARTBEAT); excluded = new ExcludedAddressRegistry(address(this), DELAY); oracle = new SupplyOracle(excluded, guardian, address(this));