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
6 changes: 5 additions & 1 deletion src/CPPIController.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
11 changes: 9 additions & 2 deletions src/libraries/FloorPolicy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
14 changes: 14 additions & 0 deletions test/CPPIController.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 18 additions & 0 deletions test/FloorPolicy.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down