From 3e198889d6e5a15f271fbb2a16b7031072e75103 Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Tue, 30 Jun 2026 14:53:37 +0530 Subject: [PATCH] feat(rebalancer): rebalance around a quarantined constituent (Section 4) A single stale feed previously reverted the whole rebalance, because getWeights priced every constituent. The rebalancer now weights and trades over the FRESH subset, so the healthy names rebalance while a quarantined name is held marked-down (Section 4) until its feed recovers. - openEpoch and maxDriftBps build a fresh subset (constituents whose feeds are not quarantined) and call getWeights over it, so a stale feed no longer reverts the trigger or the snapshot. maxDriftBps keeps a fresh-index cursor aligned to the full holdings array. - A quarantined name is excluded from the epoch entirely: not targeted, and validateOrder's inEpoch check rejects any order for it. This is the Section 16.5 stale-feed special case: the name cannot be priced, so it cannot be sold either (sellMinOut anchors to the failed oracle), so it is held rather than wound down until the feed recovers. Completes the Section 4 safety layer (quarantine, circuit breaker, rebalance around quarantine). 3 new tests, 162 total green. --- src/rebalancer/Rebalancer.sol | 54 ++++++++++-- test/RebalancerQuarantine.t.sol | 150 ++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 test/RebalancerQuarantine.t.sol 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); + } +}