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
51 changes: 40 additions & 11 deletions src/CPPIController.sol
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol";
import {CPPIMath} from "./libraries/CPPIMath.sol";
import {FloorPolicy} from "./libraries/FloorPolicy.sol";
import {RebalancePolicy} from "./libraries/RebalancePolicy.sol";
Expand All @@ -15,6 +16,7 @@ import {RebalancePolicy} from "./libraries/RebalancePolicy.sol";
/// per spec section 5.
contract CPPIController {
using FloorPolicy for FloorPolicy.State;
using FixedPointMathLib for uint256;

uint256 public immutable multiplierWad;
address public immutable vault;
Expand All @@ -27,6 +29,11 @@ contract CPPIController {
uint64 public termNumber;
bool public termActive;

/// @dev Last aggregate floor (floorPerShare * supply) computed by assess();
/// a convenience for the vault, dashboards, and invariant checks. The
/// authoritative floor is per-share, in floorState (audit H3).
uint256 public lastFloorAggregate;

uint256 internal constant MAX_RATE_WAD = 0.2e18;
uint256 internal constant MAX_RATE_STEP_WAD = 0.02e18;
uint256 internal lastRateWad;
Expand All @@ -41,6 +48,7 @@ contract CPPIController {
error TermNotActive();
error TermStillActive();
error TermNotMatured();
error ZeroSupply();

modifier onlyVault() {
if (msg.sender != vault) revert NotVault();
Expand All @@ -65,25 +73,31 @@ contract CPPIController {

// ---------- term lifecycle ----------

function startTerm(uint64 termStart, uint64 termEnd, uint256 nav) external onlyVault {
/// @param nav aggregate shareholder NAV at term start
/// @param supply total share supply at term start (protection is per-share)
function startTerm(uint64 termStart, uint64 termEnd, uint256 nav, uint256 supply) external onlyVault {
if (termActive) revert TermStillActive();
if (supply == 0) revert ZeroSupply();
floorConfig.termStart = termStart;
floorConfig.termEnd = termEnd;
FloorPolicy.validate(floorConfig);
FloorPolicy.initialize(floorState, floorConfig, nav);
FloorPolicy.initialize(floorState, floorConfig, nav.divWad(supply));
termActive = true;
lastRebalanceAt = 0;
unchecked {
++termNumber;
}
emit TermStarted(termNumber, termStart, termEnd, nav, floorState.protectedAmount);
emit TermStarted(termNumber, termStart, termEnd, nav, floorState.protectedPerShareWad.mulWad(supply));
}

/// @notice Settle a matured term. Emits realized shortfall (target: zero).
function settleTerm(uint256 nav) external onlyVault returns (uint256 shortfall) {
/// @dev Protection is per-share: the aggregate protected amount is
/// reconstructed from the current supply, so mid-term mint/burn can
/// no longer desync it from the promise (audit H3).
function settleTerm(uint256 nav, uint256 supply) external onlyVault returns (uint256 shortfall) {
if (!termActive) revert TermNotActive();
if (block.timestamp < floorConfig.termEnd) revert TermNotMatured();
uint256 protectedAmount = floorState.protectedAmount;
uint256 protectedAmount = floorState.protectedPerShareWad.mulWad(supply);
shortfall = nav < protectedAmount ? protectedAmount - nav : 0;
termActive = false;
emit TermSettled(termNumber, nav, protectedAmount, shortfall);
Expand All @@ -103,14 +117,22 @@ contract CPPIController {
/// @notice Compute floor, target exposure, and which trigger (if any) may
/// fire now. State-mutating: applies ratchet transitions and the
/// monotone clamp.
function assess(uint256 nav, uint256 riskyValue, uint256 rawRateWad)
/// @param nav aggregate shareholder NAV
/// @param supply total share supply (the per-share floor scales to it)
function assess(uint256 nav, uint256 supply, uint256 riskyValue, uint256 rawRateWad)
external
onlyVault
returns (Assessment memory a)
{
if (!termActive) revert TermNotActive();
if (supply == 0) revert ZeroSupply();
uint256 rate = _clampRate(rawRateWad);
a.floor = floorState.currentFloor(floorConfig, nav, rate, block.timestamp);
uint256 navPerShare = nav.divWad(supply);
// per-share floor is supply-invariant; reconstruct the aggregate for
// the cushion/target/drift math the vault operates on
uint256 floorPerShare = floorState.currentFloor(floorConfig, navPerShare, rate, block.timestamp);
a.floor = floorPerShare.mulWad(supply);
lastFloorAggregate = a.floor;
a.cushion = CPPIMath.cushion(nav, a.floor);
a.targetRisky = CPPIMath.targetRisky(nav, a.floor, multiplierWad);
a.driftBps = CPPIMath.driftBps(riskyValue, a.targetRisky, nav);
Expand All @@ -126,13 +148,20 @@ contract CPPIController {

// ---------- views ----------

function protectedAmount() external view returns (uint256) {
return floorState.protectedAmount;
/// @notice Per-share protected amount (the authoritative floor state).
function protectedPerShareWad() external view returns (uint256) {
return floorState.protectedPerShareWad;
}

/// @notice Aggregate protected amount at a given supply (per-share * supply).
function protectedAmount(uint256 supply) external view returns (uint256) {
return floorState.protectedPerShareWad.mulWad(supply);
}

/// @notice Last computed floor (monotone within a term); invariant hook.
/// @notice Last aggregate floor from assess() (monotone within a term
/// at fixed supply); convenience for dashboards / invariant hooks.
function lastFloor() external view returns (uint256) {
return floorState.lastFloor;
return lastFloorAggregate;
}

function maxSurvivableGapBps() external view returns (uint256) {
Expand Down
12 changes: 8 additions & 4 deletions src/CPPIVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -379,14 +379,17 @@ contract CPPIVault is ERC20, Ownable {
// ---------- term lifecycle ----------

function startTerm(uint64 duration) external onlyKeeper {
controller.startTerm(uint64(block.timestamp), uint64(block.timestamp) + duration, shareholderNav());
controller.startTerm(
uint64(block.timestamp), uint64(block.timestamp) + duration, shareholderNav(), totalSupply()
);
}

function settleTerm() external onlyKeeper returns (uint256 shortfall) {
_accrueManagementFee();
uint256 protectedAmount = controller.protectedAmount();
uint256 supply = totalSupply();
uint256 protectedAmount = controller.protectedAmount(supply);
uint256 nav = shareholderNav();
shortfall = controller.settleTerm(nav);
shortfall = controller.settleTerm(nav, supply);
if (performanceFeeBps > 0 && feeRecipient != address(0) && nav > protectedAmount) {
uint256 gainWad = nav - protectedAmount;
uint256 feeWad = gainWad * performanceFeeBps / 10_000;
Expand All @@ -402,7 +405,8 @@ contract CPPIVault is ERC20, Ownable {
/// keeper-gated; Emergency is permissionless and works while
/// paused (spec invariant 5).
function rebalance() external returns (RebalancePolicy.Trigger trigger) {
CPPIController.Assessment memory a = controller.assess(shareholderNav(), riskyLeg.value(), rateOracle.rateWad());
CPPIController.Assessment memory a =
controller.assess(shareholderNav(), totalSupply(), riskyLeg.value(), rateOracle.rateWad());
trigger = a.trigger;
if (trigger == RebalancePolicy.Trigger.None) revert NoTrigger();
if (trigger == RebalancePolicy.Trigger.Scheduled && msg.sender != keeper && msg.sender != owner()) {
Expand Down
80 changes: 42 additions & 38 deletions src/libraries/FloorPolicy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import {CPPIMath} from "./CPPIMath.sol";
/// @title FloorPolicy
/// @notice The floor-policy family for a CPPI vault: Fixed, Step ratchet, and
/// TIPP continuous ratchet, expressed as one floor function.
/// @dev floor = max(PV(protectedAmount, liveRate, timeLeft), policy term),
/// clamped monotone non-decreasing within a term. The Step policy raises
/// protectedAmount itself by k whenever NAV reaches T x floor, so the
/// ratchet survives rate changes and accretion consistently. All values
/// WAD unless noted.
/// @dev The floor is stored and computed PER SHARE, not as a whole-vault
/// aggregate (audit H3). Capital protection is a per-share promise, and
/// the vault's share supply changes mid-term via epoch settlement; a
/// per-share floor is invariant to those mint/burn flows, so a mid-term
/// deposit or redemption can no longer dilute existing holders' floor or
/// manufacture a phantom shortfall. Callers reconstruct the aggregate
/// floor as floorPerShare * totalSupply. All values WAD.
/// floorPerShare = max(PV(protectedPerShare, liveRate, timeLeft), policy),
/// clamped monotone non-decreasing within a term.
library FloorPolicy {
using FixedPointMathLib for uint256;

Expand All @@ -25,21 +29,21 @@ library FloorPolicy {
Kind kind;
uint64 termStart;
uint64 termEnd;
uint256 protectionWad; // P: fraction of term-start NAV promised at maturity
uint256 triggerWad; // T (Step only): step fires when nav >= T x floor
uint256 stepWad; // k (Step only): protectedAmount multiplier per step
uint256 ratchetWad; // TIPP only: floor >= ratchet x high-water NAV
uint256 protectionWad; // P: fraction of term-start navPerShare promised at maturity
uint256 triggerWad; // T (Step only): step fires when navPerShare >= T x floorPerShare
uint256 stepWad; // k (Step only): protectedPerShare multiplier per step
uint256 ratchetWad; // TIPP only: floorPerShare >= ratchet x high-water navPerShare
}

struct State {
uint256 protectedAmount; // absolute asset terms; Step raises this
uint256 hwmNav; // TIPP high-water NAV
uint256 lastFloor; // monotonicity clamp
uint256 protectedPerShareWad; // per-share protected amount; Step raises this
uint256 hwmNavPerShareWad; // TIPP high-water navPerShare
uint256 lastFloorPerShareWad; // monotonicity clamp (per share)
uint32 stepCount;
}

/// @dev Bounds one update's step loop; with k >= MIN_STEP a gap large
/// enough to exhaust this cap cannot occur without NAV growing
/// enough to exhaust this cap cannot occur without navPerShare growing
/// >= MIN_STEP^MAX_STEPS x floor in a single update.
uint256 internal constant MAX_STEPS_PER_UPDATE = 10;
uint256 internal constant MIN_STEP = 1.05e18;
Expand All @@ -60,33 +64,33 @@ library FloorPolicy {
}
}

function initialize(State storage s, Config memory c, uint256 termStartNav) internal {
s.protectedAmount = termStartNav.mulWad(c.protectionWad);
s.hwmNav = termStartNav;
s.lastFloor = 0;
function initialize(State storage s, Config memory c, uint256 termStartNavPerShare) internal {
s.protectedPerShareWad = termStartNavPerShare.mulWad(c.protectionWad);
s.hwmNavPerShareWad = termStartNavPerShare;
s.lastFloorPerShareWad = 0;
s.stepCount = 0;
}

/// @notice Compute the current floor and apply ratchet state transitions.
/// @param nav current vault NAV in asset terms
/// @notice Compute the current per-share floor and apply ratchet transitions.
/// @param navPerShare current vault NAV per share, WAD
/// @param rateWad live PT-implied yield (caller clamps per spec section 5)
/// @param nowTs current timestamp
function currentFloor(State storage s, Config memory c, uint256 nav, uint256 rateWad, uint256 nowTs)
function currentFloor(State storage s, Config memory c, uint256 navPerShare, uint256 rateWad, uint256 nowTs)
internal
returns (uint256 floor)
{
uint256 timeLeft = c.termEnd > nowTs ? c.termEnd - nowTs : 0;
floor = CPPIMath.floorValue(s.protectedAmount, rateWad, timeLeft);
floor = CPPIMath.floorValue(s.protectedPerShareWad, rateWad, timeLeft);

if (c.kind == Kind.Tipp) {
if (nav > s.hwmNav) s.hwmNav = nav;
uint256 ratchetFloor = s.hwmNav.mulWad(c.ratchetWad);
if (navPerShare > s.hwmNavPerShareWad) s.hwmNavPerShareWad = navPerShare;
uint256 ratchetFloor = s.hwmNavPerShareWad.mulWad(c.ratchetWad);
if (ratchetFloor > floor) floor = ratchetFloor;
} else if (c.kind == Kind.Step) {
uint256 steps;
while (steps < MAX_STEPS_PER_UPDATE && floor != 0 && nav >= floor.mulWad(c.triggerWad)) {
s.protectedAmount = s.protectedAmount.mulWad(c.stepWad);
floor = CPPIMath.floorValue(s.protectedAmount, rateWad, timeLeft);
while (steps < MAX_STEPS_PER_UPDATE && floor != 0 && navPerShare >= floor.mulWad(c.triggerWad)) {
s.protectedPerShareWad = s.protectedPerShareWad.mulWad(c.stepWad);
floor = CPPIMath.floorValue(s.protectedPerShareWad, rateWad, timeLeft);
unchecked {
++steps;
}
Expand All @@ -95,36 +99,36 @@ library FloorPolicy {
}

// Monotone within the term: rate moves and policy math may only raise it.
if (floor < s.lastFloor) {
floor = s.lastFloor;
if (floor < s.lastFloorPerShareWad) {
floor = s.lastFloorPerShareWad;
} else {
s.lastFloor = floor;
s.lastFloorPerShareWad = floor;
}
}

/// @notice View variant: what the floor would be without state transitions.
function previewFloor(State memory s, Config memory c, uint256 nav, uint256 rateWad, uint256 nowTs)
/// @notice View variant: the per-share floor without state transitions.
function previewFloor(State memory s, Config memory c, uint256 navPerShare, uint256 rateWad, uint256 nowTs)
internal
pure
returns (uint256 floor)
{
uint256 timeLeft = c.termEnd > nowTs ? c.termEnd - nowTs : 0;
floor = CPPIMath.floorValue(s.protectedAmount, rateWad, timeLeft);
floor = CPPIMath.floorValue(s.protectedPerShareWad, rateWad, timeLeft);
if (c.kind == Kind.Tipp) {
uint256 hwm = nav > s.hwmNav ? nav : s.hwmNav;
uint256 hwm = navPerShare > s.hwmNavPerShareWad ? navPerShare : s.hwmNavPerShareWad;
uint256 ratchetFloor = hwm.mulWad(c.ratchetWad);
if (ratchetFloor > floor) floor = ratchetFloor;
} else if (c.kind == Kind.Step) {
uint256 protectedAmount = s.protectedAmount;
uint256 protectedPerShare = s.protectedPerShareWad;
uint256 steps;
while (steps < MAX_STEPS_PER_UPDATE && floor != 0 && nav >= floor.mulWad(c.triggerWad)) {
protectedAmount = protectedAmount.mulWad(c.stepWad);
floor = CPPIMath.floorValue(protectedAmount, rateWad, timeLeft);
while (steps < MAX_STEPS_PER_UPDATE && floor != 0 && navPerShare >= floor.mulWad(c.triggerWad)) {
protectedPerShare = protectedPerShare.mulWad(c.stepWad);
floor = CPPIMath.floorValue(protectedPerShare, rateWad, timeLeft);
unchecked {
++steps;
}
}
}
if (floor < s.lastFloor) floor = s.lastFloor;
if (floor < s.lastFloorPerShareWad) floor = s.lastFloorPerShareWad;
}
}
Loading