diff --git a/src/IndexVault.sol b/src/IndexVault.sol index 6c0edb0..08be803 100644 --- a/src/IndexVault.sol +++ b/src/IndexVault.sol @@ -395,6 +395,31 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { return IERC20(asset()).balanceOf(address(this)); } + /// @notice USDC value of the current epoch's pending redemptions, at NAV. + function pendingRedeemAssets() public view returns (uint256) { + uint256 shares = _epochs[currentEpoch].pendingRedeemShares; + return shares == 0 ? 0 : convertToAssets(shares); + } + + /// @notice Whether the current epoch's pending redemptions exceed the idle + /// buffer, so the rebalancer must free USDC from the basket before settle can + /// pay them. This is a first-class rebalance trigger (Section 3). + function redemptionFundingNeeded() external view returns (bool) { + return pendingRedeemAssets() > idleAssets(); + } + + /// @notice USD (8-decimal, matching the rebalancer's targets) the rebalancer + /// should hold back as USDC when it opens an epoch: the full value of the + /// pending redemptions. Targeting the constituents over `NAV - reserve` + /// leaves that much USDC, and since NAV already includes the existing buffer, + /// the basket only sells the shortfall down to reach it. Zero when the buffer + /// already covers redemptions, so a pure reweight is unaffected. + function rebalanceReserveUsd() external view returns (uint256) { + uint256 pendingUsdc = pendingRedeemAssets(); + if (pendingUsdc <= idleAssets()) return 0; + return pendingUsdc.mulDiv(REGISTRY.getUsdcPriceUsd(), _ASSET_UNIT, Math.Rounding.Ceil); + } + /// @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 diff --git a/src/rebalancer/Rebalancer.sol b/src/rebalancer/Rebalancer.sol index eef9a7c..b33b83c 100644 --- a/src/rebalancer/Rebalancer.sol +++ b/src/rebalancer/Rebalancer.sol @@ -171,13 +171,22 @@ contract Rebalancer { } uint256 drift = maxDriftBps(); - if (drift < D_LARGE_BPS) { - // Not an emergency: scheduled path, keeper plus cadence plus small gate. + // A redemption the buffer cannot cover is a first-class trigger: the + // keeper may open an epoch to free USDC from the basket regardless of + // drift or cadence, because redemption liveness cannot wait for the + // reweight schedule (Section 3). The min-interval floor still applies. + bool fundingNeeded = VAULT.redemptionFundingNeeded(); + if (drift < D_LARGE_BPS && !fundingNeeded) { + // Not an emergency and no funding need: scheduled reweight path, + // keeper plus cadence plus small gate. if (msg.sender != KEEPER) revert Rebalancer_NotKeeper(msg.sender); if (epochId != 0 && block.timestamp < epochOpenedAt + CADENCE) { revert Rebalancer_CadenceNotElapsed(epochOpenedAt + CADENCE); } if (drift < D_SMALL_BPS) revert Rebalancer_BelowDriftThreshold(drift, D_SMALL_BPS); + } else if (fundingNeeded && drift < D_LARGE_BPS) { + // Funding open below the emergency band is keeper-gated. + if (msg.sender != KEEPER) revert Rebalancer_NotKeeper(msg.sender); } // Clear the previous snapshot. @@ -198,12 +207,25 @@ contract Rebalancer { uint256[] memory weights = METHODOLOGY.getWeights(fresh); (,, uint256 navUsd) = VAULT.getHoldings(); + // Hold back a USDC reserve for pending redemptions the buffer cannot + // cover, then weight the constituents over the deployable remainder. This + // makes the basket overweight relative to its targets, so it is sold to + // USDC and the redemption is funded before settle pays it (Section 3). + uint256 reserveUsd = VAULT.rebalanceReserveUsd(); + if (reserveUsd > 0) { + // Selling the basket nets only (1 - slippage) of oracle value, so + // gross the reserve up by the max slippage; otherwise the USDC raised + // would fall a haircut short of the redemption it must fund. + reserveUsd = reserveUsd.mulDiv(BPS, BPS - MAX_SLIPPAGE_BPS, Math.Rounding.Ceil); + } + uint256 deployableNav = navUsd > reserveUsd ? navUsd - reserveUsd : 0; + 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). - uint256 target = VAULT.windingDown(token) ? 0 : navUsd.mulDiv(weights[i], WAD, Math.Rounding.Floor); + uint256 target = VAULT.windingDown(token) ? 0 : deployableNav.mulDiv(weights[i], WAD, Math.Rounding.Floor); targetUsd[token] = target; inEpoch[token] = true; _epochConstituents.push(token); diff --git a/test/BasketFundedRedemption.t.sol b/test/BasketFundedRedemption.t.sol new file mode 100644 index 0000000..e0191b9 --- /dev/null +++ b/test/BasketFundedRedemption.t.sol @@ -0,0 +1,192 @@ +// 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 { GPv2Order } from "src/libraries/GPv2Order.sol"; +import { Rebalancer, Rebalancer_NotKeeper } 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 Basket-funded redemptions: when a redemption exceeds the buffer, the +/// rebalancer holds back a USDC reserve and sells the basket down to raise it, so +/// settle can pay a large exit that the buffer alone could not cover (Section 3). +contract BasketFundedRedemptionTest is Test { + uint256 internal constant WAD = 1e18; + uint48 internal constant HEARTBEAT = 1 days; + uint256 internal constant SLIPPAGE_BPS = 100; + + AssetRegistry internal registry; + MarketCapMethodology internal methodology; + IndexVault internal vault; + Rebalancer internal rebalancer; + MockGPv2Settlement internal settlement; + + MockERC20 internal usdc; + MockERC20 internal wbtc; + MockERC20 internal weth; + + address internal keeper = makeAddr("keeper"); + address internal alice = makeAddr("alice"); + + 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); + + registry = new AssetRegistry(address(this)); + registry.setUsdcFeed(address(usdc), address(new MockAggregator(8, 1e8)), HEARTBEAT); + registry.registerAsset(address(wbtc), address(new MockAggregator(8, 100_000e8)), HEARTBEAT); + registry.registerAsset(address(weth), address(new MockAggregator(8, 5_000e8)), 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); // 50/50, no cap + + 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); + + settlement = new MockGPv2Settlement(); + rebalancer = new Rebalancer( + vault, + methodology, + registry, + address(usdc), + address(settlement), + keeper, + SLIPPAGE_BPS, + 1 hours, + 7 days, + 200, + 500 + ); + vault.setRebalancer(address(rebalancer), address(settlement)); + vault.approveRelayer(address(wbtc)); + vault.approveRelayer(address(weth)); + vault.approveRelayer(address(usdc)); + + // Settlement funded to pay out the sell legs. + usdc.mint(address(settlement), 5_000_000e6); + } + + /// @dev Balanced basket ($150k WBTC + $150k WETH), no idle, alice holds all + /// shares. Deal bypasses the deposit machinery to set up the redemption case. + function _seedBalancedWithHolder() internal { + wbtc.mint(address(vault), 1.5e8); + weth.mint(address(vault), 30e18); + deal(address(vault), alice, 300_000e18, true); // adjust totalSupply too + } + + /// @dev Sells (just under) a constituent's full overweight budget to USDC. + function _sellOverweight(address token, uint256 price8, uint8 dec) internal { + uint256 overUsd = rebalancer.overweightUsd(token); + if (overUsd == 0) return; + uint256 amount = (overUsd * (10 ** dec) / price8) * 9999 / 10_000; + GPv2Order.Data memory sell = + rebalancer.buildSellOrder(token, amount, uint32(block.timestamp + 1 hours), bytes32("fund")); + settlement.settle(sell, address(vault), amount); + } + + // ======================================================================== + // Trigger and reserve + // ======================================================================== + + function test_RedemptionFundingNeeded() public { + _seedBalancedWithHolder(); + assertFalse(vault.redemptionFundingNeeded()); + + vm.prank(alice); + vault.requestRedeem(100_000e18, alice, alice); + assertTrue(vault.redemptionFundingNeeded()); + + // A buffer large enough to cover it flips the flag off. + usdc.mint(address(vault), 200_000e6); + assertFalse(vault.redemptionFundingNeeded()); + } + + function test_Funding_OpensEpochBelowDriftGate() public { + _seedBalancedWithHolder(); + // Balanced basket: drift is zero, below the scheduled gate. + assertEq(rebalancer.maxDriftBps(), 0); + + vm.prank(alice); + vault.requestRedeem(100_000e18, alice, alice); + vm.roll(block.number + 1); + + // A non-keeper cannot open the funding epoch below the emergency band. + vm.expectRevert(abi.encodeWithSelector(Rebalancer_NotKeeper.selector, address(this))); + rebalancer.openEpoch(); + + // The keeper opens it despite zero drift, because funding is needed. + vm.prank(keeper); + rebalancer.openEpoch(); + assertEq(rebalancer.epochId(), 1); + } + + function test_Funding_ReserveHeldBackInTargets() public { + _seedBalancedWithHolder(); + vm.prank(alice); + vault.requestRedeem(100_000e18, alice, alice); + vm.roll(block.number + 1); + + vm.prank(keeper); + rebalancer.openEpoch(); + + // Targets no longer sum to full NAV: a USDC reserve is held back. + uint256 sumTargets = rebalancer.targetUsd(address(wbtc)) + rebalancer.targetUsd(address(weth)); + assertLt(sumTargets, rebalancer.epochNavUsd(), "reserve not held back"); + // The basket is now overweight, so it can be sold to raise the reserve. + assertGt(rebalancer.overweightUsd(address(wbtc)), 0); + assertGt(rebalancer.overweightUsd(address(weth)), 0); + } + + // ======================================================================== + // End to end: a large redemption is funded from the basket + // ======================================================================== + + function test_E2E_BasketFundsLargeRedemption() public { + _seedBalancedWithHolder(); + + // Alice exits half the vault, $150k, which the zero buffer cannot cover. + vm.prank(alice); + vault.requestRedeem(150_000e18, alice, alice); + assertTrue(vault.redemptionFundingNeeded()); + assertEq(vault.idleAssets(), 0); + vm.roll(block.number + 1); + + // Keeper opens the funding rebalance and the basket is sold to raise USDC. + vm.prank(keeper); + rebalancer.openEpoch(); + _sellOverweight(address(wbtc), 100_000e8, 8); + _sellOverweight(address(weth), 5_000e8, 18); + + // The buffer now covers the redemption where it could not before. + assertGt(vault.idleAssets(), 149_000e6, "basket did not raise enough USDC"); + + // Settle pays the redemption from the raised buffer, then Alice claims. + vm.warp(block.timestamp + 2 hours); + vm.roll(block.number + 1); + vm.prank(keeper); + vault.settle(); + + vm.prank(alice); + uint256 out = vault.redeem(150_000e18, alice, alice); + assertGt(out, 148_000e6, "redemption underpaid"); + assertApproxEqAbs(out, 149_250e6, 1_000e6); + } +}