From 8686f03a60cb82d0140b1995caebfef3569aa5eb Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Sat, 27 Jun 2026 21:40:47 +0530 Subject: [PATCH] feat(vault): constituent quarantine and graceful redemption (Section 4) A single stale constituent feed previously halted NAV and therefore every redemption, a liveness cliff that worsens at index-100 where 'all feeds fresh' is fragile. The vault now degrades instead of halting. - AssetRegistry: add a non-reverting getPriceUsdStatus that surfaces a stale feed's last-good answer and freshness, with the feed normalization factored out so the reverting and non-reverting paths share one implementation. - NAV degradation: a fresh constituent values at full price; a stale one at its last-good price times a mark factor that starts at (1 - haircut) and decays linearly to zero over a window; a dead feed values at zero. totalAssets and getHoldings degrade together; the USDC numeraire feed stays fail-closed. - Exits bend, entries stop: sync buffer redemptions survive a stale feed at the haircut NAV, while mint, sync deposit, and async settle fail closed under quarantine, since minting against a conservative NAV would over-issue and dilute the holders who stay. A zero-balance stale constituent does not block. Deferred to later slices: the NAV-per-share circuit breaker and guardian pause, and the rebalancer dropping quarantined names from target weights. 10 new tests, 149 total green. --- src/AssetRegistry.sol | 32 ++++- src/IndexVault.sol | 107 ++++++++++++++-- test/IndexVaultQuarantine.t.sol | 210 ++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 11 deletions(-) create mode 100644 test/IndexVaultQuarantine.t.sol diff --git a/src/AssetRegistry.sol b/src/AssetRegistry.sol index 76640bb..8341525 100644 --- a/src/AssetRegistry.sol +++ b/src/AssetRegistry.sol @@ -194,6 +194,17 @@ contract AssetRegistry is Ownable2Step { return _readFeed(a); } + /// @notice Non-reverting price read for the vault's quarantine path: returns + /// the feed's last answer (8 decimals), its `updatedAt`, and whether it is + /// fresh. A stale feed still surfaces its last-good answer here rather than + /// reverting, so the vault can value the constituent conservatively instead + /// of halting NAV. A non-positive answer reports price zero and not fresh. + function getPriceUsdStatus(address token) external view returns (uint256 price, uint256 updatedAt, bool fresh) { + Asset memory a = _assets[token]; + if (a.token == address(0)) revert AssetRegistry_NotRegistered(token); + return _readFeedStatus(a); + } + // ======================================================================== // Internal // ======================================================================== @@ -205,10 +216,23 @@ contract AssetRegistry is Ownable2Step { if (block.timestamp > updatedAt + a.heartbeat) { revert AssetRegistry_StalePrice(a.feed, updatedAt, a.heartbeat); } + return _normalizePrice(uint256(answer), a.feedDecimals); + } + + /// @dev Non-reverting feed read: normalizes the last answer and reports + /// freshness. A non-positive answer yields price zero and fresh false. + function _readFeedStatus(Asset memory a) internal view returns (uint256 price, uint256 updatedAt, bool fresh) { + int256 answer; + (, answer,, updatedAt,) = IAggregatorV3(a.feed).latestRoundData(); + if (answer <= 0) return (0, updatedAt, false); + price = _normalizePrice(uint256(answer), a.feedDecimals); + fresh = block.timestamp <= 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); + /// @dev Normalizes a raw feed answer to the registry's 8-decimal convention. + function _normalizePrice(uint256 raw, uint8 feedDecimals) private pure returns (uint256) { + if (feedDecimals == PRICE_DECIMALS) return raw; + if (feedDecimals < PRICE_DECIMALS) return raw * 10 ** (PRICE_DECIMALS - feedDecimals); + return raw / 10 ** (feedDecimals - PRICE_DECIMALS); } } diff --git a/src/IndexVault.sol b/src/IndexVault.sol index bfe3e96..c9140b9 100644 --- a/src/IndexVault.sol +++ b/src/IndexVault.sol @@ -92,6 +92,17 @@ error IndexVault_NotWindingDown(address token); /// @notice Thrown when finalizing removal before the position has been wound down to dust. error IndexVault_PositionNotDust(address token, uint256 valueUsd); +/// @notice Thrown when a deposit or mint is attempted while a held constituent +/// is quarantined. Mints fail closed because a conservative NAV would over-issue. +error IndexVault_QuarantineBlocksDeposit(); + +/// @notice Thrown when settlement is attempted while a held constituent is +/// quarantined. The async batch is deferred until the feed recovers. +error IndexVault_QuarantineBlocksSettle(); + +/// @notice Thrown when quarantine parameters are out of range. +error IndexVault_InvalidQuarantineParams(); + /** * @title IndexVault * @notice Pooled, single-asset (USDC) index vault. ERC-7540 asynchronous @@ -255,6 +266,15 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { /// winding-down constituent is considered fully exited and may be removed. uint256 public dustThresholdUsd = 1e8; + /// @notice Haircut applied the moment a constituent's feed goes stale and it + /// enters quarantine, sized to the plausible adverse move over the window. + uint16 public quarantineHaircutBps = 1000; // 10% + + /// @notice Window over which a quarantined constituent's mark decays from + /// (1 - haircut) to zero, measured from the feed's last fresh timestamp. The + /// longer a feed stays dead, the closer its mark falls to zero. + uint48 public quarantineDecayWindow = 7 days; + // ======================================================================== // Events // ======================================================================== @@ -281,6 +301,7 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { event WindDownBegun(address indexed token); event ConstituentRemoved(address indexed token); event DustThresholdSet(uint256 dustThresholdUsd); + event QuarantineParamsSet(uint16 haircutBps, uint48 decayWindow); // ======================================================================== // Construction @@ -313,16 +334,17 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { * NAV-per-share ratio stays consistent for both lanes. */ function totalAssets() public view override returns (uint256) { + // The USDC numeraire feed still fails closed: it is the unit NAV is quoted + // in, not a constituent, so its staleness is a hard failure, not a quarantine. uint256 usdcPrice = REGISTRY.getUsdcPriceUsd(); address[] memory cons = _constituents; uint256 basketUsd = 0; for (uint256 i = 0; i < cons.length; i++) { - address token = cons[i]; - uint256 balance = IERC20(token).balanceOf(address(this)); + uint256 balance = IERC20(cons[i]).balanceOf(address(this)); if (balance == 0) continue; - uint256 price = REGISTRY.getPriceUsd(token); - basketUsd += balance.mulDiv(price, 10 ** _constituentDecimals[token], Math.Rounding.Floor); + (uint256 valueUsd,) = _constituentValueUsd(cons[i], balance); + basketUsd += valueUsd; } // basketUsd has 8 decimals (registry PRICE_DECIMALS); dividing the USD @@ -336,6 +358,60 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { return IERC20(asset()).balanceOf(address(this)); } + /// @dev USD value (8-decimal) of `balance` of `token`, degrading on a stale + /// or dead feed instead of reverting. A fresh feed values at full price; a + /// stale feed values at its last-good price times a mark factor that starts + /// at (1 - haircut) and decays linearly to zero over quarantineDecayWindow; a + /// dead feed (non-positive answer) values at zero. Returns whether the + /// constituent is quarantined (feed not fresh). + function _constituentValueUsd(address token, uint256 balance) + private + view + returns (uint256 valueUsd, bool quarantined) + { + (uint256 price, uint256 updatedAt, bool fresh) = REGISTRY.getPriceUsdStatus(token); + uint256 fullUsd = price == 0 ? 0 : balance.mulDiv(price, 10 ** _constituentDecimals[token], Math.Rounding.Floor); + if (fresh) return (fullUsd, false); + + // Quarantined: conservative mark on the last-good price. + uint256 markBps = _quarantineMarkBps(updatedAt); + return (fullUsd.mulDiv(markBps, BPS, Math.Rounding.Floor), true); + } + + /// @dev Quarantine mark factor in bps: (BPS - haircut) when the feed has just + /// gone stale, decaying linearly to zero across quarantineDecayWindow. Age is + /// measured from the feed's last fresh timestamp, which is marginally more + /// conservative than measuring from the heartbeat boundary, the safe direction. + function _quarantineMarkBps(uint256 updatedAt) private view returns (uint256) { + uint256 age = block.timestamp > updatedAt ? block.timestamp - updatedAt : 0; + if (age >= quarantineDecayWindow) return 0; + uint256 base = BPS - quarantineHaircutBps; + return base.mulDiv(quarantineDecayWindow - age, quarantineDecayWindow, Math.Rounding.Floor); + } + + /// @notice Whether `token` is a constituent whose feed is currently stale or + /// dead, so it is being valued conservatively in NAV. + function isQuarantined(address token) public view returns (bool) { + if (!isConstituent[token]) return false; + (,, bool fresh) = REGISTRY.getPriceUsdStatus(token); + return !fresh; + } + + /// @notice Whether any constituent the vault actually holds is quarantined. + /// Deposits, mints, and settlement gate on this: a quarantined (undervalued) + /// basket must not be minted against, or it would over-issue shares and dilute + /// the holders who stay. A zero-balance constituent cannot affect NAV, so a + /// stale feed on one the vault does not hold does not block entries. + function isAnyQuarantined() public view returns (bool) { + address[] memory cons = _constituents; + for (uint256 i = 0; i < cons.length; i++) { + if (IERC20(cons[i]).balanceOf(address(this)) == 0) continue; + (,, bool fresh) = REGISTRY.getPriceUsdStatus(cons[i]); + if (!fresh) return true; + } + return false; + } + // ======================================================================== // Constituents (curated membership) // ======================================================================== @@ -472,9 +548,9 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { 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); + // Degrade on a stale feed, matching totalAssets, so holdings reads + // and NAV agree and neither halts on a single quarantined constituent. + (uint256 valueUsd,) = balance == 0 ? (uint256(0), false) : _constituentValueUsd(token, balance); holdings[i] = Holding({ token: token, balance: balance, valueUsd: valueUsd, weightBps: 0 }); basketUsd += valueUsd; } @@ -759,6 +835,11 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { } if (block.timestamp < lastSettleTimestamp + minSettleInterval) revert IndexVault_SettleIntervalNotPassed(); if (block.number <= _lastRequestBlock) revert IndexVault_RequestBlockDelayNotPassed(); + // Defer the whole async batch while any held constituent is quarantined: + // the deposit leg must not mint against a conservative NAV, and netting + // both legs at a degraded ratio is unsafe. Sync buffer redemptions remain + // open through the degraded NAV; large flows wait for the feed to recover. + if (isAnyQuarantined()) revert IndexVault_QuarantineBlocksSettle(); uint256 epochId = currentEpoch; EpochData storage epoch = _epochs[epochId]; @@ -833,6 +914,15 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { emit DustThresholdSet(dustThresholdUsd_); } + /// @notice Sets the quarantine haircut and decay window used to value a + /// constituent whose feed has gone stale. + function setQuarantineParams(uint16 haircutBps, uint48 decayWindow) external onlyOwner { + if (haircutBps >= BPS || decayWindow == 0) revert IndexVault_InvalidQuarantineParams(); + quarantineHaircutBps = haircutBps; + quarantineDecayWindow = decayWindow; + emit QuarantineParamsSet(haircutBps, decayWindow); + } + /// @notice Sets the buffer band. Low and high gate the sync lanes; target /// is the level the rebalancer tops the buffer back toward. function setBufferBand(uint16 lowBps, uint16 targetBps, uint16 highBps) external onlyOwner { @@ -913,6 +1003,9 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { /// constituent's balanceOf, so a callback token could otherwise reenter, and /// the curation policy independently excludes callback tokens. function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override nonReentrant { + // Mints fail closed under quarantine: a conservative NAV would over-issue + // shares to a depositor at the expense of the holders who stay. + if (isAnyQuarantined()) revert IndexVault_QuarantineBlocksDeposit(); super._deposit(caller, receiver, assets, shares); } diff --git a/test/IndexVaultQuarantine.t.sol b/test/IndexVaultQuarantine.t.sol new file mode 100644 index 0000000..ba05da8 --- /dev/null +++ b/test/IndexVaultQuarantine.t.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.28; + +import { Test } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { IndexVault } from "src/IndexVault.sol"; +import { AssetRegistry } from "src/AssetRegistry.sol"; +import { + IndexVault_QuarantineBlocksDeposit, + IndexVault_QuarantineBlocksSettle, + IndexVault_InvalidQuarantineParams +} from "src/IndexVault.sol"; +import { MockERC20 } from "test/mocks/MockERC20.sol"; +import { MockAggregator } from "test/mocks/MockAggregator.sol"; + +/// @notice Section 4 Slice 1: constituent quarantine and graceful redemption +/// degradation. A stale constituent feed degrades NAV (so buffer redemptions +/// survive) rather than halting the vault, while mints and settlement fail closed. +contract IndexVaultQuarantineTest is Test { + uint48 internal constant HEARTBEAT = 1 days; + uint256 internal constant BPS = 10_000; + + MockERC20 internal usdc; + MockERC20 internal wbtc; + MockERC20 internal weth; + + MockAggregator internal usdcFeed; + MockAggregator internal wbtcFeed; + MockAggregator internal wethFeed; + + AssetRegistry internal registry; + IndexVault internal vault; + + address internal keeper = makeAddr("keeper"); + address internal alice = makeAddr("alice"); + address internal stranger = makeAddr("stranger"); + + function setUp() public { + vm.warp(30 days); + + usdc = new MockERC20("USD Coin", "USDC", 6); + wbtc = new MockERC20("Wrapped BTC", "WBTC", 8); + weth = new MockERC20("Wrapped Ether", "WETH", 18); + + usdcFeed = new MockAggregator(8, 1e8); + wbtcFeed = new MockAggregator(8, 100_000e8); + wethFeed = new MockAggregator(8, 5_000e8); + + registry = new AssetRegistry(address(this)); + registry.setUsdcFeed(address(usdc), address(usdcFeed), 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)); + address[] memory constituents = new address[](2); + constituents[0] = address(wbtc); + constituents[1] = address(weth); + vault.setConstituents(constituents); + + usdc.mint(alice, 1_000_000e6); + vm.prank(alice); + usdc.approve(address(vault), type(uint256).max); + } + + // ======================================================================== + // Helpers + // ======================================================================== + + /// @dev $100k WBTC + $100k WETH held by the vault. + function _seedBasket() internal { + wbtc.mint(address(vault), 1e8); + weth.mint(address(vault), 20e18); + } + + /// @dev Warps past the heartbeat to stale WBTC, then refreshes USDC and WETH + /// so only WBTC is quarantined. + function _quarantineWbtc() internal { + vm.warp(block.timestamp + HEARTBEAT + 1); + usdcFeed.setAnswer(1e8); + wethFeed.setAnswer(5_000e8); + } + + // ======================================================================== + // NAV degradation + // ======================================================================== + + function test_Quarantine_DegradesNavInsteadOfReverting() public { + _seedBasket(); + assertEq(vault.totalAssets(), 200_000e6); // fresh baseline + + _quarantineWbtc(); + + assertTrue(vault.isQuarantined(address(wbtc))); + assertFalse(vault.isQuarantined(address(weth))); + + // WBTC marked at base (9000) decayed by age/window: 9000 * (7d - (1d+1)) / 7d + // = 7714 bps. WBTC $100k -> $77,140; WETH $100k fresh. Total $177,140. + uint256 ta = vault.totalAssets(); + assertEq(ta, 177_140e6); + assertLt(ta, 200_000e6); + } + + function test_Quarantine_DeadFeedMarksToZero() public { + _seedBasket(); + wbtcFeed.setAnswer(0); // dead feed: non-positive answer + + assertTrue(vault.isQuarantined(address(wbtc))); + // WBTC values to zero, only fresh WETH ($100k) remains. + assertEq(vault.totalAssets(), 100_000e6); + } + + function test_Quarantine_DecaysToZeroOverWindow() public { + _seedBasket(); + // Past the full decay window, the stale mark falls to zero. + vm.warp(block.timestamp + 7 days + 1); + usdcFeed.setAnswer(1e8); + wethFeed.setAnswer(5_000e8); + + assertTrue(vault.isQuarantined(address(wbtc))); + assertEq(vault.totalAssets(), 100_000e6); // WBTC fully decayed, WETH fresh + } + + // ======================================================================== + // The liveness guarantee: buffer redemptions survive a stale feed + // ======================================================================== + + function test_Quarantine_BufferRedeemSurvives() public { + _seedBasket(); + // Alice deposits while everything is fresh, leaving an idle buffer. + vm.prank(alice); + uint256 shares = vault.deposit(10_000e6, alice); + assertGt(shares, 0); + + _quarantineWbtc(); + + // A small buffer redemption still works, at the degraded (haircut) NAV. + uint256 redeemShares = shares / 4; + uint256 before = usdc.balanceOf(alice); + vm.prank(alice); + uint256 assetsOut = vault.redeem(redeemShares, alice, alice); + + assertGt(assetsOut, 0, "buffer exit must survive a stale feed"); + assertEq(usdc.balanceOf(alice) - before, assetsOut); + } + + // ======================================================================== + // Mints and settlement fail closed + // ======================================================================== + + function test_Quarantine_MintFailsClosed() public { + _seedBasket(); + _quarantineWbtc(); + + vm.prank(alice); + vm.expectRevert(IndexVault_QuarantineBlocksDeposit.selector); + vault.deposit(1_000e6, alice); + } + + function test_Quarantine_SettleFailsClosed() public { + _seedBasket(); + vm.prank(alice); + vault.requestDeposit(10_000e6, alice, alice); + + _quarantineWbtc(); + vm.roll(block.number + 1); + + vm.prank(keeper); + vm.expectRevert(IndexVault_QuarantineBlocksSettle.selector); + vault.settle(); + } + + function test_Quarantine_ZeroBalanceDoesNotBlockEntries() public { + // Vault holds only WETH; WBTC balance is zero. + weth.mint(address(vault), 20e18); + _quarantineWbtc(); // WBTC feed stale, but the vault holds none of it + + assertTrue(vault.isQuarantined(address(wbtc))); + assertFalse(vault.isAnyQuarantined(), "a zero-balance stale feed must not block entries"); + + // A deposit goes through because no held constituent is quarantined. + vm.prank(alice); + assertGt(vault.deposit(1_000e6, alice), 0); + } + + // ======================================================================== + // Params + // ======================================================================== + + function test_SetQuarantineParams() public { + vault.setQuarantineParams(2000, 3 days); + assertEq(vault.quarantineHaircutBps(), 2000); + assertEq(vault.quarantineDecayWindow(), 3 days); + } + + function test_SetQuarantineParams_Validates() public { + vm.expectRevert(IndexVault_InvalidQuarantineParams.selector); + vault.setQuarantineParams(uint16(BPS), 3 days); // haircut must be < 100% + + vm.expectRevert(IndexVault_InvalidQuarantineParams.selector); + vault.setQuarantineParams(1000, 0); // window must be nonzero + } + + function test_SetQuarantineParams_OnlyOwner() public { + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, stranger)); + vault.setQuarantineParams(2000, 3 days); + } +}