diff --git a/src/rebalancer/Rebalancer.sol b/src/rebalancer/Rebalancer.sol index 62ca91d..eef9a7c 100644 --- a/src/rebalancer/Rebalancer.sol +++ b/src/rebalancer/Rebalancer.sol @@ -188,13 +188,18 @@ contract Rebalancer { } delete _epochConstituents; - // Snapshot the new target: weight times NAV per constituent. - address[] memory constituents = VAULT.getConstituents(); - uint256[] memory weights = METHODOLOGY.getWeights(constituents); + // Snapshot the new target over the FRESH constituents only. A quarantined + // (stale-feed) constituent cannot be priced, so the methodology cannot + // weight it and the rebalancer cannot anchor a minimum-out to sell it + // (Section 16.5). It is excluded from the epoch entirely, neither bought + // nor sold, and held marked-down (Section 4) until its feed recovers; the + // healthy names rebalance around it instead of the whole epoch halting. + address[] memory fresh = _freshSubset(VAULT.getConstituents()); + uint256[] memory weights = METHODOLOGY.getWeights(fresh); (,, uint256 navUsd) = VAULT.getHoldings(); - for (uint256 i = 0; i < constituents.length; i++) { - address token = constituents[i]; + for (uint256 i = 0; i < fresh.length; i++) { + address token = fresh[i]; // A constituent marked for wind-down targets zero, so the whole // position becomes overweight and is sold to USDC at the // oracle-anchored minimum-out (Section 16.5, wind-down not dump). @@ -210,7 +215,7 @@ contract Rebalancer { epochId++; } - emit EpochOpened(epochId, navUsd, constituents.length); + emit EpochOpened(epochId, navUsd, fresh.length); } // ======================================================================== @@ -225,17 +230,30 @@ contract Rebalancer { address[] memory cons = VAULT.getConstituents(); if (cons.length == 0) return 0; - uint256[] memory weights = METHODOLOGY.getWeights(cons); + // Weight over the fresh subset only, so a single stale feed does not + // revert the drift read (and therefore the trigger). Quarantined names + // are skipped: they are held, not rebalanced. + address[] memory fresh = _freshSubset(cons); + if (fresh.length == 0) return 0; + uint256[] memory weights = METHODOLOGY.getWeights(fresh); (IndexVault.Holding[] memory holdings,, uint256 navUsd) = VAULT.getHoldings(); if (navUsd == 0) return 0; + // `holdings` is parallel to the full constituent list; `weights` is over + // the fresh subset in the same order, so a fresh-index cursor keeps them + // aligned as the loop skips quarantined names. + uint256 fi = 0; for (uint256 i = 0; i < cons.length; i++) { + if (VAULT.isQuarantined(cons[i])) continue; // A winding-down constituent targets zero, so its full held weight // reads as drift and pulls the index toward opening an exit epoch. - uint256 targetBps = VAULT.windingDown(cons[i]) ? 0 : weights[i].mulDiv(BPS, WAD, Math.Rounding.Floor); + uint256 targetBps = VAULT.windingDown(cons[i]) ? 0 : weights[fi].mulDiv(BPS, WAD, Math.Rounding.Floor); uint256 actualBps = holdings[i].weightBps; uint256 d = actualBps > targetBps ? actualBps - targetBps : targetBps - actualBps; if (d > maxBps) maxBps = d; + unchecked { + fi++; + } } } @@ -372,6 +390,26 @@ contract Rebalancer { // Internal // ======================================================================== + /// @dev The constituents whose feeds are fresh (not quarantined), in the same + /// order as `cons`. Weighting and rebalancing operate over this subset so a + /// single stale feed does not halt the whole epoch. + function _freshSubset(address[] memory cons) internal view returns (address[] memory fresh) { + uint256 n = 0; + for (uint256 i = 0; i < cons.length; i++) { + if (!VAULT.isQuarantined(cons[i])) n++; + } + fresh = new address[](n); + uint256 j = 0; + for (uint256 i = 0; i < cons.length; i++) { + if (!VAULT.isQuarantined(cons[i])) { + fresh[j] = cons[i]; + unchecked { + j++; + } + } + } + } + function _baseOrder( address sellToken, address buyToken, diff --git a/test/RebalancerQuarantine.t.sol b/test/RebalancerQuarantine.t.sol new file mode 100644 index 0000000..f3c434d --- /dev/null +++ b/test/RebalancerQuarantine.t.sol @@ -0,0 +1,150 @@ +// 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 { IndexVault } from "src/IndexVault.sol"; +import { AssetRegistry } from "src/AssetRegistry.sol"; +import { MarketCapMethodology } from "src/methodology/MarketCapMethodology.sol"; +import { ISupplyOracle } from "src/interfaces/ISupplyOracle.sol"; +import { Rebalancer } from "src/rebalancer/Rebalancer.sol"; +import { MockGPv2Settlement } from "test/mocks/MockGPv2Settlement.sol"; +import { MockERC20 } from "test/mocks/MockERC20.sol"; +import { MockAggregator } from "test/mocks/MockAggregator.sol"; +import { MockSupplyOracle } from "test/mocks/MockSupplyOracle.sol"; + +/// @notice Section 4 Slice 3: the rebalancer rebalances around a quarantined +/// constituent (it weights and trades over the fresh subset) instead of the +/// whole epoch halting when a single feed goes stale. +contract RebalancerQuarantineTest is Test { + uint256 internal constant WAD = 1e18; + uint48 internal constant HEARTBEAT = 1 days; + uint256 internal constant SLIPPAGE_BPS = 100; + uint256 internal constant MIN_INTERVAL = 1 hours; + uint256 internal constant CADENCE = 7 days; + uint256 internal constant D_SMALL_BPS = 200; + uint256 internal constant D_LARGE_BPS = 500; + + AssetRegistry internal registry; + MarketCapMethodology internal methodology; + IndexVault internal vault; + Rebalancer internal rebalancer; + + MockERC20 internal usdc; + MockERC20 internal wbtc; + MockERC20 internal weth; + + MockAggregator internal usdcFeed; + MockAggregator internal wbtcFeed; + MockAggregator internal wethFeed; + + address internal keeper = makeAddr("keeper"); + address internal anyone = makeAddr("anyone"); + + 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); + + MockSupplyOracle supplyOracle = new MockSupplyOracle(); + supplyOracle.setSupply(address(wbtc), 1_000_000); + supplyOracle.setSupply(address(weth), 20_000_000); + + methodology = new MarketCapMethodology(registry, ISupplyOracle(address(supplyOracle)), address(this)); + methodology.setWeightParams(WAD, WAD, 1); // no cap, so a single fresh name is feasible + + 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); + + MockGPv2Settlement settlement = new MockGPv2Settlement(); + rebalancer = new Rebalancer( + vault, + methodology, + registry, + address(usdc), + address(settlement), + keeper, + SLIPPAGE_BPS, + MIN_INTERVAL, + CADENCE, + D_SMALL_BPS, + D_LARGE_BPS + ); + + // Balanced basket: WBTC $150k, WETH $150k. + wbtc.mint(address(vault), 1.5e8); + weth.mint(address(vault), 30e18); + } + + /// @dev Stale WBTC's feed, refreshing the others so only WBTC is quarantined. + function _quarantineWbtc() internal { + vm.warp(block.timestamp + HEARTBEAT + 1); + usdcFeed.setAnswer(1e8); + wethFeed.setAnswer(5_000e8); + } + + // ======================================================================== + // The trigger survives a stale feed + // ======================================================================== + + function test_Quarantine_MaxDriftDoesNotRevert() public { + _quarantineWbtc(); + assertTrue(vault.isQuarantined(address(wbtc))); + // Before the fix this reverted (getWeights priced the stale name); now it + // computes drift over the fresh subset. + uint256 drift = rebalancer.maxDriftBps(); + assertGt(drift, 0); + } + + // ======================================================================== + // openEpoch rebalances around the quarantined name + // ======================================================================== + + function test_Quarantine_OpenEpochExcludesQuarantined() public { + _quarantineWbtc(); + + // WETH is the only fresh name, so the methodology targets it at 100% of + // NAV; that large drift opens an emergency (permissionless) epoch. + assertGe(rebalancer.maxDriftBps(), D_LARGE_BPS); + vm.prank(anyone); + rebalancer.openEpoch(); + assertEq(rebalancer.epochId(), 1); + + // The quarantined name is excluded from the epoch entirely: not targeted, + // not orderable. The fresh name carries the whole target. + assertFalse(rebalancer.inEpoch(address(wbtc))); + assertEq(rebalancer.targetUsd(address(wbtc)), 0); + + assertTrue(rebalancer.inEpoch(address(weth))); + assertEq(rebalancer.targetUsd(address(weth)), rebalancer.epochNavUsd()); + assertGt(rebalancer.targetUsd(address(weth)), 0); + } + + function test_NoQuarantine_BothConstituentsInEpoch() public { + // Sanity: with all feeds fresh, the normal path is unchanged. Push WBTC + // overweight so there is drift to open on. + wbtc.mint(address(vault), 0.6e8); + vm.prank(anyone); + rebalancer.openEpoch(); + + assertTrue(rebalancer.inEpoch(address(wbtc))); + assertTrue(rebalancer.inEpoch(address(weth))); + assertGt(rebalancer.targetUsd(address(wbtc)), 0); + assertGt(rebalancer.targetUsd(address(weth)), 0); + } +}