diff --git a/src/IndexVault.sol b/src/IndexVault.sol index c9140b9..6c0edb0 100644 --- a/src/IndexVault.sol +++ b/src/IndexVault.sol @@ -103,6 +103,15 @@ error IndexVault_QuarantineBlocksSettle(); /// @notice Thrown when quarantine parameters are out of range. error IndexVault_InvalidQuarantineParams(); +/// @notice Thrown when a value-moving operation is attempted while paused. +error IndexVault_Paused(); + +/// @notice Thrown when a guardian-gated function is called by another address. +error IndexVault_NotGuardian(address caller); + +/// @notice Thrown when the max NAV deviation band is out of range. +error IndexVault_InvalidNavDeviation(); + /** * @title IndexVault * @notice Pooled, single-asset (USDC) index vault. ERC-7540 asynchronous @@ -175,6 +184,10 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { uint256 private constant BPS = 10_000; + /// @dev One whole share (18 decimals), the unit the NAV-per-share circuit + /// breaker measures the share price in via convertToAssets. + uint256 private constant WAD = 1e18; + /// @notice Shared asset catalog this index draws constituents from and prices NAV through. AssetRegistry public immutable REGISTRY; @@ -275,6 +288,25 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { /// longer a feed stays dead, the closer its mark falls to zero. uint48 public quarantineDecayWindow = 7 days; + /// @notice Address allowed to pause the vault (the systemic emergency brake), + /// distinct from the keeper and the owner. + address public guardian; + + /// @notice When true, all value-moving operations are halted. Set by the + /// guardian, by the owner, or automatically by the NAV circuit breaker. + bool public paused; + + /// @notice Largest NAV-per-share move tolerated between settlements before + /// the circuit breaker trips. Sized to catch an implausible jump (a + /// compromised token doubling, a correlated oracle failure), while tolerating + /// even severe real volatility, so a legitimate move does not false-trip and + /// train operators to reflexively unpause. Owner-tunable per index. + uint16 public maxNavDeviationBps = 5000; // 50% + + /// @notice Reference NAV per share (assets per 1e18 shares) from the last + /// settlement, against which the circuit breaker measures the next move. + uint256 public lastSettleNavPerShare; + // ======================================================================== // Events // ======================================================================== @@ -302,6 +334,11 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { event ConstituentRemoved(address indexed token); event DustThresholdSet(uint256 dustThresholdUsd); event QuarantineParamsSet(uint16 haircutBps, uint48 decayWindow); + event GuardianSet(address indexed guardian); + event Paused(address indexed by); + event Unpaused(address indexed by); + event CircuitBreakerTripped(uint256 referenceNav, uint256 currentNav); + event MaxNavDeviationSet(uint16 bps); // ======================================================================== // Construction @@ -449,6 +486,14 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { emit ConstituentsSet(tokens); } + /// @dev Halts value-moving operations while the vault is paused (guardian + /// brake or a tripped circuit breaker). Already-settled claims are not gated, + /// since their value was fixed at a prior validated settlement. + modifier whenNotPaused() { + if (paused) revert IndexVault_Paused(); + _; + } + /// @dev Restricts membership mutation to the governor, which enforces the /// timelocked, bounded change lifecycle (Section 16). The vault holds the /// set and applies changes; the governor owns the policy. @@ -647,6 +692,7 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { function requestDeposit(uint256 assets, address controller, address owner) external nonReentrant + whenNotPaused returns (uint256 requestId) { if (assets == 0) revert IndexVault_ZeroAmount(); @@ -680,6 +726,7 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { function requestRedeem(uint256 shares, address controller, address owner) external nonReentrant + whenNotPaused returns (uint256 requestId) { if (shares == 0) revert IndexVault_ZeroAmount(); @@ -829,7 +876,7 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { * settlement, callable by anyone as a liveness backstop. Must be at least * one block after the most recent request (flash-loan protection). */ - function settle() external nonReentrant { + function settle() external nonReentrant whenNotPaused { if (msg.sender != keeper) { if (block.timestamp < lastSettleTimestamp + maxSettleDelay) revert IndexVault_NotKeeper(msg.sender); } @@ -841,6 +888,24 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { // open through the degraded NAV; large flows wait for the feed to recover. if (isAnyQuarantined()) revert IndexVault_QuarantineBlocksSettle(); + // NAV-per-share circuit breaker. A move beyond the band since the last + // settlement signals a collective mispricing (a compromised constituent + // token or a correlated oracle failure) even when every feed passes its + // own staleness check. On breach, pause and return WITHOUT reverting: + // reverting would roll back the pause, so the breaker persists the pause + // and lets settlement not proceed, halting the vault for guardian review + // before any pending membership change can autonomously execute. + uint256 navPerShare = convertToAssets(WAD); + uint256 ref = lastSettleNavPerShare; + if (ref != 0) { + uint256 diff = navPerShare > ref ? navPerShare - ref : ref - navPerShare; + if (diff * BPS > ref * maxNavDeviationBps) { + paused = true; + emit CircuitBreakerTripped(ref, navPerShare); + return; + } + } + uint256 epochId = currentEpoch; EpochData storage epoch = _epochs[epochId]; @@ -883,6 +948,9 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { IERC20(asset()).safeTransfer(address(SILO), assetsToPay); } + // Update the circuit-breaker reference to the freshly settled share price. + lastSettleNavPerShare = convertToAssets(WAD); + emit Settled(epochId, ta, supply, depositAssets, sharesToMint, redeemShares, assetsToPay); } @@ -923,6 +991,38 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { emit QuarantineParamsSet(haircutBps, decayWindow); } + /// @notice Sets the guardian, the address allowed to pause the vault. + function setGuardian(address guardian_) external onlyOwner { + if (guardian_ == address(0)) revert IndexVault_ZeroAddress(); + guardian = guardian_; + emit GuardianSet(guardian_); + } + + /// @notice Halts all value-moving operations. Callable by the guardian (the + /// fast emergency brake) or the owner; the NAV circuit breaker also sets this + /// automatically. Pause stops execution, not clocks: timelocks keep elapsing. + function pause() external { + if (msg.sender != guardian && msg.sender != owner()) revert IndexVault_NotGuardian(msg.sender); + paused = true; + emit Paused(msg.sender); + } + + /// @notice Resumes operations after guardian review. Owner-gated, since + /// unpausing is a deliberate governance act. The circuit-breaker reference is + /// reset to the current share price so a resolved deviation cannot re-trip. + function unpause() external onlyOwner { + paused = false; + lastSettleNavPerShare = convertToAssets(WAD); + emit Unpaused(msg.sender); + } + + /// @notice Sets the NAV-per-share deviation band that trips the circuit breaker. + function setMaxNavDeviation(uint16 bps) external onlyOwner { + if (bps == 0 || bps > BPS) revert IndexVault_InvalidNavDeviation(); + maxNavDeviationBps = bps; + emit MaxNavDeviationSet(bps); + } + /// @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 { @@ -975,6 +1075,7 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { /// @param digest The order digest the settlement computed. /// @param signature The ABI-encoded GPv2Order for that trade. function isValidSignature(bytes32 digest, bytes calldata signature) external view returns (bytes4) { + if (paused) revert IndexVault_Paused(); if (rebalancer == address(0)) revert IndexVault_RebalancerNotSet(); GPv2Order.Data memory order = abi.decode(signature, (GPv2Order.Data)); @@ -1002,7 +1103,12 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { /// there is no nested-guard reversion. Defense in depth: NAV reads a /// 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 { + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) + internal + override + nonReentrant + whenNotPaused + { // 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(); @@ -1013,6 +1119,7 @@ contract IndexVault is ERC4626, Ownable2Step, IERC7540, ReentrancyGuard { internal override nonReentrant + whenNotPaused { super._withdraw(caller, receiver, owner, assets, shares); } diff --git a/test/IndexVaultCircuitBreaker.t.sol b/test/IndexVaultCircuitBreaker.t.sol new file mode 100644 index 0000000..63d0a0a --- /dev/null +++ b/test/IndexVaultCircuitBreaker.t.sol @@ -0,0 +1,217 @@ +// 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_Paused, IndexVault_NotGuardian, IndexVault_InvalidNavDeviation } from "src/IndexVault.sol"; +import { MockERC20 } from "test/mocks/MockERC20.sol"; +import { MockAggregator } from "test/mocks/MockAggregator.sol"; + +/// @notice Section 4 Slice 2: the vault pause / guardian brake and the +/// NAV-per-share circuit breaker (the backstop for a collective mispricing that +/// passes every per-feed check). +contract IndexVaultCircuitBreakerTest is Test { + uint48 internal constant HEARTBEAT = 1 days; + + 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 guardian = makeAddr("guardian"); + 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); + vault.setGuardian(guardian); + + usdc.mint(alice, 1_000_000e6); + vm.prank(alice); + usdc.approve(address(vault), type(uint256).max); + } + + function _seedBasket() internal { + wbtc.mint(address(vault), 1e8); // $100k + weth.mint(address(vault), 20e18); // $100k + } + + function _settle() internal { + vm.warp(block.timestamp + 2 hours); + vm.roll(block.number + 1); + vm.prank(keeper); + vault.settle(); + } + + // ======================================================================== + // Guardian and pause control + // ======================================================================== + + function test_Guardian_PauseAndOwnerUnpause() public { + vm.prank(guardian); + vault.pause(); + assertTrue(vault.paused()); + + vault.unpause(); // owner + assertFalse(vault.paused()); + } + + function test_Pause_OnlyGuardianOrOwner() public { + vm.prank(stranger); + vm.expectRevert(abi.encodeWithSelector(IndexVault_NotGuardian.selector, stranger)); + vault.pause(); + + // Owner may also pause. + vault.pause(); + assertTrue(vault.paused()); + } + + function test_Unpause_OnlyOwner() public { + vm.prank(guardian); + vault.pause(); + vm.prank(guardian); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, guardian)); + vault.unpause(); + } + + // ======================================================================== + // Paused blocks value movement, allows settled claims + // ======================================================================== + + function test_Paused_BlocksEntryAndExit() public { + _seedBasket(); + vm.prank(guardian); + vault.pause(); + + vm.startPrank(alice); + vm.expectRevert(IndexVault_Paused.selector); + vault.deposit(1_000e6, alice); + + vm.expectRevert(IndexVault_Paused.selector); + vault.requestDeposit(1_000e6, alice, alice); + + vm.expectRevert(IndexVault_Paused.selector); + vault.requestRedeem(1, alice, alice); + vm.stopPrank(); + + vm.prank(keeper); + vm.expectRevert(IndexVault_Paused.selector); + vault.settle(); + } + + function test_Paused_BlocksOrderValidation() public { + vm.prank(guardian); + vault.pause(); + // The paused check precedes the rebalancer and decode, so any input reverts. + vm.expectRevert(IndexVault_Paused.selector); + vault.isValidSignature(bytes32(0), ""); + } + + function test_Paused_AllowsSettledClaim() public { + _seedBasket(); + vm.prank(alice); + vault.requestDeposit(10_000e6, alice, alice); + _settle(); + + // Pause after settlement; the already-settled claim must still pay out. + vm.prank(guardian); + vault.pause(); + + vm.prank(alice); + uint256 shares = vault.deposit(10_000e6, alice, alice); // claim form + assertGt(shares, 0, "settled claim must survive a pause"); + } + + // ======================================================================== + // The NAV-per-share circuit breaker + // ======================================================================== + + function test_CircuitBreaker_TripsOnImplausibleJump() public { + _seedBasket(); + _settle(); // establishes the NAV-per-share reference at $200k + + // WBTC doubles to $300k: basket $400k, NAV per share +100%, past the 50% band. + wbtcFeed.setAnswer(300_000e8); + + vm.warp(block.timestamp + 2 hours); + vm.roll(block.number + 1); + vm.prank(keeper); + vault.settle(); // trips: pauses and returns, no revert + + assertTrue(vault.paused(), "breaker must auto-pause on an implausible jump"); + } + + function test_CircuitBreaker_NoTripWithinBand() public { + _seedBasket(); + _settle(); + + // WBTC to $130k: basket $230k, +15%, well within the 50% band. + wbtcFeed.setAnswer(130_000e8); + + _settle(); + assertFalse(vault.paused(), "a move within the band must not trip the breaker"); + } + + function test_CircuitBreaker_UnpauseResetsReference() public { + _seedBasket(); + _settle(); + wbtcFeed.setAnswer(300_000e8); // implausible jump + + vm.warp(block.timestamp + 2 hours); + vm.roll(block.number + 1); + vm.prank(keeper); + vault.settle(); + assertTrue(vault.paused()); + + // Owner reviews, accepts the new price as legitimate, and unpauses: the + // reference resets to the current price so the next settle does not re-trip. + vault.unpause(); + _settle(); + assertFalse(vault.paused(), "reset reference must prevent an immediate re-trip"); + } + + // ======================================================================== + // Params + // ======================================================================== + + function test_SetMaxNavDeviation_Validates() public { + vault.setMaxNavDeviation(3000); + assertEq(vault.maxNavDeviationBps(), 3000); + + vm.expectRevert(IndexVault_InvalidNavDeviation.selector); + vault.setMaxNavDeviation(0); + + vm.expectRevert(IndexVault_InvalidNavDeviation.selector); + vault.setMaxNavDeviation(10_001); + } +}