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
2 changes: 1 addition & 1 deletion script/Deploy.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ contract Deploy is Script {
controller = new CPPIController(address(vault), MULTIPLIER, fc, rc);
safeLeg = new SafeLegManager(address(vault), USDC, 6, msg.sender);
riskyLeg = new RiskyLegManager(WETH, WSTETH, msg.sender);
exec = new ExecutionModule(address(vault), USDC, WETH, WSTETH, msg.sender);
exec = new ExecutionModule(address(vault), USDC, WETH, WSTETH, 6, msg.sender);
oracle = new OracleHub(CHAINLINK_ETH_USD, WSTETH, WSTETH_WETH_POOL_100, true, msg.sender);
pt = new PendlePTAdapter(PENDLE_ROUTER, PENDLE_PY_ORACLE, pendleMarket, USDC, USDC, 6, 900, msg.sender);
}
Expand Down
38 changes: 36 additions & 2 deletions src/CPPIVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ contract CPPIVault is ERC20, Ownable {
/// wider bound during a genuine feed outage beats not de-risking.
uint256 internal constant EMERGENCY_DEGRADED_SLIPPAGE_BPS = 1000;

/// @dev Guardian-settable widening of the *healthy-oracle* emergency bound
/// (audit L6). 0 (default) uses EMERGENCY_SLIPPAGE_BPS. During a genuine
/// thin- or attacker-thinned-liquidity dislocation the permissionless
/// de-risk can miss the tight 150bps bound and revert; the guardian may
/// widen it, up to the already-sanctioned degraded ceiling, so the
/// defense still clears. Widening trades more single-swap sandwich
/// exposure (audit L7) for guaranteed execution, so it is a deliberate,
/// resettable knob that never loosens the scheduled or degraded bounds.
uint256 public emergencySlippageBps;

// ---------- async accounting ----------

struct Request {
Expand Down Expand Up @@ -102,6 +112,7 @@ contract CPPIVault is ERC20, Ownable {
event ManagementFeeAccrued(uint256 feeShares);
event PerformanceFeeCharged(uint256 feeShares, uint256 gainWad);
event OperatorSet(address indexed controller, address indexed operator, bool approved);
event EmergencySlippageSet(uint256 bps);

error ZeroAmount();
error Paused();
Expand All @@ -118,6 +129,7 @@ contract CPPIVault is ERC20, Ownable {
error FeeAboveCap();
error NotOperator();
error ClaimMismatch();
error SlippageOutOfRange();

modifier onlyKeeper() {
if (msg.sender != keeper && msg.sender != owner()) revert NotKeeper();
Expand Down Expand Up @@ -178,6 +190,21 @@ contract CPPIVault is ERC20, Ownable {
emit PausedSet(paused_);
}

/// @notice Widen (or reset) the healthy-oracle emergency de-risk bound so a
/// thin/attacker-thinned pool cannot indefinitely revert the
/// permissionless defense (audit L6). 0 resets to the tight default;
/// any override stays within [EMERGENCY_SLIPPAGE_BPS,
/// EMERGENCY_DEGRADED_SLIPPAGE_BPS] so it can only ever widen the
/// tight bound toward the already-sanctioned degraded ceiling.
function setEmergencySlippageBps(uint256 bps) external {
if (msg.sender != guardian && msg.sender != owner()) revert NotGuardian();
if (bps != 0 && (bps < EMERGENCY_SLIPPAGE_BPS || bps > EMERGENCY_DEGRADED_SLIPPAGE_BPS)) {
revert SlippageOutOfRange();
}
emergencySlippageBps = bps;
emit EmergencySlippageSet(bps);
}

// ---------- NAV ----------

/// @notice Total value in the system, WAD asset terms.
Expand Down Expand Up @@ -462,8 +489,15 @@ contract CPPIVault is ERC20, Ownable {
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;
// cannot brick the permissionless de-risk (audit H6); when the feed
// is healthy use the guardian-configurable bound, which widens the
// tight default only during a declared thin-liquidity dislocation
// (audit L6) and defaults to EMERGENCY_SLIPPAGE_BPS
if (_oracleDegraded()) {
bound = EMERGENCY_DEGRADED_SLIPPAGE_BPS;
} else {
bound = emergencySlippageBps == 0 ? EMERGENCY_SLIPPAGE_BPS : emergencySlippageBps;
}
} else {
bound = SCHEDULED_SLIPPAGE_BPS;
}
Expand Down
34 changes: 23 additions & 11 deletions src/ExecutionModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ interface IVaultAccounting {
/// oracle-anchored slippage bounds. Atomic by construction: no async
/// 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.
/// lagging feed cannot brick the de-risk, and the guardian can widen
/// the healthy-oracle emergency bound during a thin- or attacker-
/// thinned-liquidity dislocation (audit L6). A swap can still revert
/// when no venue fills within the (widened) bound: either the market
/// is genuinely gapping past a fair exit (the >1/m gap case) or a
/// transient pool dislocation that the permissionless, re-callable
/// rebalance retries away as arbitrage re-aligns the pool. The de-risk
/// is therefore best-effort-within-bound, not unconditionally atomic.
/// @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 All @@ -37,7 +42,12 @@ contract ExecutionModule is IExecutionModule, Ownable {
address public immutable usdc;
address public immutable weth;
address public immutable wsteth;
uint256 internal constant USDC_SCALE = 1e12;
/// @dev Derived from the vault asset's decimals (audit I3). The vault, safe
/// leg and PT adapter all parameterize assetDecimals, so this module
/// must too rather than bake in a 6-decimal (1e12) assumption that
/// silently breaks every conversion on a non-6-decimal redeployment.
uint256 public immutable assetScale; // 10^(18 - assetDecimals)
uint256 public immutable dustFloor; // one whole asset unit: 10^assetDecimals

SafeLegManager public safeLeg;
RiskyLegManager public riskyLeg;
Expand Down Expand Up @@ -66,11 +76,13 @@ contract ExecutionModule is IExecutionModule, Ownable {
_;
}

constructor(address vault_, address usdc_, address weth_, address wsteth_, address owner_) {
constructor(address vault_, address usdc_, address weth_, address wsteth_, uint8 assetDecimals_, address owner_) {
vault = vault_;
usdc = usdc_;
weth = weth_;
wsteth = wsteth_;
assetScale = 10 ** (18 - assetDecimals_); // reverts if assetDecimals_ > 18
dustFloor = 10 ** assetDecimals_;
_initializeOwner(owner_);
}

Expand Down Expand Up @@ -180,7 +192,7 @@ contract ExecutionModule is IExecutionModule, Ownable {
// funding: vault free idle first, then the safe leg
uint256 freeIdleWad = _vaultFreeIdleWad();
uint256 fromIdleWad = FixedPointMathLib.min(deltaWad, freeIdleWad);
uint256 fromIdleUsdc = fromIdleWad / USDC_SCALE;
uint256 fromIdleUsdc = fromIdleWad / assetScale;
if (fromIdleUsdc > 0) usdc.safeTransferFrom(vault, address(this), fromIdleUsdc);

if (fromIdleWad < deltaWad) {
Expand All @@ -189,7 +201,7 @@ contract ExecutionModule is IExecutionModule, Ownable {
uint256 usdcIn = SafeTransferLib.balanceOf(usdc, address(this));
if (usdcIn == 0) return;

uint256 minWethOut = (usdcIn * USDC_SCALE).divWad(priceSource.ethUsdWad()) * (10_000 - maxSlippageBps) / 10_000;
uint256 minWethOut = (usdcIn * assetScale).divWad(priceSource.ethUsdWad()) * (10_000 - maxSlippageBps) / 10_000;
uint256 wethOut = _swap(usdc, weth, primaryFee, usdcIn, minWethOut, address(riskyLeg));
emit RebalanceExecuted(int256(deltaWad), usdcIn, wethOut);
}
Expand All @@ -206,7 +218,7 @@ contract ExecutionModule is IExecutionModule, Ownable {
}
if (wethGot == 0) return;

uint256 minUsdcOut = wethGot.mulWad(ethUsd) * (10_000 - maxSlippageBps) / 10_000 / USDC_SCALE;
uint256 minUsdcOut = wethGot.mulWad(ethUsd) * (10_000 - maxSlippageBps) / 10_000 / assetScale;
uint256 usdcOut = _swap(weth, usdc, primaryFee, wethGot, minUsdcOut, address(safeLeg));
safeLeg.onInflow();
emit RebalanceExecuted(-int256(deltaWad), usdcOut, wethGot);
Expand Down Expand Up @@ -247,8 +259,8 @@ contract ExecutionModule is IExecutionModule, Ownable {
/// the vault. Never runs inside freeAssets (that flow is outbound).
function _sweepIdle() internal {
uint256 freeWad = _vaultFreeIdleWad();
uint256 assets = freeWad / USDC_SCALE;
if (assets < 1e6) return; // dust: not worth the PT trade
uint256 assets = freeWad / assetScale;
if (assets < dustFloor) return; // dust: not worth the PT trade
usdc.safeTransferFrom(vault, address(safeLeg), assets);
safeLeg.onInflow();
}
Expand All @@ -259,7 +271,7 @@ contract ExecutionModule is IExecutionModule, Ownable {
/// the funding back into the safe leg).
function _vaultFreeIdleWad() internal view returns (uint256) {
IVaultAccounting v = IVaultAccounting(vault);
uint256 idleWad = SafeTransferLib.balanceOf(usdc, vault) * USDC_SCALE;
uint256 idleWad = SafeTransferLib.balanceOf(usdc, vault) * assetScale;
uint256 owedWad = v.totalPendingDepositsWad() + v.totalReservedPayoutsWad()
+ v.totalPendingRedeemShares().mulWad(v.navPerShare());
return idleWad > owedWad ? idleWad - owedWad : 0;
Expand Down
21 changes: 20 additions & 1 deletion src/PendlePTAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ contract PendlePTAdapter is IPTAdapter, Ownable {
event Withdrawn(uint256 amountWad, uint256 ptIn, uint256 assetsOut, bool viaRedemption);
event Rolled(address indexed fromMarket, address indexed toMarket, uint256 assetsMoved, uint256 ptOut);
event SlippageSet(uint256 bps);
event MarketPrepared(address indexed market, uint16 cardinality);

error NotAuthorized();
error AlreadySet();
Expand Down Expand Up @@ -184,6 +185,21 @@ contract PendlePTAdapter is IPTAdapter, Ownable {
emit Rolled(oldMarket, newMarket, assetsMoved, ptOut);
}

/// @notice Warm up a market's Pendle oracle ahead of binding it (audit I2).
/// `_bindMarket` (via the constructor or `rollToMarket`) reverts
/// `OracleNotReady` when the TWAP window is not yet satisfied, which
/// rolls back the cardinality increase issued in the same tx, so the
/// bump never persists and the operator is stuck. This standalone,
/// non-reverting call issues the increase (a permissionless one-time
/// market setup) so the TWAP window can start filling before the
/// roll. Owner-only convenience; the underlying market call is itself
/// permissionless, so it can also be triggered directly on the market.
function prepareMarket(address market_) external onlyOwner {
(bool increaseRequired, uint16 cardinalityRequired,) = oracle.getOracleState(market_, twapDuration);
if (increaseRequired) IPendleMarket(market_).increaseObservationsCardinalityNext(cardinalityRequired);
emit MarketPrepared(market_, cardinalityRequired);
}

// ---------- internal ----------

function _bindMarket(address market_) internal {
Expand All @@ -193,7 +209,10 @@ contract PendlePTAdapter is IPTAdapter, Ownable {
}
(bool increaseRequired, uint16 cardinalityRequired, bool oldestSatisfied) =
oracle.getOracleState(market_, twapDuration);
// cardinality growth is permissionless one-time setup; do it ourselves
// cardinality growth is a permissionless one-time market setup; issue it
// here too, but note it only persists when this bind succeeds. For a cold
// oracle (oldest observation not yet satisfied) the revert below rolls it
// back, so warm the market with prepareMarket() ahead of the roll (audit I2).
if (increaseRequired) IPendleMarket(market_).increaseObservationsCardinalityNext(cardinalityRequired);
if (!oldestSatisfied) revert OracleNotReady();
uint256 expiry = IPendleMarket(market_).expiry();
Expand Down
67 changes: 66 additions & 1 deletion test/CrashScenarios.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ contract CrashScenariosTest is Test {
safeLeg = new SafeLegManager(address(vault), address(usdc), 6, owner);
pt = new MockPTAdapter(address(usdc));
riskyLeg = new RiskyLegManager(address(weth), address(wsteth), owner);
exec = new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), owner);
exec = new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), 6, owner);

vm.startPrank(owner);
vault.setController(controller);
Expand Down Expand Up @@ -299,4 +299,69 @@ contract CrashScenariosTest is Test {
vm.expectRevert(); // both tiers miss the 150bps minOut
vault.rebalance();
}

// ---------- L6: guardian-widenable healthy-oracle emergency bound ----

// The exact thin-liquidity dislocation of test_h6_tightBoundStillProtects...
// (3% cost, healthy oracle) reverts the permissionless de-risk at 150bps.
// The guardian can widen the healthy-oracle bound so the defense clears.
function test_l6_guardianWidenedBoundClearsThinLiquidityDeRisk() public {
router.setTier(500, 300, false);
router.setTier(3000, 300, false);
vm.warp(block.timestamp + 2 hours);
prices.setEth(ETH0 * 60 / 100);

// guardian (== keeper in this setup) widens the emergency bound to 5%
// for the declared dislocation; the 3% fill now clears (audit L6).
vm.prank(keeper);
vault.setEmergencySlippageBps(500);

uint256 riskyBefore = riskyLeg.value();
vm.prank(makeAddr("rando"));
vault.rebalance(); // must NOT revert now: 500bps bound accommodates 3%

assertLt(riskyLeg.value(), riskyBefore); // de-risk actually executed
assertLt(riskyLeg.value() * 1e18 / vault.shareholderNav(), 0.35e18);
}

// Resetting the override (0) restores the tight default, so the same de-risk
// reverts again: the knob never permanently loosens MEV protection.
function test_l6_resetRestoresTightBound() public {
router.setTier(500, 300, false);
router.setTier(3000, 300, false);
vm.warp(block.timestamp + 2 hours);
prices.setEth(ETH0 * 60 / 100);

vm.prank(keeper);
vault.setEmergencySlippageBps(500);
vm.prank(keeper);
vault.setEmergencySlippageBps(0); // reset to EMERGENCY_SLIPPAGE_BPS

vm.prank(makeAddr("rando"));
vm.expectRevert(); // back to the tight 150bps bound
vault.rebalance();
}

function test_l6_setEmergencySlippage_accessAndBounds() public {
// only guardian or owner may set
vm.prank(makeAddr("rando"));
vm.expectRevert(CPPIVault.NotGuardian.selector);
vault.setEmergencySlippageBps(500);

// out-of-range rejected: below the tight floor, above the degraded cap
vm.prank(keeper);
vm.expectRevert(CPPIVault.SlippageOutOfRange.selector);
vault.setEmergencySlippageBps(149);
vm.prank(keeper);
vm.expectRevert(CPPIVault.SlippageOutOfRange.selector);
vault.setEmergencySlippageBps(1001);

// in-range and reset accepted
vm.prank(keeper);
vault.setEmergencySlippageBps(1000);
assertEq(vault.emergencySlippageBps(), 1000);
vm.prank(keeper);
vault.setEmergencySlippageBps(0);
assertEq(vault.emergencySlippageBps(), 0);
}
}
27 changes: 26 additions & 1 deletion test/ExecutionLayer.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ contract ExecutionLayerTest is Test {
safeLeg = new SafeLegManager(address(vault), address(usdc), 6, owner);
pt = new MockPTAdapter(address(usdc));
riskyLeg = new RiskyLegManager(address(weth), address(wsteth), owner);
exec = new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), owner);
exec = new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), 6, owner);

vm.startPrank(owner);
safeLeg.setPeriphery(IPTAdapter(address(pt)), address(exec), keeper);
Expand Down Expand Up @@ -242,4 +242,29 @@ contract ExecutionLayerTest is Test {
vm.expectRevert(ExecutionModule.NotKeeper.selector);
exec.rebalanceComposition(1e18, 50);
}

// I3: the module derives its scale and dust floor from the asset decimals
// passed at construction instead of baking in a 6-decimal (1e12) constant,
// so a non-6-decimal redeployment converts correctly.
function test_i3_scaleAndDustFloorTrackAssetDecimals() public {
ExecutionModule e6 =
new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), 6, owner);
assertEq(e6.assetScale(), 1e12);
assertEq(e6.dustFloor(), 1e6);

ExecutionModule e8 =
new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), 8, owner);
assertEq(e8.assetScale(), 1e10);
assertEq(e8.dustFloor(), 1e8);

ExecutionModule e18 =
new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), 18, owner);
assertEq(e18.assetScale(), 1);
assertEq(e18.dustFloor(), 1e18);
}

function test_i3_decimalsAbove18Revert() public {
vm.expectRevert();
new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), 19, owner);
}
}
2 changes: 1 addition & 1 deletion test/Invariants.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ contract InvariantsTest is Test {
safeLeg = new SafeLegManager(address(vault), address(usdc), 6, owner);
pt = new MockPTAdapter(address(usdc));
riskyLeg = new RiskyLegManager(address(weth), address(wsteth), owner);
exec = new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), owner);
exec = new ExecutionModule(address(vault), address(usdc), address(weth), address(wsteth), 6, owner);

vm.startPrank(owner);
vault.setController(controller);
Expand Down
Loading