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
16 changes: 16 additions & 0 deletions src/CPPIVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 56 additions & 2 deletions src/OracleHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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 ----------
Expand Down
43 changes: 43 additions & 0 deletions test/OracleHub.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions test/VaultIntegration.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down