diff --git a/src/CPPIVault.sol b/src/CPPIVault.sol index 46fd2b1..34df4cc 100644 --- a/src/CPPIVault.sol +++ b/src/CPPIVault.sol @@ -11,6 +11,7 @@ import {ILeg, IExecutionModule, IRateOracle} from "./interfaces/IVaultPeriphery. interface IOracleHealth { function healthy() external view returns (bool); + function prolongedStale() external view returns (bool); } /// @title CPPIVault @@ -255,6 +256,7 @@ contract CPPIVault is ERC20, Ownable { /// shares, and reserve their payout. Requires enough idle asset /// to cover reserved payouts (keeper frees assets beforehand). function settleEpoch() external onlyKeeper { + _requireOracleHealthy(); // L5: don't crystallize value at a stale/depegged price _accrueManagementFee(); uint256 depositsWad = totalPendingDepositsWad; uint256 redeemShares = totalPendingRedeemShares; @@ -450,6 +452,20 @@ contract CPPIVault is ERC20, Ownable { emit Rebalanced(trigger, deltaWad, a.floor, a.targetRisky); } + /// @notice Permissionless circuit breaker (audit M4). When the oracle has + /// been stale beyond its prolonged window, the risky-leg mark is + /// frozen and the normal CPPI trigger is blind to a real decline, + /// so anyone may fully de-risk the vault into the safe leg at the + /// degraded bound. Over-conservative but floor-safe: a later + /// rebalance re-risks once the feed recovers. + function deRiskUnderProlongedStaleness() external { + if (address(healthSource) == address(0) || !healthSource.prolongedStale()) revert OracleUnhealthy(); + uint256 risky = riskyLeg.value(); + if (risky == 0) revert NoTrigger(); + executor.executeRebalance(-int256(risky), EMERGENCY_DEGRADED_SLIPPAGE_BPS); + emit Rebalanced(RebalancePolicy.Trigger.Emergency, -int256(risky), 0, 0); + } + /// @notice Keeper pre-funds redemption settlement from the safe side. function freeAssets(uint256 amountWad) external onlyKeeper { executor.freeAssets(amountWad); diff --git a/src/OracleHub.sol b/src/OracleHub.sol index abef960..ac6adae 100644 --- a/src/OracleHub.sol +++ b/src/OracleHub.sol @@ -55,11 +55,26 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { uint256 public maxFeedAge = 3900; // Chainlink ETH/USD heartbeat 3600 + margin uint256 public maxBasisBps = 200; + /// @dev Optional USDC/USD feed (audit L8). The vault denominates in USDC + /// but marks the risky leg in USD; if unset, USDC is assumed at par. + /// When set and USDC depegs beyond maxUsdcDepegBps, healthy() flips + /// false so deposits/settlement pause until the peg returns. + IChainlinkFeed public usdcUsdFeed; + uint256 public maxUsdcDepegBps = 200; + + /// @dev Prolonged-staleness window (audit M4). Once the ETH feed has been + /// stale longer than this since the last good snapshot, the CPPI + /// trigger is blind, so a permissionless circuit-breaker de-risk is + /// allowed (see CPPIVault.deRiskUnderProlongedStaleness). + uint256 public prolongedStalenessWindow = 3 hours; + uint256 public snapshotEthUsdWad; uint64 public snapshotAt; event Refreshed(uint256 ethUsdWad); event ParamsSet(uint256 maxFeedAge, uint256 maxBasisBps); + event UsdcFeedSet(address feed, uint256 maxDepegBps); + event ProlongedWindowSet(uint256 window); error NoPrice(); error BadParams(); @@ -83,6 +98,21 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { emit ParamsSet(maxFeedAge_, maxBasisBps_); } + /// @notice Set the optional USDC/USD depeg feed (audit L8). address(0) + /// disables the check (USDC assumed at par). + function setUsdcFeed(address feed, uint256 maxDepegBps) external onlyOwner { + if (maxDepegBps > 2000) revert BadParams(); + usdcUsdFeed = IChainlinkFeed(feed); + maxUsdcDepegBps = maxDepegBps; + emit UsdcFeedSet(feed, maxDepegBps); + } + + function setProlongedStalenessWindow(uint256 window) external onlyOwner { + if (window < maxFeedAge || window > 2 days) revert BadParams(); + prolongedStalenessWindow = window; + emit ProlongedWindowSet(window); + } + // ---------- maintenance ---------- /// @notice Snapshot the current fresh price; permissionless. The keeper @@ -125,10 +155,34 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { return _basisBps(rateBased, poolBased) <= maxBasisBps; } - /// @notice Feed freshness gate the vault uses to pause NEW deposits. + /// @notice Health gate the vault uses to pause NEW user flows and epoch + /// settlement: the ETH feed must be fresh AND USDC on peg (L8). function healthy() external view returns (bool) { (, bool fresh) = _freshEthUsd(); - return fresh; + return fresh && usdcHealthy(); + } + + /// @notice True when USDC is within its depeg tolerance (or no feed set). + function usdcHealthy() public view returns (bool) { + if (address(usdcUsdFeed) == address(0)) return true; + try usdcUsdFeed.latestRoundData() returns (uint80, int256 answer, uint256, uint256 updatedAt, uint80) { + if (answer <= 0 || block.timestamp > updatedAt + maxFeedAge) return false; + uint256 px = uint256(answer) * 10 ** (18 - usdcUsdFeed.decimals()); + uint256 dev = px > 1e18 ? px - 1e18 : 1e18 - px; + return dev * 10_000 / 1e18 <= maxUsdcDepegBps; + } catch { + return false; + } + } + + /// @notice True when the ETH feed has been stale beyond the prolonged + /// window (audit M4): the risky-leg mark is frozen and the CPPI + /// trigger is blind, so a permissionless circuit-breaker de-risk + /// is warranted. + function prolongedStale() external view returns (bool) { + (, bool fresh) = _freshEthUsd(); + if (fresh) return false; + return snapshotAt != 0 && block.timestamp > uint256(snapshotAt) + prolongedStalenessWindow; } // ---------- IRateOracle ---------- diff --git a/test/OracleHub.t.sol b/test/OracleHub.t.sol index 5cfac58..5b6d121 100644 --- a/test/OracleHub.t.sol +++ b/test/OracleHub.t.sol @@ -76,6 +76,49 @@ contract OracleHubTest is Test { assertTrue(hub.wstethBuyAllowed()); } + // ---------- L8 regression: USDC depeg gate ---------- + + function test_l8_noUsdcFeed_assumesPar() public view { + assertTrue(hub.usdcHealthy()); + assertTrue(hub.healthy()); + } + + function test_l8_usdcDepeg_flipsUnhealthy() public { + MockFeed usdc = new MockFeed(); + usdc.set(1e8, block.timestamp); // $1.00 + hub.setUsdcFeed(address(usdc), 200); // 2% tolerance + assertTrue(hub.healthy()); + + usdc.set(0.99e8, block.timestamp); // 1% off: within tolerance + assertTrue(hub.usdcHealthy()); + + usdc.set(0.95e8, block.timestamp); // 5% depeg: breaches + assertFalse(hub.usdcHealthy()); + assertFalse(hub.healthy()); // eth fresh but usdc depegged + } + + function test_l8_staleUsdcFeed_unhealthy() public { + MockFeed usdc = new MockFeed(); + usdc.set(1e8, block.timestamp); + hub.setUsdcFeed(address(usdc), 200); + vm.warp(block.timestamp + 2 hours); // usdc feed now stale + assertFalse(hub.usdcHealthy()); + } + + // ---------- M4 regression: prolonged-staleness view ---------- + + function test_m4_prolongedStale_onlyAfterWindow() public { + hub.refresh(); // snapshotAt = now + assertFalse(hub.prolongedStale()); // fresh + + vm.warp(block.timestamp + 2 hours); // stale (> 65min) but < 3h window + assertFalse(hub.healthy()); + assertFalse(hub.prolongedStale()); + + vm.warp(block.timestamp + 2 hours); // now > 3h since snapshot + assertTrue(hub.prolongedStale()); + } + function test_staleness_servesSnapshot_flagsUnhealthy() public { hub.refresh(); // snapshot 2000 // feed goes stale beyond maxFeedAge diff --git a/test/VaultIntegration.t.sol b/test/VaultIntegration.t.sol index 640a2fd..3b49dbd 100644 --- a/test/VaultIntegration.t.sol +++ b/test/VaultIntegration.t.sol @@ -18,10 +18,15 @@ import {MockPTAdapter} from "./SafeLegManager.t.sol"; contract MockHealth is IOracleHealth { bool public healthy = true; + bool public prolongedStale; function set(bool h) external { healthy = h; } + + function setProlonged(bool p) external { + prolongedStale = p; + } } contract MockRate { @@ -157,6 +162,40 @@ contract VaultIntegrationTest is Test { assertEq(vault.settleTerm(), 0); } + // ---------- M4/L5 regression: oracle robustness ---------- + + function test_m4_prolongedStaleness_permissionlessFullDeRisk() public { + _enter(100_000e6); + assertGt(riskyLeg.value(), 20_000e18); // ~27% ETH + + // oracle stale beyond the prolonged window: normal trigger is blind + health.setProlonged(true); + + // anyone can force a full de-risk into the safe leg + vm.prank(makeAddr("rando")); + vault.deRiskUnderProlongedStaleness(); + assertLt(riskyLeg.value(), 1e15); // fully de-risked + assertGe(vault.shareholderNav() + 1e15, controller.lastFloor()); + } + + function test_m4_notAllowedWhenNotProlonged() public { + _enter(100_000e6); + vm.prank(makeAddr("rando")); + vm.expectRevert(CPPIVault.OracleUnhealthy.selector); + vault.deRiskUnderProlongedStaleness(); + } + + function test_l5_settleEpoch_blockedWhenUnhealthy() public { + // alice requests while healthy + vm.prank(alice); + vault.requestDeposit(100_000e6); + // oracle goes unhealthy before settlement + health.set(false); + vm.prank(keeper); + vm.expectRevert(CPPIVault.OracleUnhealthy.selector); + vault.settleEpoch(); + } + function _enter(uint256 assets) internal { vm.prank(alice); vault.requestDeposit(assets);