Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/IndexVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 25 additions & 3 deletions src/rebalancer/Rebalancer.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down
192 changes: 192 additions & 0 deletions test/BasketFundedRedemption.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}