diff --git a/src/CPPIVault.sol b/src/CPPIVault.sol index 36da2d2..2c6f0bb 100644 --- a/src/CPPIVault.sol +++ b/src/CPPIVault.sol @@ -54,6 +54,12 @@ contract CPPIVault is ERC20, Ownable { uint256 internal constant SCHEDULED_SLIPPAGE_BPS = 50; uint256 internal constant EMERGENCY_SLIPPAGE_BPS = 150; + /// @dev Wider bound used for an emergency de-risk while the oracle is + /// unhealthy (audit H6). A stale feed serves a last-good price that + /// lags a fast fall; the tight 150bps bound would then make the swap + /// unfillable and brick the very defense it must run. Executing at a + /// wider bound during a genuine feed outage beats not de-risking. + uint256 internal constant EMERGENCY_DEGRADED_SLIPPAGE_BPS = 1000; // ---------- async accounting ---------- @@ -414,7 +420,14 @@ contract CPPIVault is ERC20, Ownable { } int256 deltaWad = int256(a.targetRisky) - int256(riskyLeg.value()); - uint256 bound = trigger == RebalancePolicy.Trigger.Emergency ? EMERGENCY_SLIPPAGE_BPS : SCHEDULED_SLIPPAGE_BPS; + uint256 bound; + if (trigger == RebalancePolicy.Trigger.Emergency) { + // relax the bound while the oracle is degraded so a lagging feed + // cannot brick the permissionless de-risk (audit H6) + bound = _oracleDegraded() ? EMERGENCY_DEGRADED_SLIPPAGE_BPS : EMERGENCY_SLIPPAGE_BPS; + } else { + bound = SCHEDULED_SLIPPAGE_BPS; + } executor.executeRebalance(deltaWad, bound); controller.recordRebalance(trigger, a.floor, a.targetRisky); emit Rebalanced(trigger, deltaWad, a.floor, a.targetRisky); @@ -451,6 +464,10 @@ contract CPPIVault is ERC20, Ownable { if (address(healthSource) != address(0) && !healthSource.healthy()) revert OracleUnhealthy(); } + function _oracleDegraded() internal view returns (bool) { + return address(healthSource) != address(0) && !healthSource.healthy(); + } + function _idleWad() internal view returns (uint256) { return SafeTransferLib.balanceOf(asset, address(this)) * assetScale; } diff --git a/src/ExecutionModule.sol b/src/ExecutionModule.sol index 6c77dba..9fbb445 100644 --- a/src/ExecutionModule.sol +++ b/src/ExecutionModule.sol @@ -19,8 +19,11 @@ interface IVaultAccounting { /// @title ExecutionModule /// @notice Routes every value flow between the legs through Uniswap V3 with /// oracle-anchored slippage bounds. Atomic by construction: no async -/// dependency anywhere, so the permissionless emergency rebalance is -/// never blocked (spec invariant 5). +/// dependency anywhere on the emergency path. The vault widens the +/// emergency bound while the oracle is degraded (audit H6) so a +/// lagging feed cannot brick the de-risk; a swap can still revert if +/// no venue can fill within the (widened) bound, which is the market +/// genuinely gapping past a fair exit, i.e. the >1/m gap case. /// @dev Buy-side funding order: the vault's FREE idle first (settled deposit /// cash awaiting allocation; pending-deposit and reserved-payout cash is /// never touched), then the safe leg. Sell proceeds always land in the diff --git a/test/CrashScenarios.t.sol b/test/CrashScenarios.t.sol index 3d78995..c5a3b8f 100644 --- a/test/CrashScenarios.t.sol +++ b/test/CrashScenarios.t.sol @@ -257,4 +257,46 @@ contract CrashScenariosTest is Test { _try(makeAddr("rando")); assertGe(vault.shareholderNav() + 1e15, controller.lastFloor()); } + + // ---------- H6 regression: emergency de-risk survives a stale oracle ---- + + function test_h6_emergencyDeRiskNotBrickedByStaleOracle() public { + // wire a real OracleHub as the health source and let its feed go stale + MockFeed feed = new MockFeed(); + MockWstRate wr = new MockWstRate(); + MockPool pool = new MockPool(); + pool.setRatioWad(1.2e18); + OracleHub hub = new OracleHub(address(feed), address(wr), address(pool), true, owner); + hub.refresh(); + vm.prank(owner); + vault.setHealthSource(IOracleHealth(address(hub))); + vm.warp(block.timestamp + 3 hours); // feed stale -> degraded + + // execution costs 3% on BOTH fee tiers: exceeds the tight 150bps + // emergency bound but is inside the 1000bps degraded bound + router.setTier(500, 300, false); + router.setTier(3000, 300, false); + + // crash: emergency fires; a stranger de-risks permissionlessly + prices.setEth(ETH0 * 60 / 100); + vm.prank(makeAddr("rando")); + vault.rebalance(); // must NOT revert: degraded bound accommodates 3% + + // exposure was actually reduced (de-risk executed), floor defended + uint256 nav = vault.shareholderNav(); + assertLt(riskyLeg.value() * 1e18 / nav, 0.2e18); + assertGe(nav + 1e15, controller.lastFloor()); + } + + function test_h6_tightBoundStillProtectsWhenHealthy() public { + // same 3% execution cost but oracle HEALTHY: the tight 150bps bound + // must reject the sandwiched fill (MEV protection preserved) + router.setTier(500, 300, false); + router.setTier(3000, 300, false); + vm.warp(block.timestamp + 2 hours); + prices.setEth(ETH0 * 60 / 100); + vm.prank(makeAddr("rando")); + vm.expectRevert(); // both tiers miss the 150bps minOut + vault.rebalance(); + } }