From 3f7d9fbd7854b6cf91a7ec9b4d7cad616b3cc795 Mon Sep 17 00:00:00 2001 From: jayesh yadav Date: Mon, 20 Jul 2026 12:02:29 +0530 Subject: [PATCH] fix(M3/L2): floor computation correctness - M3: the rate clamp is now asymmetric. Only UPWARD rate moves are bounded (a spiked rate deepens the discount and lowers the floor, the manipulation direction). A falling rate is applied immediately: a lower discount raises the floor, the conservative/better-funded direction, so it must not lag a fast PT-yield collapse (which would understate the floor and over-expose the vault). - L2: the step-ratchet trigger now evaluates against the effective (monotone-clamped) floor instead of the raw PV. A risen rate can dip raw PV below lastFloor and fire a spurious step against the too-low value; using the effective floor prevents that in both currentFloor and previewFloor. 2 regression tests (falling rate raises the floor now; risen rate fires no spurious step). 136 -> 138. --- src/CPPIController.sol | 6 +++++- src/libraries/FloorPolicy.sol | 11 +++++++++-- test/CPPIController.t.sol | 14 ++++++++++++++ test/FloorPolicy.t.sol | 18 ++++++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/CPPIController.sol b/src/CPPIController.sol index bc1f4e4..b7088a2 100644 --- a/src/CPPIController.sol +++ b/src/CPPIController.sol @@ -185,8 +185,12 @@ contract CPPIController { function _clampRate(uint256 raw) internal returns (uint256 rate) { rate = raw > MAX_RATE_WAD ? MAX_RATE_WAD : raw; uint256 last = lastRateWad; + // Asymmetric clamp (audit M3): only bound UPWARD moves. A spiked rate + // deepens the discount and lowers the floor, the manipulation + // direction, so it is rate-limited. A falling rate raises the floor + // (conservative, better-funded), so it is applied immediately rather + // than lagging behind a fast PT-yield collapse. if (rate > last + MAX_RATE_STEP_WAD) rate = last + MAX_RATE_STEP_WAD; - else if (rate + MAX_RATE_STEP_WAD < last) rate = last - MAX_RATE_STEP_WAD; lastRateWad = rate; } } diff --git a/src/libraries/FloorPolicy.sol b/src/libraries/FloorPolicy.sol index e9de0a8..599a487 100644 --- a/src/libraries/FloorPolicy.sol +++ b/src/libraries/FloorPolicy.sol @@ -88,9 +88,14 @@ library FloorPolicy { if (ratchetFloor > floor) floor = ratchetFloor; } else if (c.kind == Kind.Step) { uint256 steps; - while (steps < MAX_STEPS_PER_UPDATE && floor != 0 && navPerShare >= floor.mulWad(c.triggerWad)) { + // Trigger on the EFFECTIVE (monotone-clamped) floor, not the raw PV + // (audit L2): a risen rate can dip raw PV below lastFloor and fire a + // spurious step against the too-low raw value. + uint256 eff = floor > s.lastFloorPerShareWad ? floor : s.lastFloorPerShareWad; + while (steps < MAX_STEPS_PER_UPDATE && eff != 0 && navPerShare >= eff.mulWad(c.triggerWad)) { s.protectedPerShareWad = s.protectedPerShareWad.mulWad(c.stepWad); floor = CPPIMath.floorValue(s.protectedPerShareWad, rateWad, timeLeft); + eff = floor > s.lastFloorPerShareWad ? floor : s.lastFloorPerShareWad; unchecked { ++steps; } @@ -121,9 +126,11 @@ library FloorPolicy { } else if (c.kind == Kind.Step) { uint256 protectedPerShare = s.protectedPerShareWad; uint256 steps; - while (steps < MAX_STEPS_PER_UPDATE && floor != 0 && navPerShare >= floor.mulWad(c.triggerWad)) { + uint256 eff = floor > s.lastFloorPerShareWad ? floor : s.lastFloorPerShareWad; + while (steps < MAX_STEPS_PER_UPDATE && eff != 0 && navPerShare >= eff.mulWad(c.triggerWad)) { protectedPerShare = protectedPerShare.mulWad(c.stepWad); floor = CPPIMath.floorValue(protectedPerShare, rateWad, timeLeft); + eff = floor > s.lastFloorPerShareWad ? floor : s.lastFloorPerShareWad; unchecked { ++steps; } diff --git a/test/CPPIController.t.sol b/test/CPPIController.t.sol index 4fbc135..3c613d7 100644 --- a/test/CPPIController.t.sol +++ b/test/CPPIController.t.sol @@ -99,6 +99,20 @@ contract CPPIControllerTest is Test { vm.stopPrank(); } + // M3: a falling rate raises the floor immediately (no downward clamp lag) + function test_m3_fallingRateRaisesFloorImmediately() public { + vm.startPrank(vault); + // start term is at 4%; assess once to seed lastRate + CPPIController.Assessment memory a1 = controller.assess(NAV0, 1e18, 27e18, RATE); + // PT yield collapses to 1% in one step: floor should jump up now, not + // catch down 2% per assess (that lag under-funds the floor, audit M3) + CPPIController.Assessment memory a2 = controller.assess(NAV0, 1e18, 27e18, 0.01e18); + assertGt(a2.floor, a1.floor); + // floor at 1% discount is materially higher than a 2%-clamped step + // would give (0.04 -> 0.02): PV(90, .01) ~ 89.1 vs PV(90, .02) ~ 88.2 + assertGt(a2.floor, 89e18); + } + function test_settleTerm_flow() public { vm.startPrank(vault); vm.expectRevert(CPPIController.TermNotMatured.selector); diff --git a/test/FloorPolicy.t.sol b/test/FloorPolicy.t.sol index 63bce8e..4f47449 100644 --- a/test/FloorPolicy.t.sol +++ b/test/FloorPolicy.t.sol @@ -113,6 +113,24 @@ contract FloorPolicyTest is Test { assertLt(f1, nav); } + // L2: a risen rate dips raw PV below the monotone floor; the step trigger + // must evaluate against the EFFECTIVE floor, so no spurious step fires. + function test_l2_stepTriggerUsesEffectiveFloor() public { + FloorPolicyHarness h = new FloorPolicyHarness(cfg(FloorPolicy.Kind.Step), NAV0); + uint256 f0 = h.update(NAV0, RATE, T0); // floor ~86.5, lastFloor 86.5 + assertEq(h.stepCount(), 0); + + // rate spikes to 50%: raw PV ~ 90*e^-0.5 ~ 54.6, but effective floor is + // still 86.5 (monotone). Put NAV between rawPV*1.8 (~98) and + // effFloor*1.8 (~155.7): a raw-PV trigger would step, effective won't. + uint256 nav = 120e18; + h.update(nav, 0.5e18, T0 + 1); + assertEq(h.stepCount(), 0); // no spurious step against the too-low raw PV + // sanity: a genuine trigger above the effective floor DOES step + h.update(f0 * 181 / 100, RATE, T0 + 2); + assertEq(h.stepCount(), 1); + } + // ---------- tipp ---------- function test_tipp_tracksHighWater() public {