diff --git a/src/CPPIVault.sol b/src/CPPIVault.sol index b21e04e..34793a1 100644 --- a/src/CPPIVault.sol +++ b/src/CPPIVault.sol @@ -130,6 +130,7 @@ contract CPPIVault is ERC20, Ownable { error NotOperator(); error ClaimMismatch(); error SlippageOutOfRange(); + error NavCollapsed(); modifier onlyKeeper() { if (msg.sender != keeper && msg.sender != owner()) revert NotKeeper(); @@ -299,6 +300,15 @@ contract CPPIVault is ERC20, Ownable { if (depositsWad == 0 && redeemShares == 0) revert NothingToSettle(); uint256 price = navPerShare(); + // A collapsed NAV (shareholderNav == 0 while shares are outstanding) + // makes navPerShare 0, which is the "unsettled" sentinel for + // epochNavPerShare (and a divide-by-zero for the deposit-share mint). + // Settling here would poison the epoch: every claim/view then reads it + // as unsettled and reverts EpochNotSettled forever, permanently locking + // the requests. Refuse to settle a 0-price epoch; it becomes settleable + // again if NAV recovers above 0, and holders keep their shares/requests + // meanwhile (fairer than crystallizing a 0 payout). + if (price == 0) revert NavCollapsed(); uint64 epoch = currentEpoch; epochNavPerShare[epoch] = price; diff --git a/src/SafeLegManager.sol b/src/SafeLegManager.sol index d1307da..fbf0239 100644 --- a/src/SafeLegManager.sol +++ b/src/SafeLegManager.sol @@ -140,14 +140,21 @@ contract SafeLegManager is ILeg, Ownable { /// 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); + // The buffer-only payout must stay oracle-independent (audit M1 residual): + // _bandWad -> vault.totalNav() -> safeLeg.value() -> pt.value(), so a + // Pendle-oracle outage would otherwise brick even a buffer-coverable + // payout despite the documented "fast path". Fall back to a zero reserve + // band when totalNav is unavailable; rebalanceBuffer restores it later. + uint256 minBuf = _bandWadOrZero(bufferMinBps); uint256 fromBuffer = buf > minBuf ? buf - minBuf : 0; if (fromBuffer > amountWad) fromBuffer = amountWad; uint256 fromPt = amountWad - fromBuffer; if (fromPt > 0) { - // only now touch the PT/oracle path - uint256 ptValue = pt.value(); + // PT is needed. If its oracle is down we cannot value it; treat it + // as 0 so the deliverable buffer is still paid best-effort rather + // than reverting the whole payout. + uint256 ptValue = _ptValueOrZero(); if (fromPt > ptValue) { // PT cannot cover the remainder: dig into the protected band uint256 extra = fromPt - ptValue; @@ -198,4 +205,27 @@ contract SafeLegManager is ILeg, Ownable { function _bandWad(uint256 bps) internal view returns (uint256) { return INavSource(vault).totalNav() * bps / 10_000; } + + /// @dev Band size, but resilient to a reverting totalNav (e.g. a PT-oracle + /// outage propagating through safeLeg.value()). Used only on the + /// outbound payout path, where a zero reserve band frees the full + /// buffer rather than bricking a buffer-coverable payout (audit M1). + function _bandWadOrZero(uint256 bps) internal view returns (uint256) { + try INavSource(vault).totalNav() returns (uint256 nav) { + return nav * bps / 10_000; + } catch { + return 0; + } + } + + /// @dev pt.value() but 0 if the PT oracle read reverts, so a buffer-plus-PT + /// payout still delivers its buffer portion during an oracle outage + /// instead of reverting (audit M1). + function _ptValueOrZero() internal view returns (uint256) { + try pt.value() returns (uint256 v) { + return v; + } catch { + return 0; + } + } } diff --git a/test/ERC7540.t.sol b/test/ERC7540.t.sol index b46eae7..9769fa3 100644 --- a/test/ERC7540.t.sol +++ b/test/ERC7540.t.sol @@ -179,6 +179,36 @@ contract ERC7540Test is Test { assertEq(vault.totalReservedPayoutsWad(), 0, "reserved dust must be fully drained"); } + // G2: settling an epoch when NAV has collapsed to 0 (navPerShare == 0 while + // shares are outstanding) must revert instead of writing the 0 that doubles + // as the "unsettled" sentinel for epochNavPerShare, which would brick every + // claim/view for that epoch forever and permanently lock the requests. + function test_g2_settleRevertsWhenNavCollapsedToZero() public { + vm.prank(alice); + vault.requestDeposit(100e6); + vm.prank(keeper); + vault.settleEpoch(); + vm.prank(alice); + vault.claimShares(); + assertEq(vault.balanceOf(alice), 100e18); + + // queue a full redeem: shares lock in custody, totalSupply stays 100e18 + vm.prank(alice); + vault.requestRedeem(100e18); + + // simulate total loss: drain the vault's idle USDC so totalNav -> 0 and + // navPerShare -> 0 with shares still outstanding + uint256 bal = usdc.balanceOf(address(vault)); + vm.prank(address(vault)); + usdc.transfer(address(0xdead), bal); + assertEq(vault.totalNav(), 0); + assertEq(vault.navPerShare(), 0); + + vm.prank(keeper); + vm.expectRevert(CPPIVault.NavCollapsed.selector); + vault.settleEpoch(); + } + function test_deposit_fullClaimOnly() public { vm.prank(alice); vault.requestDeposit(100e6, alice, alice); diff --git a/test/SafeLegManager.t.sol b/test/SafeLegManager.t.sol index 5cf0d51..f8d59df 100644 --- a/test/SafeLegManager.t.sol +++ b/test/SafeLegManager.t.sol @@ -82,6 +82,22 @@ contract MockNavVault { } } +/// @dev Production-shaped NAV source: totalNav chains through the leg's own +/// value() (buffer + pt.value()), so a PT-oracle outage propagates into +/// band sizing exactly as CPPIVault.totalNav() does. Used to expose the +/// M1 residual that the fixed-value MockNavVault masks. +contract MockNavVaultLive { + SafeLegManager public leg; + + function setLeg(SafeLegManager l) external { + leg = l; + } + + function totalNav() external view returns (uint256) { + return leg.value(); + } +} + contract SafeLegManagerTest is Test { SafeLegManager manager; MockPTAdapter pt; @@ -198,6 +214,33 @@ contract SafeLegManagerTest is Test { assertEq(out, 15e6); } + // G1 (M1 residual): the test above passes only because MockNavVault severs + // the totalNav -> safeLeg.value -> pt.value chain that exists in production. + // With a production-shaped NAV source, a PT-oracle outage makes totalNav() + // (and thus the band read in provide) revert. A buffer-coverable payout must + // still succeed. + function test_g1_bufferPayoutSurvivesTotalNavOracleOutage() public { + MockNavVaultLive liveVault = new MockNavVaultLive(); + SafeLegManager legL = new SafeLegManager(address(liveVault), address(usdc), 6, owner); + liveVault.setLeg(legL); + MockPTAdapter ptL = new MockPTAdapter(address(usdc)); + vm.prank(owner); + legL.setPeriphery(IPTAdapter(address(ptL)), executor, keeper); + + // 1000 in -> ~3% buffer (30) / PT (970); buffer covers the 15 payout + usdc.mint(address(legL), 1000e6); + vm.prank(keeper); + legL.onInflow(); + assertGt(legL.bufferWad(), 15e18); + + // PT oracle down: leg.value() and thus liveVault.totalNav() now revert + ptL.setFailValue(true); + + vm.prank(executor); + uint256 out = legL.provide(15e18, executor); // must NOT brick on the band read + assertEq(out, 15e6); + } + function test_m2_onInflowPtBuyRevert_leavesAssetsInBuffer() public { pt.setFailDeposit(true); // Pendle market dislocated usdc.mint(address(manager), 100e6);