From bca2a9b2a6874712d46df8c08c467570fd95869f Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Sat, 18 Jul 2026 22:40:22 +0530 Subject: [PATCH] fix(M1/M2/L4): revert-resilience for the emergency & redemption paths Groups the audit's revert-resilience mediums/lows so no PT or swap leg can brick the paths the spec calls never-blocked: - M1: SafeLegManager.provide wraps pt.withdraw in try/catch (PT that can't meet its bound reduces delivered assets, never reverts) and takes an oracle-free fast path when the request fits the buffer (a Pendle-oracle outage no longer blocks a buffer-only payout). provide is now best-effort: it delivers what's available instead of reverting. - M2: onInflow/rebalanceBuffer buy PT best-effort; a dislocated Pendle market leaves the assets in the buffer (reclaimed from the adapter) instead of reverting the caller, so a PT-buy revert can't unwind the permissionless emergency rebalance. PT-buy search window is now configurable (setApproxWindowBps) so spot-vs-TWAP divergence is tolerable without a redeploy. Adds IPTAdapter.reclaim. - L4: freeAssets self-calls the risky shortfall sale under try/catch so the safe-leg portion always delivers even when the risky sale can't clear its bound. L6's large-delta chunking is deferred (a distinct mechanism; its acute parts were handled in H6). 6 regression tests; 123 -> 127, 9 fork green. --- src/ExecutionModule.sol | 13 ++++++- src/PendlePTAdapter.sol | 20 +++++++++-- src/SafeLegManager.sol | 63 ++++++++++++++++++++++------------ src/interfaces/IPTAdapter.sol | 5 +++ test/ExecutionLayer.t.sol | 16 +++++++++ test/SafeLegManager.t.sol | 64 ++++++++++++++++++++++++++++++++--- 6 files changed, 153 insertions(+), 28 deletions(-) diff --git a/src/ExecutionModule.sol b/src/ExecutionModule.sol index 9fbb445..afc77f5 100644 --- a/src/ExecutionModule.sol +++ b/src/ExecutionModule.sol @@ -110,13 +110,24 @@ contract ExecutionModule is IExecutionModule, Ownable { uint256 shortfall = (amountWad - safeVal) * 10_250 / 10_000; uint256 riskyVal = riskyLeg.value(); if (shortfall > riskyVal) shortfall = riskyVal; - if (shortfall > 0) _sellRisky(shortfall, 150); + // Best-effort (audit L4): if the risky sale can't clear its bound, + // still deliver the safe-leg portion below rather than reverting + // the whole redemption funding. Self-call so try/catch applies. + if (shortfall > 0) { + try this.sellRiskySelf(shortfall) {} catch {} + } } uint256 available = FixedPointMathLib.min(amountWad, safeLeg.value()); safeLeg.provide(available, vault); emit AssetsFreed(available); } + /// @dev Self-call entrypoint so `freeAssets` can try/catch the risky sale. + function sellRiskySelf(uint256 deltaWad) external { + if (msg.sender != address(this)) revert NotVault(); + _sellRisky(deltaWad, 150); + } + // ---------- composition maintenance (keeper) ---------- /// @notice Move the risky leg's wstETH share toward its target, bounded diff --git a/src/PendlePTAdapter.sol b/src/PendlePTAdapter.sol index f24df93..3e98919 100644 --- a/src/PendlePTAdapter.sol +++ b/src/PendlePTAdapter.sol @@ -58,6 +58,7 @@ contract PendlePTAdapter is IPTAdapter, Ownable { address public manager; // SafeLegManager, set once uint256 public maxSlippageBps = 50; + uint256 public approxWindowBps = 1000; // PT-buy search window above expected uint256 internal constant YEAR = 365 days; @@ -111,6 +112,20 @@ contract PendlePTAdapter is IPTAdapter, Ownable { emit SlippageSet(bps); } + /// @notice Width of the PT-buy binary-search window above the expected fill + /// (bps). Wider tolerates larger spot-vs-TWAP divergence before the + /// router search range reverts (audit M2). Bounded to keep the + /// search from overflowing router math. + function setApproxWindowBps(uint256 bps) external onlyOwner { + if (bps < 100 || bps > 5000) revert BadSlippage(); + approxWindowBps = bps; + } + + /// @notice Return un-deposited deposit asset to the manager (audit M2). + function reclaim(uint256 amount, address to) external onlyManager { + asset.safeTransfer(to, amount); + } + // ---------- IPTAdapter ---------- /// @notice PT position marked at the oracle TWAP rate, WAD asset terms. @@ -204,8 +219,9 @@ contract PendlePTAdapter is IPTAdapter, Ownable { swapData: SwapData(SwapType.NONE, address(0), "", false) }); // oracle-anchored search range: the true fill lives near expectedPt, - // so a tight window converges fast and cannot overflow router math - ApproxParams memory approx = ApproxParams(minPtOut, expectedPt * 110 / 100, 0, 30, 1e14); + // so a bounded window converges fast and cannot overflow router math + uint256 guessMax = expectedPt * (10_000 + approxWindowBps) / 10_000; + ApproxParams memory approx = ApproxParams(minPtOut, guessMax, 0, 30, 1e14); (netPtOut,,) = router.swapExactTokenForPt(address(this), market, minPtOut, approx, input, _emptyLimit()); emit Deposited(assets, netPtOut); } diff --git a/src/SafeLegManager.sol b/src/SafeLegManager.sol index 5570ebf..d1307da 100644 --- a/src/SafeLegManager.sol +++ b/src/SafeLegManager.sol @@ -108,33 +108,52 @@ contract SafeLegManager is ILeg, Ownable { if (buf > target) { toPtWad = buf - target; uint256 assets = toPtWad / assetScale; - if (assets > 0) { - asset.safeTransfer(address(pt), assets); - pt.deposit(assets); - } + if (assets > 0) _buyPtBestEffort(assets); } emit Inflow(buf, buf - toPtWad, toPtWad); } - /// @notice Deliver `amountWad` of deposit asset to `to`: buffer down to - /// its minimum band first, PT for the remainder. - /// @dev PT sale proceeds can be below amount requested (duration risk / - /// slippage); the shortfall reduces delivered assets, never reverts, - /// so the emergency path cannot be blocked by PT market conditions. - function provide(uint256 amountWad, address to) external onlyRouter returns (uint256 deliveredAssets) { - if (amountWad > value()) revert InsufficientValue(); + /// @dev Move `assets` into PT, but never revert the caller if the Pendle + /// market is dislocated (audit M2): on failure the assets are pulled + /// back to the buffer. onInflow runs inside the permissionless + /// emergency rebalance, so a PT-buy revert must not unwind the + /// de-risk. Returns whether the buy succeeded. + function _buyPtBestEffort(uint256 assets) internal returns (bool) { + asset.safeTransfer(address(pt), assets); + try pt.deposit(assets) { + return true; + } catch { + pt.reclaim(assets, address(this)); + return false; + } + } + /// @notice Deliver up to `amountWad` of deposit asset to `to`: buffer down + /// to its minimum band first, PT for the remainder. Best-effort and + /// never reverts on PT conditions (audit M1). + /// @dev A request that fits the deliverable buffer takes an oracle-free + /// fast path (no pt.value() read), so a Pendle-oracle outage cannot + /// block a buffer-only payout. When PT is needed, the withdraw is + /// wrapped: if the Pendle market cannot fill within its slippage + /// bound, the buffer portion is still delivered and the shortfall + /// simply reduces `deliveredAssets`, so the emergency de-risk and + /// redemption funding are never bricked by PT market conditions. + function provide(uint256 amountWad, address to) external onlyRouter returns (uint256 deliveredAssets) { uint256 buf = bufferWad(); uint256 minBuf = _bandWad(bufferMinBps); uint256 fromBuffer = buf > minBuf ? buf - minBuf : 0; if (fromBuffer > amountWad) fromBuffer = amountWad; - // if PT cannot cover the remainder, dig into the protected buffer band + uint256 fromPt = amountWad - fromBuffer; - uint256 ptValue = pt.value(); - if (fromPt > ptValue) { - uint256 extra = fromPt - ptValue; - fromPt = ptValue; - fromBuffer = fromBuffer + extra > buf ? buf : fromBuffer + extra; + if (fromPt > 0) { + // only now touch the PT/oracle path + uint256 ptValue = pt.value(); + if (fromPt > ptValue) { + // PT cannot cover the remainder: dig into the protected band + uint256 extra = fromPt - ptValue; + fromPt = ptValue; + fromBuffer = fromBuffer + extra > buf ? buf : fromBuffer + extra; + } } if (fromBuffer > 0) { @@ -143,7 +162,11 @@ contract SafeLegManager is ILeg, Ownable { deliveredAssets = assets; } if (fromPt > 0) { - deliveredAssets += pt.withdraw(fromPt, to); + try pt.withdraw(fromPt, to) returns (uint256 got) { + deliveredAssets += got; + } catch { + // PT market could not fill within its bound; deliver buffer only + } } emit Provided(to, amountWad, fromBuffer, fromPt); } @@ -156,9 +179,7 @@ contract SafeLegManager is ILeg, Ownable { if (buf > _bandWad(bufferMaxBps)) { uint256 excess = buf - target; uint256 assets = excess / assetScale; - if (assets > 0) { - asset.safeTransfer(address(pt), assets); - pt.deposit(assets); + if (assets > 0 && _buyPtBestEffort(assets)) { emit BufferRebalanced(-int256(assets * assetScale)); } } else if (buf < _bandWad(bufferMinBps)) { diff --git a/src/interfaces/IPTAdapter.sol b/src/interfaces/IPTAdapter.sol index ed8295e..55a919a 100644 --- a/src/interfaces/IPTAdapter.sol +++ b/src/interfaces/IPTAdapter.sol @@ -17,6 +17,11 @@ interface IPTAdapter { /// Caller must have transferred the assets to the adapter first. function deposit(uint256 assets) external; + /// @notice Return up to `amount` of un-deposited deposit asset held by the + /// adapter to `to` (the manager). Used to recover funds when a PT + /// buy is skipped/failed so nothing strands (audit M2). + function reclaim(uint256 amount, address to) external; + /// @notice Sell/redeem PT worth `amountWad` and send proceeds to `to`. /// @return assetsOut deposit-asset units actually delivered function withdraw(uint256 amountWad, address to) external returns (uint256 assetsOut); diff --git a/test/ExecutionLayer.t.sol b/test/ExecutionLayer.t.sol index 51a058c..5f6f2bb 100644 --- a/test/ExecutionLayer.t.sol +++ b/test/ExecutionLayer.t.sol @@ -155,6 +155,22 @@ contract ExecutionLayerTest is Test { assertEq(usdc.balanceOf(address(vault)), 40e6); } + function test_l4_freeAssets_deliversSafePortionWhenRiskySaleReverts() public { + // safe leg holds 40 (buffer), risky leg holds WETH; ask for 60 so a + // risky shortfall sale is needed + usdc.mint(address(safeLeg), 40e6); + weth.mint(address(riskyLeg), 5e18); // 10,000 of ETH exposure + // both swap tiers disabled: the risky sale cannot clear + router.setTier(500, 0, true); + router.setTier(3000, 0, true); + + vm.prank(address(vault)); + exec.freeAssets(60e18); // must NOT revert + + // the safe-leg portion was still delivered + assertEq(usdc.balanceOf(address(vault)), 40e6); + } + function test_composition_movesTowardTarget() public { vm.prank(owner); riskyLeg.setWstethTarget(2500); // 25% diff --git a/test/SafeLegManager.t.sol b/test/SafeLegManager.t.sol index 920b617..5cf0d51 100644 --- a/test/SafeLegManager.t.sol +++ b/test/SafeLegManager.t.sol @@ -27,14 +27,36 @@ contract MockPTAdapter is IPTAdapter { haircutBps = bps; } + bool public failDeposit; + bool public failWithdraw; + bool public failValue; + + function setFailDeposit(bool v) external { + failDeposit = v; + } + + function setFailWithdraw(bool v) external { + failWithdraw = v; + } + + function setFailValue(bool v) external { + failValue = v; + } + function value() external view returns (uint256) { + require(!failValue, "oracle down"); return valueWad; } function deposit(uint256 assets) external { + require(!failDeposit, "PT market dislocated"); valueWad += assets * 1e12; } + function reclaim(uint256 amount, address to) external { + usdc.safeTransfer(to, amount); + } + /// @dev The real PT accretes toward par; this mock does not. Scenario /// tests inject that carry explicitly so maturity math is faithful. function simulateYield(uint256 amountWad) external { @@ -43,6 +65,7 @@ contract MockPTAdapter is IPTAdapter { } function withdraw(uint256 amountWad, address to) external returns (uint256 assetsOut) { + require(!failWithdraw, "PT slippage exceeded"); valueWad -= amountWad; assetsOut = (amountWad * (10_000 - haircutBps) / 10_000) / 1e12; usdc.safeTransfer(to, assetsOut); @@ -146,11 +169,44 @@ contract SafeLegManagerTest is Test { assertEq(out, 49.7e6); } - function test_provide_revertsBeyondValue() public { - _inflow(100e6); + function test_provide_beyondValue_bestEffortNoRevert() public { + _inflow(100e6); // total value 100 + // ask for more than the leg holds: delivers all available, never reverts + vm.prank(executor); + uint256 out = manager.provide(101e18, executor); + assertEq(out, 100e6); + assertEq(manager.value(), 0); + } + + // ---------- M1/M2 regression: PT reverts never brick the flow ---------- + + function test_m1_ptWithdrawRevert_deliversBufferBestEffort() public { + _inflow(100e6); // buffer 30, pt 70 + pt.setFailWithdraw(true); // PT sale reverts (can't meet its bound) + // provide must not revert; it delivers the buffer portion vm.prank(executor); - vm.expectRevert(SafeLegManager.InsufficientValue.selector); - manager.provide(101e18, executor); + uint256 out = manager.provide(50e18, executor); + assertEq(out, 20e6); // 20 from buffer (down to min); PT best-effort + } + + function test_m1_bufferOnlyPath_isOracleFree() public { + _inflow(100e6); // buffer 30, pt 70 + pt.setFailValue(true); // pt.value() (oracle) would revert + // a request that fits the deliverable buffer must not touch the oracle + vm.prank(executor); + uint256 out = manager.provide(15e18, executor); + assertEq(out, 15e6); + } + + function test_m2_onInflowPtBuyRevert_leavesAssetsInBuffer() public { + pt.setFailDeposit(true); // Pendle market dislocated + usdc.mint(address(manager), 100e6); + vm.prank(keeper); + manager.onInflow(); // must not revert + // assets stayed in the buffer instead of stranding at the adapter + assertEq(manager.bufferWad(), 100e18); + assertEq(pt.valueWad(), 0); + assertEq(manager.value(), 100e18); } function test_bufferRebalance_spillsAboveMax() public {