From 330033a33f5519dcba4b0edf200106a9ace42372 Mon Sep 17 00:00:00 2001 From: Jayesh Yadav Date: Mon, 20 Jul 2026 18:11:45 +0530 Subject: [PATCH] fix(L6/I2/I3): emergency-bound escape hatch, oracle prepare, param scale L6 (emergency de-risk reverts under thin/attacker-thinned liquidity): the permissionless de-risk swaps once at 98.5% of oracle-fair with no admin knob; a >1.5% pool dislocation reverts both fee tiers. Add a guardian/owner-settable emergencySlippageBps that widens ONLY the healthy-oracle emergency bound, capped within [EMERGENCY_SLIPPAGE_BPS, EMERGENCY_DEGRADED_SLIPPAGE_BPS] and resettable to the tight default (0). The scheduled and degraded bounds are untouched, and the widening is a deliberate trade of more single-swap sandwich exposure (L7) for guaranteed execution during a declared dislocation. Correct the overstated "atomic" invariant comment. Full chunked/partial-fill execution is deliberately not added: rebalance() is re-callable every block and self-heals as arbitrage re-aligns the pool, so it is disproportionate to a self-healing Low. I2 (dead oracle cardinality self-heal): _bindMarket issued increaseObservationsCardinalityNext then reverted OracleNotReady in the same tx on a cold oracle, rolling the bump back so it never persisted. Add a standalone, non-reverting prepareMarket(market) to warm a market's TWAP window ahead of the roll; clarify the _bindMarket comment. I3 (hardcoded USDC_SCALE = 1e12): the vault, safe leg and PT adapter all parameterize assetDecimals but ExecutionModule baked in 1e12, a latent redeployment footgun for any non-6-decimal asset. Derive assetScale and the dust floor from an assetDecimals constructor arg (public immutables). Updated all six construction sites (6 for USDC). Tests (+8, 149 total): three prepareMarket cases with minimal Pendle mocks (persists bump / bind rolls it back / owner-only); scale+dust-floor derivation for 6/8/18 decimals and >18 revert; guardian-widened bound clears the thin- liquidity de-risk that reverts at 150bps, reset restores the tight bound, plus setter access/bounds. --- script/Deploy.s.sol | 2 +- src/CPPIVault.sol | 38 +++++++- src/ExecutionModule.sol | 34 ++++--- src/PendlePTAdapter.sol | 21 +++- test/CrashScenarios.t.sol | 67 ++++++++++++- test/ExecutionLayer.t.sol | 27 +++++- test/Invariants.t.sol | 2 +- test/PendlePTAdapterPrepare.t.sol | 137 +++++++++++++++++++++++++++ test/VaultIntegration.t.sol | 2 +- test/fork/ExecutionModule.fork.t.sol | 2 +- 10 files changed, 312 insertions(+), 20 deletions(-) create mode 100644 test/PendlePTAdapterPrepare.t.sol diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index 2d50e2c..d849696 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -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); } diff --git a/src/CPPIVault.sol b/src/CPPIVault.sol index ac581dd..b21e04e 100644 --- a/src/CPPIVault.sol +++ b/src/CPPIVault.sol @@ -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 { @@ -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(); @@ -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(); @@ -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. @@ -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; } diff --git a/src/ExecutionModule.sol b/src/ExecutionModule.sol index a753557..346c010 100644 --- a/src/ExecutionModule.sol +++ b/src/ExecutionModule.sol @@ -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 @@ -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; @@ -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_); } @@ -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) { @@ -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); } @@ -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); @@ -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(); } @@ -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; diff --git a/src/PendlePTAdapter.sol b/src/PendlePTAdapter.sol index 3e98919..b021734 100644 --- a/src/PendlePTAdapter.sol +++ b/src/PendlePTAdapter.sol @@ -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(); @@ -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 { @@ -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(); diff --git a/test/CrashScenarios.t.sol b/test/CrashScenarios.t.sol index c5a3b8f..166ba62 100644 --- a/test/CrashScenarios.t.sol +++ b/test/CrashScenarios.t.sol @@ -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); @@ -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); + } } diff --git a/test/ExecutionLayer.t.sol b/test/ExecutionLayer.t.sol index 08a9ac5..3bb7aa2 100644 --- a/test/ExecutionLayer.t.sol +++ b/test/ExecutionLayer.t.sol @@ -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); @@ -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); + } } diff --git a/test/Invariants.t.sol b/test/Invariants.t.sol index 20b357a..63ca3d7 100644 --- a/test/Invariants.t.sol +++ b/test/Invariants.t.sol @@ -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); diff --git a/test/PendlePTAdapterPrepare.t.sol b/test/PendlePTAdapterPrepare.t.sol new file mode 100644 index 0000000..c16c440 --- /dev/null +++ b/test/PendlePTAdapterPrepare.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {PendlePTAdapter} from "../src/PendlePTAdapter.sol"; +import {IPendleMarket, IStandardizedYield, IPendlePYLpOracle} from "../src/interfaces/pendle/IPendle.sol"; +import {MockUSDC} from "./mocks/Mocks.sol"; +import {Mock18} from "./mocks/ExecutionMocks.sol"; + +// ---- minimal Pendle mocks (audit I2 regression) ---- + +contract MockSY is IStandardizedYield { + function isValidTokenIn(address) external pure returns (bool) { + return true; + } + + function isValidTokenOut(address) external pure returns (bool) { + return true; + } +} + +contract MockPendleMarket is IPendleMarket { + address public sy; + address public pt; + address public yt; + uint256 public expiryTs; + uint16 public lastCardinality; + uint256 public cardinalityCalls; + + constructor(address sy_, address pt_, address yt_, uint256 expiry_) { + sy = sy_; + pt = pt_; + yt = yt_; + expiryTs = expiry_; + } + + function readTokens() external view returns (address, address, address) { + return (sy, pt, yt); + } + + function increaseObservationsCardinalityNext(uint16 cardinalityNext) external { + lastCardinality = cardinalityNext; + cardinalityCalls++; + } + + function expiry() external view returns (uint256) { + return expiryTs; + } + + function isExpired() external view returns (bool) { + return block.timestamp >= expiryTs; + } +} + +contract MockPtOracle is IPendlePYLpOracle { + struct State { + bool increaseRequired; + uint16 cardinalityRequired; + bool oldestSatisfied; + } + + mapping(address => State) internal _state; + + function set(address market, bool increaseRequired, uint16 cardinalityRequired, bool oldestSatisfied) external { + _state[market] = State(increaseRequired, cardinalityRequired, oldestSatisfied); + } + + function getOracleState(address market, uint32) external view returns (bool, uint16, bool) { + State memory s = _state[market]; + return (s.increaseRequired, s.cardinalityRequired, s.oldestSatisfied); + } + + function getPtToAssetRate(address, uint32) external pure returns (uint256) { + return 1e18; + } +} + +contract PendlePTAdapterPrepareTest is Test { + PendlePTAdapter adapter; + MockUSDC usdc; + MockPtOracle oracle; + MockPendleMarket readyMarket; + MockPendleMarket coldMarket; + + address owner = makeAddr("owner"); + address manager = makeAddr("manager"); + address router = makeAddr("router"); // only cast/approved, never called here + + function setUp() public { + usdc = new MockUSDC(); + MockSY sy = new MockSY(); + Mock18 pt = new Mock18("PT"); + oracle = new MockPtOracle(); + + uint256 exp = block.timestamp + 180 days; + readyMarket = new MockPendleMarket(address(sy), address(pt), makeAddr("yt"), exp); + coldMarket = new MockPendleMarket(address(sy), address(pt), makeAddr("yt2"), exp); + + // the initial (bound) market's oracle is warm so the constructor binds + oracle.set(address(readyMarket), false, 0, true); + // the target market is cold: needs a cardinality bump and its TWAP + // window is not yet satisfied + oracle.set(address(coldMarket), true, 200, false); + + adapter = new PendlePTAdapter( + router, address(oracle), address(readyMarket), address(usdc), address(usdc), 6, 900, owner + ); + vm.prank(owner); + adapter.setManager(manager); + } + + // I2: prepareMarket issues the cardinality increase and does NOT revert on a + // cold oracle, so the bump persists (unlike the _bindMarket path below). + function test_i2_prepareMarketPersistsCardinalityBump() public { + assertEq(coldMarket.cardinalityCalls(), 0); + vm.prank(owner); + adapter.prepareMarket(address(coldMarket)); + assertEq(coldMarket.cardinalityCalls(), 1, "bump issued"); + assertEq(coldMarket.lastCardinality(), 200, "bump used the required cardinality"); + } + + // Contrast: binding the cold market via rollToMarket reverts OracleNotReady, + // which rolls back the same-tx cardinality bump (the reason prepareMarket + // exists). No deposits, so there is nothing to exit first. + function test_i2_bindRollsBackBumpWhenOracleCold() public { + vm.prank(manager); + vm.expectRevert(PendlePTAdapter.OracleNotReady.selector); + adapter.rollToMarket(address(coldMarket)); + assertEq(coldMarket.cardinalityCalls(), 0, "bump rolled back with the revert"); + } + + function test_i2_prepareMarket_ownerOnly() public { + vm.prank(makeAddr("rando")); + vm.expectRevert(); + adapter.prepareMarket(address(coldMarket)); + } +} diff --git a/test/VaultIntegration.t.sol b/test/VaultIntegration.t.sol index 3b49dbd..518c653 100644 --- a/test/VaultIntegration.t.sol +++ b/test/VaultIntegration.t.sol @@ -81,7 +81,7 @@ contract VaultIntegrationTest 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); diff --git a/test/fork/ExecutionModule.fork.t.sol b/test/fork/ExecutionModule.fork.t.sol index 4cc166b..2cee953 100644 --- a/test/fork/ExecutionModule.fork.t.sol +++ b/test/fork/ExecutionModule.fork.t.sol @@ -68,7 +68,7 @@ contract ExecutionModuleForkTest is Test { pt = new MockPTAdapter(USDC); safeLeg = new SafeLegManager(address(vaultStub), USDC, 6, address(this)); riskyLeg = new RiskyLegManager(WETH, WSTETH, address(this)); - exec = new ExecutionModule(address(vaultStub), USDC, WETH, WSTETH, address(this)); + exec = new ExecutionModule(address(vaultStub), USDC, WETH, WSTETH, 6, address(this)); safeLeg.setPeriphery(IPTAdapter(address(pt)), address(exec), keeper); riskyLeg.setPeriphery(IPriceSource(address(prices)), address(exec), keeper);