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
10 changes: 10 additions & 0 deletions src/CPPIVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;

Expand Down
36 changes: 33 additions & 3 deletions src/SafeLegManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
}
30 changes: 30 additions & 0 deletions test/ERC7540.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
43 changes: 43 additions & 0 deletions test/SafeLegManager.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down