From b115fbd7b068786c29758ea0c0638fac87a2af10 Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Sat, 18 Jul 2026 16:13:54 +0530 Subject: [PATCH] fix(H6): relax the emergency de-risk bound while the oracle is degraded Audit H6: every de-risk swap anchors minOut to OracleHub.ethUsdWad() x (1 - bound). When the Chainlink feed is stale the hub serves the last-good snapshot, which during a fast fall is above market; at the tight 150bps emergency bound the swap then can't fill on either tier and rebalance() reverts, bricking the permissionless de-risk during the exact crash it must run. Fix: the vault checks oracle health (it already holds healthSource) and, for an Emergency trigger while degraded, uses a wider 1000bps bound so the de-risk executes rather than reverting. Normal-condition emergencies keep the tight 150bps MEV protection. A swap can still revert if no venue fills within the widened bound, which is the market genuinely gapping past a fair exit (the >1/m case). Corrected the overstated 'never blocked' invariant comment. Regression tests: emergency de-risk survives a stale oracle + 3% execution cost; the tight bound still rejects the same fill when healthy. 116 -> 118. --- src/CPPIVault.sol | 19 +++++++++++++++++- src/ExecutionModule.sol | 7 +++++-- test/CrashScenarios.t.sol | 42 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) 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(); + } }