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
19 changes: 18 additions & 1 deletion src/CPPIVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----------

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 5 additions & 2 deletions src/ExecutionModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions test/CrashScenarios.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}