diff --git a/src/CPPIController.sol b/src/CPPIController.sol index b7088a2..f2347ed 100644 --- a/src/CPPIController.sol +++ b/src/CPPIController.sol @@ -18,15 +18,36 @@ contract CPPIController { using FloorPolicy for FloorPolicy.State; using FixedPointMathLib for uint256; + // ============================================================================ + // Configuration and state + // ============================================================================ + + /// @notice The CPPI multiplier, in WAD. Immutable and shared by every class; + /// it alone controls breach risk (target risky = multiplier * cushion). uint256 public immutable multiplierWad; + + /// @notice The vault contract authorized to drive this controller's state machine. address public immutable vault; + /// @dev Floor policy parameters (glide path, protected ratio, term bounds), + /// fixed at construction and re-stamped with each term's start/end. FloorPolicy.Config internal floorConfig; + + /// @dev Rebalance policy parameters (drift/cushion thresholds, min interval) + /// used to classify which trigger, if any, may fire. RebalancePolicy.Config internal rebalConfig; + + /// @dev Live floor state (per-share protected amount and ratchet state) for + /// the active term. The authoritative floor is stored per-share here. FloorPolicy.State internal floorState; + /// @notice Timestamp of the last recorded rebalance, gating the next one via minInterval. uint64 public lastRebalanceAt; + + /// @notice The current term counter, incremented on each startTerm. uint64 public termNumber; + + /// @notice Whether a term is currently active (between startTerm and settleTerm). bool public termActive; /// @dev Last aggregate floor (floorPerShare * supply) computed by assess(); @@ -34,32 +55,81 @@ contract CPPIController { /// authoritative floor is per-share, in floorState (audit H3). uint256 public lastFloorAggregate; + /// @dev Hard ceiling on the accepted PT-implied yield, in WAD, clamping the + /// rate input before it feeds the floor computation. uint256 internal constant MAX_RATE_WAD = 0.2e18; + + /// @dev Maximum accepted upward change in the rate per update, in WAD; bounds + /// floor manipulation via a spiked PT-implied-yield input (audit M3). uint256 internal constant MAX_RATE_STEP_WAD = 0.02e18; + + /// @dev Last clamped rate applied, in WAD; the anchor the per-update step is + /// measured against. Seeded at construction. uint256 internal lastRateWad; /// @dev Anti-churn floor on term length (audit H2). Well below the 12-month /// product term; exists so a compromised keeper cannot loop tiny terms. uint64 public constant MIN_TERM_DURATION = 7 days; + // ============================================================================ + // Events and errors + // ============================================================================ + + /// @notice Emitted when a new term is started. + /// @param termNumber The term counter after incrementing. + /// @param termStart The term's start timestamp. + /// @param termEnd The term's maturity timestamp. + /// @param nav Aggregate shareholder NAV at term start, in WAD. + /// @param protectedAmount Aggregate protected amount at term start (per-share * supply), in WAD. event TermStarted( uint64 indexed termNumber, uint64 termStart, uint64 termEnd, uint256 nav, uint256 protectedAmount ); + + /// @notice Emitted when a matured term is settled. + /// @param termNumber The term counter of the settled term. + /// @param nav Aggregate shareholder NAV at settlement, in WAD. + /// @param protectedAmount Aggregate protected amount reconstructed at settlement, in WAD. + /// @param shortfall Realized shortfall below the protected amount (target: zero), in WAD. event TermSettled(uint64 indexed termNumber, uint256 nav, uint256 protectedAmount, uint256 shortfall); + + /// @notice Emitted when an executed rebalance is recorded. + /// @param termNumber The term counter the rebalance belongs to. + /// @param trigger Which trigger fired (Scheduled or Emergency). + /// @param floor The aggregate floor the assessment was taken against, in WAD. + /// @param target The target risky exposure, in WAD. event RebalanceRecorded(uint64 indexed termNumber, RebalancePolicy.Trigger trigger, uint256 floor, uint256 target); + /// @notice Caller is not the wired vault. error NotVault(); + + /// @notice An operation requiring an active term was attempted while none is active. error TermNotActive(); + + /// @notice startTerm was called while a term is still active. error TermStillActive(); + + /// @notice settleTerm was called before the term reached maturity. error TermNotMatured(); + + /// @notice An operation was attempted at zero share supply (protection is per-share). error ZeroSupply(); + + /// @notice A term shorter than MIN_TERM_DURATION was requested (audit H2). error TermTooShort(); + /// @notice Restricts a function to the wired vault. + /// @dev Reverts NotVault for any other caller. modifier onlyVault() { if (msg.sender != vault) revert NotVault(); _; } + /// @notice Deploy the controller, wiring it to a vault and fixing the CPPI + /// multiplier and floor/rebalance policy parameters. + /// @param vault_ The vault authorized to drive this controller. + /// @param multiplierWad_ The CPPI multiplier, in WAD (bounded to [1e18, 4e18]). + /// @param floorConfig_ The floor policy parameters, validated at construction. + /// @param rebalConfig_ The rebalance policy parameters, validated at construction. constructor( address vault_, uint256 multiplierWad_, @@ -76,8 +146,14 @@ contract CPPIController { lastRateWad = 0.04e18; } - // ---------- term lifecycle ---------- + // ============================================================================ + // Term lifecycle + // ============================================================================ + /// @notice Start a new term: stamp the term window, initialize the per-share + /// floor from entry navPerShare, and activate the state machine. + /// @param termStart The term's start timestamp. + /// @param termEnd The term's maturity timestamp (must be at least MIN_TERM_DURATION out). /// @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 { @@ -100,6 +176,9 @@ contract CPPIController { /// @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). + /// @param nav aggregate shareholder NAV at settlement + /// @param supply total share supply at settlement (the per-share floor scales to it) + /// @return shortfall The amount NAV falls below the protected amount, or 0 if fully funded. function settleTerm(uint256 nav, uint256 supply) external onlyVault returns (uint256 shortfall) { if (!termActive) revert TermNotActive(); if (block.timestamp < floorConfig.termEnd) revert TermNotMatured(); @@ -109,8 +188,17 @@ contract CPPIController { emit TermSettled(termNumber, nav, protectedAmount, shortfall); } - // ---------- assessment ---------- + // ============================================================================ + // Assessment + // ============================================================================ + /// @notice Snapshot of the CPPI assessment the vault acts on. + /// @param floor The aggregate floor (per-share floor * supply), in WAD. + /// @param cushion The buffer of NAV above the floor, in WAD. + /// @param targetRisky The target risky-leg exposure (multiplier * cushion), in WAD. + /// @param driftBps The current risky exposure's drift from target, in basis points. + /// @param cushionBps The cushion expressed as a fraction of NAV, in basis points. + /// @param trigger Which rebalance trigger (if any) may fire now. struct Assessment { uint256 floor; uint256 cushion; @@ -125,6 +213,9 @@ contract CPPIController { /// monotone clamp. /// @param nav aggregate shareholder NAV /// @param supply total share supply (the per-share floor scales to it) + /// @param riskyValue the current risky-leg value, in WAD, measured for drift + /// @param rawRateWad the live PT-implied yield, in WAD, before clamping + /// @return a The assessment snapshot (floor, cushion, target, drift, cushion bps, trigger). function assess(uint256 nav, uint256 supply, uint256 riskyValue, uint256 rawRateWad) external onlyVault @@ -147,6 +238,9 @@ contract CPPIController { } /// @notice Record an executed rebalance (gates the next one via minInterval). + /// @param trigger Which trigger fired (Scheduled or Emergency). + /// @param floor The aggregate floor the assessment was taken against, in WAD. + /// @param target The target risky exposure that was executed toward, in WAD. function recordRebalance(RebalancePolicy.Trigger trigger, uint256 floor, uint256 target) external onlyVault { lastRebalanceAt = uint64(block.timestamp); emit RebalanceRecorded(termNumber, trigger, floor, target); @@ -155,33 +249,48 @@ contract CPPIController { // ---------- views ---------- /// @notice Per-share protected amount (the authoritative floor state). + /// @return The per-share protected amount, in WAD. function protectedPerShareWad() external view returns (uint256) { return floorState.protectedPerShareWad; } /// @notice Aggregate protected amount at a given supply (per-share * supply). + /// @param supply The share supply the per-share floor scales to. + /// @return The aggregate protected amount at that supply, in WAD. function protectedAmount(uint256 supply) external view returns (uint256) { return floorState.protectedPerShareWad.mulWad(supply); } /// @notice Last aggregate floor from assess() (monotone within a term /// at fixed supply); convenience for dashboards / invariant hooks. + /// @return The last aggregate floor computed by assess(), in WAD. function lastFloor() external view returns (uint256) { return lastFloorAggregate; } + /// @notice The largest single-step risky-leg drawdown the strategy can + /// absorb before NAV would breach the floor, a function of the + /// multiplier alone. + /// @return The maximum survivable gap, in basis points. function maxSurvivableGapBps() external view returns (uint256) { return CPPIMath.maxSurvivableGapBps(multiplierWad); } + /// @notice The active floor policy configuration (glide path, protected + /// ratio, term window). + /// @return The floor policy config struct currently in effect. function floorConfigView() external view returns (FloorPolicy.Config memory) { return floorConfig; } // ---------- internal ---------- + /// @notice Clamp the raw oracle rate to the accepted band and rate-limit + /// upward moves before it feeds the floor computation. /// @dev Clamp the oracle rate to [0, MAX_RATE] and bound per-update change, /// limiting floor manipulation via the PT-implied-yield input. + /// @param raw The live PT-implied yield, in WAD, before clamping. + /// @return rate The clamped rate applied, in WAD. function _clampRate(uint256 raw) internal returns (uint256 rate) { rate = raw > MAX_RATE_WAD ? MAX_RATE_WAD : raw; uint256 last = lastRateWad; diff --git a/src/CPPIVault.sol b/src/CPPIVault.sol index 34793a1..2bcac48 100644 --- a/src/CPPIVault.sol +++ b/src/CPPIVault.sol @@ -8,11 +8,7 @@ import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol"; import {CPPIController} from "./CPPIController.sol"; import {RebalancePolicy} from "./libraries/RebalancePolicy.sol"; import {ILeg, IExecutionModule, IRateOracle} from "./interfaces/IVaultPeriphery.sol"; - -interface IOracleHealth { - function healthy() external view returns (bool); - function prolongedStale() external view returns (bool); -} +import {IOracleHealth} from "./interfaces/IOracleHealth.sol"; /// @title CPPIVault /// @notice Term-based capital-protected vault share token with asynchronous, @@ -29,37 +25,75 @@ contract CPPIVault is ERC20, Ownable { using SafeTransferLib for address; using FixedPointMathLib for uint256; - // ---------- config ---------- + // ============================================================================ + // Configuration and state + // ============================================================================ - address public immutable asset; // deposit asset (e.g. USDC) - uint256 internal immutable assetScale; // 10^(18 - assetDecimals) + /// @notice The deposit asset (e.g. USDC) that the vault accepts. + address public immutable asset; + /// @notice Scale factor to convert the asset's native decimals to WAD (18 decimals). + uint256 internal immutable assetScale; + + /// @notice The controller that manages the CPPI strategy and term lifecycle. CPPIController public controller; + + /// @notice The safe leg: the capital-protection side, held in a Pendle PT (a + /// zero-coupon-bond-like instrument) plus a liquid asset buffer. ILeg public safeLeg; + + /// @notice The risky leg: the growth side, held as ETH exposure (WETH plus a + /// capped wstETH fraction). ILeg public riskyLeg; + + /// @notice The execution module that handles rebalancing trades. IExecutionModule public executor; + + /// @notice The rate oracle that provides the current interest rate for the CPPI strategy. IRateOracle public rateOracle; + /// @notice The keeper address that is authorized to perform certain actions on the vault. address public keeper; + + /// @notice The guardian address that can pause the vault and set emergency parameters. address public guardian; + + /// @notice Indicates whether the vault is paused, preventing new deposits and redemptions. bool public paused; - IOracleHealth public healthSource; // optional: gates NEW user flows only - - // fees: caps enforced in code, zero by default (curator loss-leader norm) - uint16 public managementFeeBps; // per year, on shareholder NAV - uint16 public performanceFeeBps; // on gains in navPerShare above the high-water mark - /// @dev Per-share high-water mark: the highest navPerShare a performance - /// fee has been charged at. The fee applies only to gains above it, - /// so flat/losing terms and re-struck short terms pay nothing (audit - /// H1/H2). Initialized at the first startTerm. + + /// @notice The health source that provides oracle health information, gating new user flows. + IOracleHealth public healthSource; + + /// @notice Management fee in basis points (bps) per year, charged on shareholder NAV. + uint16 public managementFeeBps; + + /// @notice Performance fee in basis points (bps) on gains in NAV per share above the high-water mark. + uint16 public performanceFeeBps; + + /// @notice The per-share high-water mark for the performance fee: the highest + /// navPerShare a fee has been charged at. The fee applies only to gains + /// above it, so flat/losing terms and re-struck short terms pay nothing + /// (audit H1/H2). Initialized at the first startTerm. uint256 public highWaterPerShareWad; + + /// @notice The recipient address for management and performance fees. address public feeRecipient; + + /// @notice Timestamp of the last management fee accrual, used to calculate the fee over time. uint64 public lastMgmtAccrualAt; + + /// @notice Maximum allowed management fee in basis points (bps) per year. uint16 public constant MAX_MANAGEMENT_FEE_BPS = 200; + + /// @notice Maximum allowed performance fee in basis points (bps) on gains. uint16 public constant MAX_PERFORMANCE_FEE_BPS = 2000; + /// @dev Tight bound for a keeper-gated scheduled rebalance, in basis points. uint256 internal constant SCHEDULED_SLIPPAGE_BPS = 50; + + /// @dev Tight bound used for a permissionless emergency de-risk while the oracle is healthy (audit H6). uint256 internal constant EMERGENCY_SLIPPAGE_BPS = 150; + /// @dev Wider bound used for an emergency de-risk while the oracle is /// unhealthy (audit H6). A stale feed serves a last-good price that /// lags a fast fall; the tight 150bps bound would then make the swap @@ -77,66 +111,190 @@ contract CPPIVault is ERC20, Ownable { /// resettable knob that never loosens the scheduled or degraded bounds. uint256 public emergencySlippageBps; - // ---------- async accounting ---------- + // ============================================================================ + // Async deposit/redeem state and operator model + // ============================================================================ + /// @notice Async deposit/redeem request data structure, tracking the epoch and amount in WAD. + /// @param epoch The epoch in which the request was made. + /// @param amountWad The amount of assets (for deposits) or shares (for redemptions) in WAD. struct Request { uint64 epoch; - uint192 amountWad; // deposits: asset WAD; redeems: share WAD + uint192 amountWad; } - /// @dev ERC-7540 operator model: operator may act for a controller. + /// @notice Mapping of controller addresses to operator approvals, allowing operators to act on behalf of controllers. mapping(address => mapping(address => bool)) public isOperator; + /// @notice The current epoch number, incremented with each settlement. uint64 public currentEpoch = 1; - mapping(uint64 => uint256) public epochNavPerShare; // 0 = unsettled + + /// @notice Mapping of epoch numbers to NAV per share at settlement, with 0 indicating unsettled epochs. + mapping(uint64 => uint256) public epochNavPerShare; + + /// @notice Mapping of controller addresses to their pending deposit requests. mapping(address => Request) public depositRequests; + + /// @notice Mapping of controller addresses to their pending redeem requests. mapping(address => Request) public redeemRequests; + + /// @notice Total pending deposits in WAD, representing the sum of all deposit requests not yet settled. uint256 public totalPendingDepositsWad; + + /// @notice Total pending redeem shares, representing the sum of all redeem requests not yet settled. uint256 public totalPendingRedeemShares; + + /// @notice Total reserved payouts in WAD, representing the sum of all settled but unclaimed redemptions. uint256 public totalReservedPayoutsWad; - // per-epoch redemption bookkeeping so the last claimant of an epoch drains - // the aggregate-vs-per-user rounding residue (audit I1) + + /// @notice Per-epoch reserved payout in WAD. Together with epochRedeemRemaining + /// this lets the last claimant of an epoch drain the aggregate-vs-per-user + /// rounding residue instead of leaving it frozen in shareholderNav (audit I1). mapping(uint64 => uint256) public epochReservedWad; + + /// @notice Per-epoch redeem shares still unclaimed. Reaching 0 marks the last + /// claimant, who then drains the epoch's rounding residue (audit I1). mapping(uint64 => uint256) public epochRedeemRemaining; - // ---------- events / errors ---------- + // ============================================================================ + // Events, errors, and modifiers + // ============================================================================ + /// @notice Emitted when a deposit request is queued into an epoch. + /// @param user The controller whose request slot was credited. + /// @param epoch The epoch the request settles in. + /// @param assetsWad The requested deposit amount, in WAD. event DepositRequested(address indexed user, uint64 indexed epoch, uint256 assetsWad); + + /// @notice Emitted when a redeem request is queued into an epoch. + /// @param user The controller whose request slot was credited. + /// @param epoch The epoch the request settles in. + /// @param shares The share amount locked for redemption, in WAD. event RedeemRequested(address indexed user, uint64 indexed epoch, uint256 shares); + + /// @notice Emitted when an epoch is settled at a single navPerShare. + /// @param epoch The epoch that was settled. + /// @param navPerShare The price struck for the epoch, in WAD. + /// @param depositsWad Aggregate deposits minted at settlement, in WAD. + /// @param redeemShares Aggregate shares burned at settlement, in WAD. event EpochSettled(uint64 indexed epoch, uint256 navPerShare, uint256 depositsWad, uint256 redeemShares); + + /// @notice Emitted when a settled deposit is claimed for its shares. + /// @param user The controller whose deposit was claimed. + /// @param epoch The epoch the deposit settled in. + /// @param shares The shares delivered, in WAD. event SharesClaimed(address indexed user, uint64 indexed epoch, uint256 shares); + + /// @notice Emitted when a settled redemption is claimed for its assets. + /// @param user The controller whose redemption was claimed. + /// @param epoch The epoch the redemption settled in. + /// @param assetsWad The payout, in WAD. event AssetsClaimed(address indexed user, uint64 indexed epoch, uint256 assetsWad); + + /// @notice Emitted after a rebalance moves the risky leg toward its target. + /// @param trigger Which trigger fired (Scheduled or Emergency). + /// @param deltaWad Signed change in risky exposure applied, in WAD. + /// @param floor The floor the assessment was taken against, in WAD. + /// @param target The target risky exposure, in WAD. event Rebalanced(RebalancePolicy.Trigger trigger, int256 deltaWad, uint256 floor, uint256 target); + + /// @notice Emitted when the guardian/owner pauses or unpauses user flows. + /// @param paused The new paused state. event PausedSet(bool paused); + + /// @notice Emitted when fee parameters are updated. + /// @param managementBps The new management fee, in basis points per year. + /// @param performanceBps The new performance fee, in basis points. + /// @param recipient The new fee recipient. event FeesSet(uint16 managementBps, uint16 performanceBps, address recipient); + + /// @notice Emitted when management-fee shares are accrued and minted. + /// @param feeShares The shares minted to the fee recipient. event ManagementFeeAccrued(uint256 feeShares); + + /// @notice Emitted when a performance fee is charged at term settlement. + /// @param feeShares The shares minted to the fee recipient. + /// @param gainWad The gain the fee was charged on, in WAD. event PerformanceFeeCharged(uint256 feeShares, uint256 gainWad); + + /// @notice Emitted when a controller sets or revokes an operator. + /// @param controller The controller granting/revoking authority. + /// @param operator The operator being set. + /// @param approved The new approval state. event OperatorSet(address indexed controller, address indexed operator, bool approved); + + /// @notice Emitted when the guardian/owner widens or resets the healthy-oracle + /// emergency slippage bound. + /// @param bps The new override, in basis points (0 resets to the tight default). event EmergencySlippageSet(uint256 bps); + /// @notice A user-supplied amount was zero. error ZeroAmount(); + + /// @notice User flow attempted while the vault is paused. error Paused(); + + /// @notice Caller is neither the keeper nor the owner. error NotKeeper(); + + /// @notice Caller is neither the guardian nor the owner. error NotGuardian(); + + /// @notice A rebalance was requested with no active trigger. error NoTrigger(); + + /// @notice Claim/redeem attempted before the request's epoch was settled. error EpochNotSettled(); + + /// @notice A controller already has a pending request from an earlier epoch. error PendingRequestFromEarlierEpoch(); + + /// @notice Claim attempted with no claimable request. error NothingToClaim(); + + /// @notice settleEpoch called with no pending deposits or redemptions. error NothingToSettle(); + + /// @notice Not enough idle asset to reserve the settled redemption payouts. error InsufficientIdle(); + + /// @notice A one-time setter was called after it was already set. error AlreadySet(); + + /// @notice Oracle health gate failed (unhealthy, or the wrong health state). error OracleUnhealthy(); + + /// @notice A fee parameter exceeds its hard cap. error FeeAboveCap(); + + /// @notice Caller is neither the controller nor its operator. error NotOperator(); + + /// @notice A full-only claim amount did not match the whole claimable amount. error ClaimMismatch(); + + /// @notice An emergency slippage override was outside [tight, degraded]. error SlippageOutOfRange(); + + /// @notice Refused to settle an epoch at a zero navPerShare (audit: G2 guard). error NavCollapsed(); + /// @notice Restricts a function to the keeper or the owner. + /// @dev Reverts NotKeeper for any other caller. The owner is always allowed + /// so it can act as a fallback keeper. modifier onlyKeeper() { if (msg.sender != keeper && msg.sender != owner()) revert NotKeeper(); _; } + /// @notice Deploy the vault for a given deposit asset and set its owner. + /// @dev Records the WAD scale factor from the asset's decimals so all + /// internal accounting can run in 18-decimal fixed point regardless of + /// the asset's native precision. Rejects assets with more than 18 + /// decimals, which the scale factor cannot represent. + /// @param asset_ The deposit asset the vault accepts. + /// @param assetDecimals The asset's token decimals (must be <= 18). + /// @param owner_ The initial owner address. constructor(address asset_, uint8 assetDecimals, address owner_) { require(assetDecimals <= 18); asset = asset_; @@ -144,21 +302,38 @@ contract CPPIVault is ERC20, Ownable { _initializeOwner(owner_); } + /// @notice The ERC-20 name of the share token. + /// @return The human-readable token name. function name() public pure override returns (string memory) { return "CPPI Protected Vault"; } + /// @notice The ERC-20 symbol of the share token. + /// @return The token symbol. function symbol() public pure override returns (string memory) { return "cppiVLT"; } - // ---------- wiring (owner, one-time for controller) ---------- + // ============================================================================ + // Wiring (owner, one-time setup) + // ============================================================================ + /// @notice Wire the CPPI controller once, at setup. + /// @dev One-time setter: reverts AlreadySet if the controller is already + /// configured, so the strategy brain cannot be swapped after launch. + /// @param c The controller that manages the CPPI strategy and term lifecycle. function setController(CPPIController c) external onlyOwner { if (address(controller) != address(0)) revert AlreadySet(); controller = c; } + /// @notice Wire the periphery modules and grant the executor spend approval. + /// @dev Approves the executor to pull the deposit asset up to the max so it + /// can fund rebalancing trades without per-trade approvals. + /// @param safe_ The safe (capital-protection) leg. + /// @param risky_ The risky (growth) leg. + /// @param exec_ The execution module that runs rebalancing trades. + /// @param rate_ The rate oracle feeding the CPPI strategy. function setPeriphery(ILeg safe_, ILeg risky_, IExecutionModule exec_, IRateOracle rate_) external onlyOwner { safeLeg = safe_; riskyLeg = risky_; @@ -167,15 +342,27 @@ contract CPPIVault is ERC20, Ownable { asset.safeApprove(address(exec_), type(uint256).max); } + /// @notice Set the keeper and guardian role addresses. + /// @param keeper_ The keeper authorized for scheduled operational actions. + /// @param guardian_ The guardian authorized to pause and set emergency params. function setRoles(address keeper_, address guardian_) external onlyOwner { keeper = keeper_; guardian = guardian_; } + /// @notice Set the oracle health source that gates new user flows. + /// @param healthSource_ The health source; the zero address disables the gate. function setHealthSource(IOracleHealth healthSource_) external onlyOwner { healthSource = healthSource_; } + /// @notice Update fee parameters and the fee recipient. + /// @dev Enforces the hard caps and accrues the outstanding management fee at + /// the old rate before switching, so the rate change never applies + /// retroactively to elapsed time. + /// @param managementBps The new management fee, in basis points per year. + /// @param performanceBps The new performance fee, in basis points. + /// @param recipient The new fee recipient. function setFees(uint16 managementBps, uint16 performanceBps, address recipient) external onlyOwner { if (managementBps > MAX_MANAGEMENT_FEE_BPS || performanceBps > MAX_PERFORMANCE_FEE_BPS) revert FeeAboveCap(); _accrueManagementFee(); @@ -185,6 +372,10 @@ contract CPPIVault is ERC20, Ownable { emit FeesSet(managementBps, performanceBps, recipient); } + /// @notice Pause or unpause user deposit and redeem flows. + /// @dev Guardian- or owner-gated. Pausing halts new requests but leaves the + /// permissionless emergency de-risk operational (spec invariant 5). + /// @param paused_ The new paused state. function setPaused(bool paused_) external { if (msg.sender != guardian && msg.sender != owner()) revert NotGuardian(); paused = paused_; @@ -197,6 +388,9 @@ contract CPPIVault is ERC20, Ownable { /// any override stays within [EMERGENCY_SLIPPAGE_BPS, /// EMERGENCY_DEGRADED_SLIPPAGE_BPS] so it can only ever widen the /// tight bound toward the already-sanctioned degraded ceiling. + /// @dev Guardian- or owner-gated. Reverts SlippageOutOfRange for any nonzero + /// value outside [EMERGENCY_SLIPPAGE_BPS, EMERGENCY_DEGRADED_SLIPPAGE_BPS]. + /// @param bps The new override, in basis points; 0 resets to the tight default. function setEmergencySlippageBps(uint256 bps) external { if (msg.sender != guardian && msg.sender != owner()) revert NotGuardian(); if (bps != 0 && (bps < EMERGENCY_SLIPPAGE_BPS || bps > EMERGENCY_DEGRADED_SLIPPAGE_BPS)) { @@ -206,38 +400,62 @@ contract CPPIVault is ERC20, Ownable { emit EmergencySlippageSet(bps); } - // ---------- NAV ---------- + // ============================================================================ + // NAV accounting + // ============================================================================ /// @notice Total value in the system, WAD asset terms. + /// @return The sum of idle asset plus both leg values, in WAD. function totalNav() public view returns (uint256) { return _idleWad() + safeLeg.value() + riskyLeg.value(); } /// @notice Value belonging to current shareholders: excludes unsettled /// deposit cash and reserved (settled, unclaimed) redemptions. + /// @return The shareholder-owned NAV, in WAD. function shareholderNav() public view returns (uint256) { return totalNav() - totalPendingDepositsWad - totalReservedPayoutsWad; } + /// @notice The current NAV per share. + /// @dev Returns 1e18 when no shares are outstanding, seeding the price at par. + /// @return The price per share, in WAD. function navPerShare() public view returns (uint256) { uint256 supply = totalSupply(); return supply == 0 ? 1e18 : shareholderNav().divWad(supply); } - // ---------- requests ---------- + // ============================================================================ + // Deposit and redeem requests + // ============================================================================ + /// @notice Grant or revoke an operator that may act on the caller's behalf. + /// @param operator The operator being set for msg.sender as controller. + /// @param approved The new approval state. + /// @return Always true, per the ERC-7540 operator interface. function setOperator(address operator, bool approved) external returns (bool) { isOperator[msg.sender][operator] = approved; emit OperatorSet(msg.sender, operator, approved); return true; } + /// @notice Queue a deposit request for the caller, as both controller and owner. + /// @param assets The deposit amount, in the asset's native decimals. + /// @return The epoch the request settles in (its ERC-7540 requestId). function requestDeposit(uint256 assets) external returns (uint256) { return _requestDeposit(assets, msg.sender, msg.sender); } /// @notice ERC-7540 request form. requestId is the epoch the request /// settles in; requests are fungible within an epoch. + /// @dev The caller must be authorized over both owner_ (to move its assets) + /// and controller (to write its request slot). Gating the controller + /// too blocks seeding a dust request into an arbitrary controller's slot + /// to grief it (audit L1). + /// @param assets The deposit amount, in the asset's native decimals. + /// @param controller The controller whose request slot is credited. + /// @param owner_ The address whose assets are pulled in. + /// @return The epoch the request settles in (its ERC-7540 requestId). function requestDeposit(uint256 assets, address controller, address owner_) external returns (uint256) { // caller must be able to move owner_'s assets AND to write the // controller's request slot (audit L1): the latter blocks seeding a @@ -247,16 +465,36 @@ contract CPPIVault is ERC20, Ownable { return _requestDeposit(assets, controller, owner_); } + /// @notice Queue a redeem request for the caller, as both controller and owner. + /// @param shares The share amount to lock for redemption, in WAD. + /// @return The epoch the request settles in (its ERC-7540 requestId). function requestRedeem(uint256 shares) external returns (uint256) { return _requestRedeem(shares, msg.sender, msg.sender); } + /// @notice ERC-7540 redeem request form on behalf of a controller and owner. + /// @dev The caller must be authorized over both owner_ (to move its shares) + /// and controller (to write its request slot); gating the controller + /// blocks griefing a foreign slot (audit L1). + /// @param shares The share amount to lock for redemption, in WAD. + /// @param controller The controller whose request slot is credited. + /// @param owner_ The address whose shares are locked in custody. + /// @return The epoch the request settles in (its ERC-7540 requestId). function requestRedeem(uint256 shares, address controller, address owner_) external returns (uint256) { _authControllerOrOperator(owner_); _authControllerOrOperator(controller); // audit L1: can't grief a foreign slot return _requestRedeem(shares, controller, owner_); } + /// @notice Shared deposit-request logic: pull assets and accrue into the + /// controller's slot for the current epoch. + /// @dev Reverts while paused or when the oracle is unhealthy, and blocks a + /// new request if the controller still has an unsettled request from an + /// earlier epoch. Amounts are scaled to WAD for internal accounting. + /// @param assets The deposit amount, in the asset's native decimals. + /// @param controller The controller whose request slot is credited. + /// @param owner_ The address whose assets are pulled in. + /// @return The current epoch, in which the request settles. function _requestDeposit(uint256 assets, address controller, address owner_) internal returns (uint256) { if (paused) revert Paused(); _requireOracleHealthy(); @@ -272,6 +510,15 @@ contract CPPIVault is ERC20, Ownable { return currentEpoch; } + /// @notice Shared redeem-request logic: lock shares in custody and accrue + /// into the controller's slot for the current epoch. + /// @dev Reverts while paused or when the oracle is unhealthy, and blocks a + /// new request if the controller still has an unsettled request from an + /// earlier epoch. Shares are transferred to the vault to lock them. + /// @param shares The share amount to lock for redemption, in WAD. + /// @param controller The controller whose request slot is credited. + /// @param owner_ The address whose shares are locked in custody. + /// @return The current epoch, in which the request settles. function _requestRedeem(uint256 shares, address controller, address owner_) internal returns (uint256) { if (paused) revert Paused(); _requireOracleHealthy(); @@ -286,7 +533,9 @@ contract CPPIVault is ERC20, Ownable { return currentEpoch; } - // ---------- settlement ---------- + // ============================================================================ + // Epoch settlement + // ============================================================================ /// @notice Settle the current epoch at one NAV per share: mint aggregate /// shares for pending deposits into custody, burn locked redeem @@ -330,10 +579,12 @@ contract CPPIVault is ERC20, Ownable { emit EpochSettled(epoch, price, depositsWad, redeemShares); } + /// @notice Claim the shares from the caller's settled deposit request. function claimShares() external { _claimDeposit(msg.sender, msg.sender); } + /// @notice Claim the assets from the caller's settled redeem request. function claimAssets() external { _claimRedeem(msg.sender, msg.sender); } @@ -341,12 +592,25 @@ contract CPPIVault is ERC20, Ownable { /// @notice ERC-7540 claim entrypoints. Deviation from the standard, /// documented: claims are full-only; `assets`/`shares` must match /// the whole claimable amount. + /// @dev Claim the settled deposit for a controller. `assets` must equal the + /// whole claimable amount or it reverts ClaimMismatch. + /// @param assets The full claimable deposit amount, in native decimals. + /// @param receiver The address that receives the minted shares. + /// @param controller The controller whose settled deposit is claimed. + /// @return shares The shares delivered to the receiver, in WAD. function deposit(uint256 assets, address receiver, address controller) external returns (uint256 shares) { _authControllerOrOperator(controller); if (assets != claimableDepositRequest(depositRequests[controller].epoch, controller)) revert ClaimMismatch(); return _claimDeposit(controller, receiver); } + /// @notice ERC-7540 full-only mint claim: deliver the settled deposit shares. + /// @dev `shares` must equal the whole claimable share amount at the settled + /// price or it reverts ClaimMismatch. + /// @param shares The full claimable share amount, in WAD. + /// @param receiver The address that receives the shares. + /// @param controller The controller whose settled deposit is claimed. + /// @return assets The assets that funded the claim, in native decimals. function mint(uint256 shares, address receiver, address controller) external returns (uint256 assets) { _authControllerOrOperator(controller); Request storage r = depositRequests[controller]; @@ -356,6 +620,13 @@ contract CPPIVault is ERC20, Ownable { _claimDeposit(controller, receiver); } + /// @notice ERC-7540 full-only redeem claim: pay out the settled redemption. + /// @dev `shares` must equal the whole locked redeem amount, and the epoch + /// must be settled, or it reverts. + /// @param shares The full locked redeem share amount, in WAD. + /// @param receiver The address that receives the assets. + /// @param controller The controller whose settled redemption is claimed. + /// @return assets The payout delivered, in native decimals. function redeem(uint256 shares, address receiver, address controller) external returns (uint256 assets) { _authControllerOrOperator(controller); if (shares != uint256(redeemRequests[controller].amountWad)) revert ClaimMismatch(); @@ -363,6 +634,14 @@ contract CPPIVault is ERC20, Ownable { return _claimRedeem(controller, receiver); } + /// @notice ERC-7540 full-only withdraw claim: pay out the settled redemption + /// expressed as an asset amount. + /// @dev The epoch must be settled and `assets` must equal the whole payout at + /// the settled price or it reverts. + /// @param assets The full claimable payout, in native decimals. + /// @param receiver The address that receives the assets. + /// @param controller The controller whose settled redemption is claimed. + /// @return shares The locked shares burned for the payout, in WAD. function withdraw(uint256 assets, address receiver, address controller) external returns (uint256 shares) { _authControllerOrOperator(controller); Request storage r = redeemRequests[controller]; @@ -373,6 +652,13 @@ contract CPPIVault is ERC20, Ownable { _claimRedeem(controller, receiver); } + /// @notice Shared deposit-claim logic: deliver settled shares and clear the slot. + /// @dev Reverts NothingToClaim if the slot is empty or EpochNotSettled if its + /// epoch has no struck price. Deletes the request before transferring the + /// shares out of custody. + /// @param controller The controller whose settled deposit is claimed. + /// @param receiver The address that receives the shares. + /// @return shares The shares delivered, in WAD. function _claimDeposit(address controller, address receiver) internal returns (uint256 shares) { Request storage r = depositRequests[controller]; uint256 price = epochNavPerShare[r.epoch]; @@ -385,6 +671,15 @@ contract CPPIVault is ERC20, Ownable { emit SharesClaimed(controller, epoch, shares); } + /// @notice Shared redeem-claim logic: pay out the settled redemption and + /// clear the slot. + /// @dev Reverts NothingToClaim if the slot is empty or EpochNotSettled if its + /// epoch has no struck price. The last redeemer of the epoch drains the + /// aggregate-vs-per-user rounding residue so it does not stay frozen in + /// shareholderNav (audit I1). + /// @param controller The controller whose settled redemption is claimed. + /// @param receiver The address that receives the assets. + /// @return assets The payout delivered, in native decimals. function _claimRedeem(address controller, address receiver) internal returns (uint256 assets) { Request storage r = redeemRequests[controller]; uint256 price = epochNavPerShare[r.epoch]; @@ -408,37 +703,66 @@ contract CPPIVault is ERC20, Ownable { emit AssetsClaimed(controller, epoch, payoutWad); } - // ---------- ERC-7540 views ---------- + // ============================================================================ + // Claims and ERC-7540 views + // ============================================================================ + /// @notice ERC-7540 pending (not-yet-settled) deposit amount for a request. + /// @param requestId The epoch the request was made in. + /// @param controller The controller whose request is queried. + /// @return assets The pending deposit amount in native decimals, or 0 if the + /// request is absent, from another epoch, or already settled. function pendingDepositRequest(uint256 requestId, address controller) public view returns (uint256 assets) { Request storage r = depositRequests[controller]; if (r.epoch == requestId && epochNavPerShare[r.epoch] == 0) return uint256(r.amountWad) / assetScale; } + /// @notice ERC-7540 claimable (settled) deposit amount for a request. + /// @param requestId The epoch the request was made in. + /// @param controller The controller whose request is queried. + /// @return assets The claimable deposit amount in native decimals, or 0 if + /// the request is absent, from another epoch, or not yet settled. function claimableDepositRequest(uint256 requestId, address controller) public view returns (uint256 assets) { Request storage r = depositRequests[controller]; if (r.epoch == requestId && epochNavPerShare[r.epoch] != 0) return uint256(r.amountWad) / assetScale; } + /// @notice ERC-7540 pending (not-yet-settled) redeem shares for a request. + /// @param requestId The epoch the request was made in. + /// @param controller The controller whose request is queried. + /// @return shares The pending redeem shares in WAD, or 0 if the request is + /// absent, from another epoch, or already settled. function pendingRedeemRequest(uint256 requestId, address controller) public view returns (uint256 shares) { Request storage r = redeemRequests[controller]; if (r.epoch == requestId && epochNavPerShare[r.epoch] == 0) return uint256(r.amountWad); } + /// @notice ERC-7540 claimable (settled) redeem shares for a request. + /// @param requestId The epoch the request was made in. + /// @param controller The controller whose request is queried. + /// @return shares The claimable redeem shares in WAD, or 0 if the request is + /// absent, from another epoch, or not yet settled. function claimableRedeemRequest(uint256 requestId, address controller) public view returns (uint256 shares) { Request storage r = redeemRequests[controller]; if (r.epoch == requestId && epochNavPerShare[r.epoch] != 0) return uint256(r.amountWad); } + /// @notice Shareholder-owned assets, in the asset's native decimals. + /// @return The shareholder NAV converted from WAD to native decimals. function totalAssets() external view returns (uint256) { return shareholderNav() / assetScale; } /// @notice ERC-7575 single-share-token vault: the share IS this contract. + /// @return The share token address (this contract). function share() external view returns (address) { return address(this); } + /// @notice ERC-165 interface detection. + /// @param interfaceId The interface identifier to check. + /// @return True for ERC-165 and the ERC-7540 operator, async-deposit, and + /// async-redeem interfaces; false otherwise. function supportsInterface(bytes4 interfaceId) external pure returns (bool) { return interfaceId == 0x01ffc9a7 // ERC-165 || interfaceId == 0xe3bc4e65 // ERC-7540 operator @@ -446,8 +770,16 @@ contract CPPIVault is ERC20, Ownable { || interfaceId == 0x620ee8e4; // ERC-7540 async redeem } - // ---------- term lifecycle ---------- + // ============================================================================ + // Term lifecycle and fees + // ============================================================================ + /// @notice Start a new capital-protection term of the given duration. + /// @dev Keeper-gated. Seeds the performance-fee high-water mark at the first + /// term's entry navPerShare so the fee is measured from real principal, + /// not from zero, then hands the term parameters to the controller. + /// @param duration The term length in seconds, added to the current timestamp + /// to set the maturity. function startTerm(uint64 duration) external onlyKeeper { // seed the high-water mark at the first term's entry navPerShare so the // performance fee is measured from real principal, not from zero @@ -457,11 +789,13 @@ contract CPPIVault is ERC20, Ownable { ); } + /// @notice Settle the active term, accrue fees, and report any shortfall. /// @dev Performance fee is charged only on the rise in navPerShare above /// the per-share high-water mark, then the mark ratchets up. Flat or /// losing terms, and re-struck short terms where navPerShare has not /// advanced, pay nothing (audit H1/H2). Deposits never move /// navPerShare (they mint at price), so the per-share basis is clean. + /// @return shortfall The term shortfall reported by the controller, in WAD. function settleTerm() external onlyKeeper returns (uint256 shortfall) { _accrueManagementFee(); uint256 supply = totalSupply(); @@ -481,11 +815,17 @@ contract CPPIVault is ERC20, Ownable { } } - // ---------- rebalancing ---------- + // ============================================================================ + // Rebalancing and emergency de-risk + // ============================================================================ /// @notice Execute a rebalance if a trigger fires. Scheduled path is /// keeper-gated; Emergency is permissionless and works while /// paused (spec invariant 5). + /// @dev Reverts NoTrigger when neither trigger is active, and NotKeeper when + /// a Scheduled trigger is invoked by a non-keeper. The slippage bound is + /// chosen per trigger and, for Emergency, per oracle health. + /// @return trigger The trigger that fired and was executed. function rebalance() external returns (RebalancePolicy.Trigger trigger) { CPPIController.Assessment memory a = controller.assess(shareholderNav(), totalSupply(), riskyLeg.value(), rateOracle.rateWad()); @@ -531,15 +871,20 @@ contract CPPIVault is ERC20, Ownable { } /// @notice Keeper pre-funds redemption settlement from the safe side. + /// @param amountWad The asset amount to free into idle custody, in WAD. function freeAssets(uint256 amountWad) external onlyKeeper { executor.freeAssets(amountWad); } - // ---------- internal ---------- + // ============================================================================ + // Internal helpers + // ============================================================================ + /// @notice Accrue and mint the management fee owed since the last accrual. /// @dev Mint management-fee shares pro-rata to elapsed time. Dilutes all /// holders equally; called before any settlement pricing so epochs - /// never straddle an unaccrued period. + /// never straddle an unaccrued period. No-ops when the fee is off, there + /// is no recipient, no prior accrual timestamp, or no supply. function _accrueManagementFee() internal { uint64 last = lastMgmtAccrualAt; lastMgmtAccrualAt = uint64(block.timestamp); @@ -553,18 +898,28 @@ contract CPPIVault is ERC20, Ownable { emit ManagementFeeAccrued(feeShares); } + /// @notice Require that the caller is the controller itself or its operator. + /// @dev Reverts NotOperator otherwise. + /// @param controller The controller whose authority is being checked. function _authControllerOrOperator(address controller) internal view { if (msg.sender != controller && !isOperator[controller][msg.sender]) revert NotOperator(); } + /// @notice Require the oracle be healthy (or unconfigured) to proceed. + /// @dev Reverts OracleUnhealthy when a health source is set and reports + /// unhealthy; a zero health source disables the gate. function _requireOracleHealthy() internal view { if (address(healthSource) != address(0) && !healthSource.healthy()) revert OracleUnhealthy(); } + /// @notice Whether the oracle is configured and currently unhealthy. + /// @return True if a health source is set and reports unhealthy. function _oracleDegraded() internal view returns (bool) { return address(healthSource) != address(0) && !healthSource.healthy(); } + /// @notice The idle deposit asset held directly by the vault, in WAD. + /// @return The vault's asset balance scaled to WAD. function _idleWad() internal view returns (uint256) { return SafeTransferLib.balanceOf(asset, address(this)) * assetScale; } diff --git a/src/ExecutionModule.sol b/src/ExecutionModule.sol index 346c010..e08a65f 100644 --- a/src/ExecutionModule.sol +++ b/src/ExecutionModule.sol @@ -9,10 +9,23 @@ import {IPriceSource, ISwapRouter02} from "./interfaces/IExecutionPeriphery.sol" import {SafeLegManager} from "./SafeLegManager.sol"; import {RiskyLegManager} from "./RiskyLegManager.sol"; +/// @notice Minimal view of the vault's async accounting that the execution +/// module reads to compute how much idle cash is truly free to spend. interface IVaultAccounting { + /// @notice Total pending (unsettled) deposit cash held by the vault, in WAD. + /// @return The sum of all deposit requests not yet settled, in WAD. function totalPendingDepositsWad() external view returns (uint256); + + /// @notice Total settled-but-unclaimed redemption payouts reserved by the vault, in WAD. + /// @return The sum of all reserved payouts, in WAD. function totalReservedPayoutsWad() external view returns (uint256); + + /// @notice Total shares locked for redemption but not yet settled, in WAD. + /// @return The sum of all pending redeem shares, in WAD. function totalPendingRedeemShares() external view returns (uint256); + + /// @notice Current NAV per share, used to value pending redeem shares. + /// @return The NAV per share, in WAD. function navPerShare() external view returns (uint256); } @@ -38,44 +51,109 @@ contract ExecutionModule is IExecutionModule, Ownable { using SafeTransferLib for address; using FixedPointMathLib for uint256; + // ============================================================================ + // Configuration and state + // ============================================================================ + + /// @notice The vault this module executes trades for; the only caller of the onlyVault entrypoints. address public immutable vault; + + /// @notice The deposit asset (USDC) that funds risky buys and receives sell proceeds. address public immutable usdc; + + /// @notice WETH, the base risky-leg exposure and the routing hop for every swap. address public immutable weth; + + /// @notice wstETH, the capped yield-bearing fraction of the risky leg. address public immutable wsteth; - /// @dev Derived from the vault asset's decimals (audit I3). The vault, safe - /// leg and PT adapter all parameterize assetDecimals, so this module - /// must too rather than bake in a 6-decimal (1e12) assumption that - /// silently breaks every conversion on a non-6-decimal redeployment. - uint256 public immutable assetScale; // 10^(18 - assetDecimals) - uint256 public immutable dustFloor; // one whole asset unit: 10^assetDecimals + /// @dev Derived from the vault asset's decimals (audit I3), equal to + /// 10^(18 - assetDecimals). The vault, safe leg and PT adapter all + /// parameterize assetDecimals, so this module must too rather than bake + /// in a 6-decimal (1e12) assumption that silently breaks every + /// conversion on a non-6-decimal redeployment. + uint256 public immutable assetScale; + + /// @dev One whole asset unit, equal to 10^assetDecimals. Sweeps below this + /// are treated as dust and skipped since they are not worth a PT trade. + uint256 public immutable dustFloor; + + /// @notice The safe leg manager: capital-protection side that funds and receives asset flows. SafeLegManager public safeLeg; + + /// @notice The risky leg manager: growth side that supplies and receives WETH/wstETH. RiskyLegManager public riskyLeg; + + /// @notice Oracle price source used to anchor every swap's slippage bound. IPriceSource public priceSource; + + /// @notice The Uniswap V3 router every swap is routed through. ISwapRouter02 public router; + + /// @notice The keeper authorized to run composition maintenance. address public keeper; + /// @notice Primary Uniswap V3 fee tier tried first on every swap, in hundredths of a bip. uint24 public primaryFee = 500; + + /// @notice Fallback Uniswap V3 fee tier retried when the primary tier reverts, in hundredths of a bip. uint24 public fallbackFee = 3000; + + /// @notice Tight-pool fee tier used for the WETH<->wstETH hop, in hundredths of a bip. uint24 public wstethPoolFee = 100; /// @dev Ceiling on the caller-supplied composition-rebalance slippage /// (audit L3): a keeper cannot drive minOut toward zero. uint256 internal constant MAX_COMPOSITION_SLIPPAGE_BPS = 500; + // ============================================================================ + // Events and errors + // ============================================================================ + + /// @notice Emitted after a risky-leg rebalance swap completes. + /// @param deltaWad Signed change in risky exposure applied, in WAD (positive buys, negative sells). + /// @param usdcMoved USDC spent (buy) or received (sell) in the swap, in native asset units. + /// @param wethMoved WETH received (buy) or sold (sell) in the swap, in wei. event RebalanceExecuted(int256 deltaWad, uint256 usdcMoved, uint256 wethMoved); + + /// @notice Emitted after a composition maintenance trade shifts the WETH/wstETH split. + /// @param wethToWstethWad Signed WETH-equivalent moved, in WAD (positive buys wstETH, negative sells it). event CompositionRebalanced(int256 wethToWstethWad); + + /// @notice Emitted when assets are freed to the vault for redemption funding. + /// @param amountWad The amount delivered to the vault, in WAD. event AssetsFreed(uint256 amountWad); + /// @notice Caller of an onlyVault function is not the vault. error NotVault(); + + /// @notice Caller of composition maintenance is neither the keeper nor the owner. error NotKeeper(); + + /// @notice A rebalance was requested with a zero delta. error ZeroDelta(); + /// @notice Restricts a function to the vault. + /// @dev Reverts NotVault for any other caller. modifier onlyVault() { if (msg.sender != vault) revert NotVault(); _; } + // ============================================================================ + // Setup (owner, one-time wiring) + // ============================================================================ + + /// @notice Deploy the execution module for a vault and its token set. + /// @dev Derives assetScale and dustFloor from the asset's decimals so all + /// conversions run in 18-decimal fixed point; the assetScale expression + /// reverts if assetDecimals_ exceeds 18, which it cannot represent. + /// @param vault_ The vault this module executes for. + /// @param usdc_ The deposit asset (USDC) address. + /// @param weth_ The WETH address. + /// @param wsteth_ The wstETH address. + /// @param assetDecimals_ The deposit asset's token decimals (must be <= 18). + /// @param owner_ The initial owner address. constructor(address vault_, address usdc_, address weth_, address wsteth_, uint8 assetDecimals_, address owner_) { vault = vault_; usdc = usdc_; @@ -86,6 +164,15 @@ contract ExecutionModule is IExecutionModule, Ownable { _initializeOwner(owner_); } + /// @notice Wire the leg managers, price source, router, and keeper, and grant + /// the router spend approval for every traded token. + /// @dev Approves the router to pull USDC, WETH, and wstETH up to the max so + /// swaps need no per-trade approvals. + /// @param safeLeg_ The safe leg manager that funds buys and receives sell proceeds. + /// @param riskyLeg_ The risky leg manager that supplies and receives WETH/wstETH. + /// @param priceSource_ The oracle price source anchoring every swap's slippage bound. + /// @param router_ The Uniswap V3 router every swap is routed through. + /// @param keeper_ The keeper authorized to run composition maintenance. function setPeriphery( SafeLegManager safeLeg_, RiskyLegManager riskyLeg_, @@ -105,6 +192,15 @@ contract ExecutionModule is IExecutionModule, Ownable { // ---------- IExecutionModule ---------- + /// @notice Apply a signed change to risky exposure: buy when the delta is + /// positive, sell when negative, then sweep any leftover free idle + /// into the safe leg. + /// @dev Vault-only. Reverts ZeroDelta on a zero delta. The buy path funds + /// from the vault's free idle first then the safe leg; sell proceeds + /// land in the safe leg. The trailing sweep runs only here, never on + /// the outbound freeAssets path. + /// @param deltaWad The signed change in risky exposure to apply, in WAD. + /// @param maxSlippageBps The slippage bound for the swap, in basis points. function executeRebalance(int256 deltaWad, uint256 maxSlippageBps) external onlyVault { if (deltaWad == 0) revert ZeroDelta(); if (deltaWad > 0) { @@ -115,11 +211,15 @@ contract ExecutionModule is IExecutionModule, Ownable { _sweepIdle(); } + /// @notice Free up to `amountWad` of asset into vault custody for redemption + /// funding, drawing the safe leg first and selling the risky leg + /// only for the shortfall. /// @dev Redemption funding sources the safe leg first; if it cannot /// cover the request (e.g. full exits while the vault still holds /// ETH), the shortfall is sold from the risky leg at the emergency /// bound, with a small margin so slippage cannot leave the transfer /// short. Settlement prices at the vault reflect any cost paid. + /// @param amountWad The asset amount to free into vault custody, in WAD. function freeAssets(uint256 amountWad) external onlyVault { uint256 safeVal = safeLeg.value(); if (amountWad > safeVal) { @@ -138,7 +238,12 @@ contract ExecutionModule is IExecutionModule, Ownable { emit AssetsFreed(available); } + /// @notice Sell risky exposure through an external self-call so freeAssets + /// can wrap the sale in try/catch and stay best-effort (audit L4). /// @dev Self-call entrypoint so `freeAssets` can try/catch the risky sale. + /// Reverts NotVault for any caller other than this contract. Sells at + /// the 150bps emergency bound. + /// @param deltaWad The risky exposure to sell, in WAD. function sellRiskySelf(uint256 deltaWad) external { if (msg.sender != address(this)) revert NotVault(); _sellRisky(deltaWad, 150); @@ -154,6 +259,9 @@ contract ExecutionModule is IExecutionModule, Ownable { /// a mispriced/stale mark (the buy branch already required this; the /// sell branch did not). Composition maintenance simply pauses during /// a depeg or feed outage; the keeper retries when healthy. + /// @param maxMoveWad The maximum WETH-equivalent to shift this call, in WAD. + /// @param maxSlippageBps The requested slippage bound, in basis points, + /// clamped to MAX_COMPOSITION_SLIPPAGE_BPS (audit L3). function rebalanceComposition(uint256 maxMoveWad, uint256 maxSlippageBps) external { if (msg.sender != keeper && msg.sender != owner()) revert NotKeeper(); if (maxSlippageBps > MAX_COMPOSITION_SLIPPAGE_BPS) maxSlippageBps = MAX_COMPOSITION_SLIPPAGE_BPS; @@ -188,6 +296,14 @@ contract ExecutionModule is IExecutionModule, Ownable { // ---------- internal ---------- + /// @notice Buy `deltaWad` of risky exposure and deliver the resulting WETH + /// to the risky leg. + /// @dev Funding order: the vault's FREE idle first, then the safe leg for + /// the remainder; idle owed to users is never spent (see + /// _vaultFreeIdleWad). minWethOut is oracle-anchored at the slippage + /// bound. No-ops when no USDC ends up available to spend. + /// @param deltaWad The risky exposure to buy, in WAD. + /// @param maxSlippageBps The slippage bound for the USDC->WETH swap, in basis points. function _buyRisky(uint256 deltaWad, uint256 maxSlippageBps) internal { // funding: vault free idle first, then the safe leg uint256 freeIdleWad = _vaultFreeIdleWad(); @@ -206,6 +322,14 @@ contract ExecutionModule is IExecutionModule, Ownable { emit RebalanceExecuted(int256(deltaWad), usdcIn, wethOut); } + /// @notice Sell `deltaWad` of risky exposure and deliver the USDC proceeds + /// to the safe leg. + /// @dev Pulls WETH (and any wstETH share) from the risky leg; any wstETH is + /// first hopped to WETH through the tight pool, then joins the WETH + /// sale. Proceeds land in the safe leg via onInflow. Each swap is + /// oracle-anchored at the slippage bound. No-ops when no WETH results. + /// @param deltaWad The risky exposure to sell, in WAD. + /// @param maxSlippageBps The slippage bound for each swap, in basis points. function _sellRisky(uint256 deltaWad, uint256 maxSlippageBps) internal { (uint256 wethGot, uint256 wstethGot) = riskyLeg.provide(deltaWad, address(this)); @@ -224,6 +348,8 @@ contract ExecutionModule is IExecutionModule, Ownable { emit RebalanceExecuted(-int256(deltaWad), usdcOut, wethGot); } + /// @notice Execute an exact-input single-hop swap on Uniswap V3, retrying + /// the fallback fee tier if the primary tier reverts. /// @dev Try the primary fee tier; on any revert (thin pool, minOut miss), /// retry once on the fallback tier with the same bound. /// @dev sqrtPriceLimitX96 = 0 is deliberate (audit L7). For an exact-input @@ -233,6 +359,13 @@ contract ExecutionModule is IExecutionModule, Ownable { /// price limit would only add exact-input partial-fill semantics /// (leftover tokenIn to account for) without tightening that bound, /// so it is intentionally omitted rather than risk a mis-set limit. + /// @param tokenIn The input token sold into the pool. + /// @param tokenOut The output token bought from the pool. + /// @param fee The primary Uniswap V3 fee tier tried first, in hundredths of a bip. + /// @param amountIn The exact input amount, in tokenIn units. + /// @param minOut The minimum acceptable output, in tokenOut units. + /// @param recipient The address that receives the output tokens. + /// @return amountOut The output amount delivered, in tokenOut units. function _swap(address tokenIn, address tokenOut, uint24 fee, uint256 amountIn, uint256 minOut, address recipient) internal returns (uint256 amountOut) @@ -254,9 +387,12 @@ contract ExecutionModule is IExecutionModule, Ownable { } } + /// @notice Park leftover free idle cash into the safe leg after a delta + /// trade so uninvested cash earns the floor rate. /// @dev After the delta trade, park any remaining free idle in the safe /// leg so uninvested cash earns the floor rate instead of sitting in /// the vault. Never runs inside freeAssets (that flow is outbound). + /// Sub-dustFloor remainders are skipped as not worth a PT trade. function _sweepIdle() internal { uint256 freeWad = _vaultFreeIdleWad(); uint256 assets = freeWad / assetScale; @@ -265,10 +401,13 @@ contract ExecutionModule is IExecutionModule, Ownable { safeLeg.onInflow(); } + /// @notice The vault's idle cash truly free to spend: the USDC balance + /// minus all idle owed to users. /// @dev Idle owed to users is untouchable: pending deposit cash, reserved /// payouts, AND requested-but-unsettled redemptions at current NAV /// (else a rebalance between freeAssets and settleEpoch would claw /// the funding back into the safe leg). + /// @return The free idle balance in WAD, or 0 when owed cash meets or exceeds idle. function _vaultFreeIdleWad() internal view returns (uint256) { IVaultAccounting v = IVaultAccounting(vault); uint256 idleWad = SafeTransferLib.balanceOf(usdc, vault) * assetScale; diff --git a/src/OracleHub.sol b/src/OracleHub.sol index ac6adae..a731ffb 100644 --- a/src/OracleHub.sol +++ b/src/OracleHub.sol @@ -7,19 +7,41 @@ import {IPriceSource} from "./interfaces/IExecutionPeriphery.sol"; import {IRateOracle} from "./interfaces/IVaultPeriphery.sol"; import {IPTAdapter} from "./interfaces/IPTAdapter.sol"; +/// @notice Minimal Chainlink aggregator surface (price feed plus decimals). interface IChainlinkFeed { + /// @notice Latest round data from the aggregator. + /// @return roundId The round the answer was computed in. + /// @return answer The reported price, in feed decimals. + /// @return startedAt When the round started. + /// @return updatedAt When the answer was last updated (used for staleness). + /// @return answeredInRound The round the answer was carried over from. function latestRoundData() external view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); + + /// @notice The feed's answer decimals, used to scale the price to WAD. + /// @return The number of decimals the feed reports. function decimals() external view returns (uint8); } +/// @notice Minimal wstETH surface: the Lido stETH-per-wstETH exchange rate. interface IWstETHRate { + /// @notice stETH per wstETH, the fundamental (non-manipulable) redemption rate. + /// @return The exchange rate, in WAD. function stEthPerToken() external view returns (uint256); } +/// @notice Minimal Uniswap V3 pool surface: the current spot (slot0). interface IUniV3PoolSlot0 { + /// @notice Current pool slot0 state. + /// @return sqrtPriceX96 The current sqrt price as a Q64.96. + /// @return tick The current tick. + /// @return obsIndex The most recent observation index. + /// @return obsCard The current observation cardinality. + /// @return obsCardNext The next observation cardinality. + /// @return feeProtocol The protocol fee setting. + /// @return unlocked Whether the pool is unlocked. function slot0() external view @@ -46,13 +68,29 @@ interface IUniV3PoolSlot0 { contract OracleHub is IPriceSource, IRateOracle, Ownable { using FixedPointMathLib for uint256; + // ============================================================================ + // Configuration and state + // ============================================================================ + + /// @notice The Chainlink ETH/USD price feed. IChainlinkFeed public immutable ethUsdFeed; + + /// @notice The wstETH exchange-rate source (Lido stEthPerToken). IWstETHRate public immutable wsteth; + + /// @notice The wstETH/WETH Uniswap V3 pool, read as a depeg gate only. IUniV3PoolSlot0 public immutable wstethWethPool; + + /// @notice The PT adapter that supplies the live implied floor rate. IPTAdapter public ptAdapter; + + /// @notice Whether wstETH is token0 in wstethWethPool, fixing the ratio orientation. bool public immutable wstethIsToken0; + /// @notice Max age (seconds) before the ETH/USD feed is considered stale. uint256 public maxFeedAge = 3900; // Chainlink ETH/USD heartbeat 3600 + margin + + /// @notice Max tolerated basis (bps) between the wstETH exchange rate and pool spot before buys are blocked. uint256 public maxBasisBps = 200; /// @dev Optional USDC/USD feed (audit L8). The vault denominates in USDC @@ -60,6 +98,8 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { /// When set and USDC depegs beyond maxUsdcDepegBps, healthy() flips /// false so deposits/settlement pause until the peg returns. IChainlinkFeed public usdcUsdFeed; + + /// @notice Max tolerated USDC depeg (bps) before healthy() flips false (audit L8). uint256 public maxUsdcDepegBps = 200; /// @dev Prolonged-staleness window (audit M4). Once the ETH feed has been @@ -68,17 +108,50 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { /// allowed (see CPPIVault.deRiskUnderProlongedStaleness). uint256 public prolongedStalenessWindow = 3 hours; + /// @notice Last refreshed ETH/USD price, served while the feed is stale (WAD). uint256 public snapshotEthUsdWad; + + /// @notice Timestamp of the last snapshot, the anchor for prolonged staleness. uint64 public snapshotAt; + // ============================================================================ + // Events and errors + // ============================================================================ + + /// @notice Emitted when the price snapshot is refreshed. + /// @param ethUsdWad The freshly snapshotted ETH/USD price, in WAD. event Refreshed(uint256 ethUsdWad); + + /// @notice Emitted when the staleness/basis params are updated. + /// @param maxFeedAge The new max feed age, in seconds. + /// @param maxBasisBps The new max wstETH basis, in basis points. event ParamsSet(uint256 maxFeedAge, uint256 maxBasisBps); + + /// @notice Emitted when the optional USDC/USD depeg feed is set. + /// @param feed The USDC/USD feed address (address(0) disables the check). + /// @param maxDepegBps The new max USDC depeg tolerance, in basis points. event UsdcFeedSet(address feed, uint256 maxDepegBps); + + /// @notice Emitted when the prolonged-staleness window is updated. + /// @param window The new window, in seconds. event ProlongedWindowSet(uint256 window); + /// @notice No usable price is available (feed reverted/non-positive and no snapshot). error NoPrice(); + + /// @notice A parameter update was outside its allowed range. error BadParams(); + // ============================================================================ + // Setup (owner) + // ============================================================================ + + /// @notice Deploy the hub over its price sources. + /// @param ethUsdFeed_ The Chainlink ETH/USD feed. + /// @param wsteth_ The wstETH exchange-rate source. + /// @param pool_ The wstETH/WETH Uniswap V3 pool used as a depeg gate. + /// @param wstethIsToken0_ Whether wstETH is token0 in that pool. + /// @param owner_ The initial owner. constructor(address ethUsdFeed_, address wsteth_, address pool_, bool wstethIsToken0_, address owner_) { ethUsdFeed = IChainlinkFeed(ethUsdFeed_); wsteth = IWstETHRate(wsteth_); @@ -87,10 +160,15 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { _initializeOwner(owner_); } + /// @notice Wire the PT adapter that supplies the implied floor rate. + /// @param ptAdapter_ The PT adapter. function setPtAdapter(IPTAdapter ptAdapter_) external onlyOwner { ptAdapter = ptAdapter_; } + /// @notice Update the staleness and wstETH-basis parameters. + /// @param maxFeedAge_ The new max feed age in seconds (600 .. 1 days). + /// @param maxBasisBps_ The new max wstETH basis in bps (<= 1000). function setParams(uint256 maxFeedAge_, uint256 maxBasisBps_) external onlyOwner { if (maxFeedAge_ < 600 || maxFeedAge_ > 1 days || maxBasisBps_ > 1000) revert BadParams(); maxFeedAge = maxFeedAge_; @@ -100,6 +178,8 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { /// @notice Set the optional USDC/USD depeg feed (audit L8). address(0) /// disables the check (USDC assumed at par). + /// @param feed The USDC/USD feed address (address(0) to disable). + /// @param maxDepegBps The new max USDC depeg tolerance in bps (<= 2000). function setUsdcFeed(address feed, uint256 maxDepegBps) external onlyOwner { if (maxDepegBps > 2000) revert BadParams(); usdcUsdFeed = IChainlinkFeed(feed); @@ -107,13 +187,17 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { emit UsdcFeedSet(feed, maxDepegBps); } + /// @notice Update the prolonged-staleness window (audit M4). + /// @param window The new window in seconds (>= maxFeedAge, <= 2 days). function setProlongedStalenessWindow(uint256 window) external onlyOwner { if (window < maxFeedAge || window > 2 days) revert BadParams(); prolongedStalenessWindow = window; emit ProlongedWindowSet(window); } - // ---------- maintenance ---------- + // ============================================================================ + // Maintenance + // ============================================================================ /// @notice Snapshot the current fresh price; permissionless. The keeper /// calls this each rebalance so a later feed outage degrades to @@ -126,8 +210,14 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { emit Refreshed(price); } - // ---------- IPriceSource ---------- + // ============================================================================ + // IPriceSource: price reads + // ============================================================================ + /// @notice ETH/USD price: the live feed while fresh, else the last snapshot. + /// @dev When degraded, serves the last-good snapshot so the floor defense + /// keeps running (spec section 8); reverts only if no snapshot exists. + /// @return The ETH/USD price, in WAD. function ethUsdWad() public view returns (uint256) { (uint256 price, bool fresh) = _freshEthUsd(); if (fresh) return price; @@ -143,10 +233,15 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { /// marking (Aave/Morpho). The pool spot is kept only as a depeg /// gate on BUYING more wstETH (wstethBuyAllowed), where a pushed /// spot is fail-safe: it can only block a buy, never inflate value. + /// @return The wstETH price, in USD WAD. function wstethUsdWad() external view returns (uint256) { return wsteth.stEthPerToken().mulWad(ethUsdWad()); } + /// @notice Whether buying more wstETH is allowed: the ETH feed must be fresh + /// and the exchange rate must not diverge from pool spot beyond the + /// basis limit. A pushed spot can only block a buy, never inflate value. + /// @return True when a wstETH buy is permitted. function wstethBuyAllowed() external view returns (bool) { (, bool fresh) = _freshEthUsd(); if (!fresh) return false; @@ -155,14 +250,20 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { return _basisBps(rateBased, poolBased) <= maxBasisBps; } + // ============================================================================ + // Health signals + // ============================================================================ + /// @notice Health gate the vault uses to pause NEW user flows and epoch /// settlement: the ETH feed must be fresh AND USDC on peg (L8). + /// @return True when the oracle stack is healthy. function healthy() external view returns (bool) { (, bool fresh) = _freshEthUsd(); return fresh && usdcHealthy(); } /// @notice True when USDC is within its depeg tolerance (or no feed set). + /// @return True when USDC is on peg (or the depeg check is disabled). function usdcHealthy() public view returns (bool) { if (address(usdcUsdFeed) == address(0)) return true; try usdcUsdFeed.latestRoundData() returns (uint80, int256 answer, uint256, uint256 updatedAt, uint80) { @@ -179,20 +280,32 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { /// window (audit M4): the risky-leg mark is frozen and the CPPI /// trigger is blind, so a permissionless circuit-breaker de-risk /// is warranted. + /// @return True when staleness has exceeded the prolonged window. function prolongedStale() external view returns (bool) { (, bool fresh) = _freshEthUsd(); if (fresh) return false; return snapshotAt != 0 && block.timestamp > uint256(snapshotAt) + prolongedStalenessWindow; } - // ---------- IRateOracle ---------- + // ============================================================================ + // IRateOracle + // ============================================================================ + /// @notice The live PT-implied yield used to discount the floor. + /// @return The implied rate, in WAD. function rateWad() external view returns (uint256) { return ptAdapter.impliedRateWad(); } - // ---------- internal ---------- + // ============================================================================ + // Internal helpers + // ============================================================================ + /// @notice Read the ETH/USD feed and report whether it is fresh. + /// @dev Reverts are caught and reported as not-fresh so a feed outage + /// degrades gracefully rather than bubbling up. + /// @return priceWad The feed price scaled to WAD (0 if unusable). + /// @return fresh Whether the answer is positive and within maxFeedAge. function _freshEthUsd() internal view returns (uint256 priceWad, bool fresh) { try ethUsdFeed.latestRoundData() returns (uint80, int256 answer, uint256, uint256 updatedAt, uint80) { if (answer <= 0) return (0, false); @@ -205,6 +318,7 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { /// @dev Pool spot: WETH per wstETH from sqrtPriceX96. With wstETH as /// token0 the raw ratio is already token1/token0. + /// @return WETH per wstETH from the pool spot, in WAD. function _poolWethPerWsteth() internal view returns (uint256) { (uint160 sqrtPriceX96,,,,,,) = wstethWethPool.slot0(); uint256 sq = uint256(sqrtPriceX96) * uint256(sqrtPriceX96); @@ -212,6 +326,10 @@ contract OracleHub is IPriceSource, IRateOracle, Ownable { return wstethIsToken0 ? ratioWad : uint256(1e36) / ratioWad; } + /// @notice Relative difference between two values, in basis points. + /// @param a The first value. + /// @param b The second value. + /// @return The gap between them as bps of the larger (0 if both are 0). function _basisBps(uint256 a, uint256 b) internal pure returns (uint256) { uint256 hi = FixedPointMathLib.max(a, b); uint256 lo = FixedPointMathLib.min(a, b); diff --git a/src/PendlePTAdapter.sol b/src/PendlePTAdapter.sol index b021734..14e19bb 100644 --- a/src/PendlePTAdapter.sol +++ b/src/PendlePTAdapter.sol @@ -28,12 +28,25 @@ import { /// exits redeem PT at par. rollToMarket moves the whole position to the /// next maturity in one transaction so the floor leg is never without /// fixed yield across a roll. +/// @notice Minimal view into a token's decimals, used to derive the PT scale factor. interface IERC20DecimalsLike { + /// @notice The token's decimal precision. + /// @return The number of decimals the token uses. function decimals() external view returns (uint8); } +/// @notice Minimal ERC-4626 view used when the SY redeems to a vault wrapper +/// rather than the deposit asset itself. interface IERC4626Like { + /// @notice The underlying asset the vault wraps. + /// @return The underlying asset address. function asset() external view returns (address); + + /// @notice Redeem vault shares for the underlying asset. + /// @param shares The number of vault shares to redeem. + /// @param receiver The address that receives the underlying asset. + /// @param owner The address whose shares are burned. + /// @return assets The amount of underlying asset returned. function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets); } @@ -41,45 +54,133 @@ contract PendlePTAdapter is IPTAdapter, Ownable { using SafeTransferLib for address; using FixedPointMathLib for uint256; + // ============================================================================ + // Configuration and state + // ============================================================================ + + /// @notice The Pendle router used to buy PT, swap PT for tokens, and redeem PT at maturity. IPendleRouter public immutable router; + + /// @notice The canonical Pendle PY/LP oracle that supplies the PT/asset TWAP rate. IPendlePYLpOracle public immutable oracle; + + /// @notice The deposit asset (e.g. USDC) that this adapter accepts and reports value in. address public immutable asset; - /// @dev token the SY redeems to; if not the asset itself, it must be an - /// ERC-4626 vault on the asset and exits unwrap through it + + /// @dev Token the SY redeems to; if not the asset itself, it must be an + /// ERC-4626 vault on the asset and exits unwrap through it. address public immutable redeemToken; - uint256 internal immutable assetScale; // 10^(18 - assetDecimals) + + /// @dev Scale factor to convert the asset's native decimals to WAD: 10^(18 - assetDecimals). + uint256 internal immutable assetScale; + + /// @notice The TWAP window (seconds) used for all oracle rate reads. uint32 public immutable twapDuration; + /// @notice The Pendle market the position is currently bound to. address public market; + + /// @notice The PT (principal token) of the bound market. address public pt; + + /// @notice The YT (yield token) of the bound market, used to redeem PY at maturity. address public yt; + + /// @notice Maturity timestamp of the bound market. Exits swap through the + /// market before this and redeem PT at par after it. uint256 public maturity; - uint256 public ptScale; // 10^(18 - ptDecimals), re-read on every market bind - address public manager; // SafeLegManager, set once + /// @dev Scale factor to convert PT native decimals to WAD: 10^(18 - ptDecimals). + /// Re-read on every market bind since a new market may have a different PT. + uint256 public ptScale; + + /// @dev The SafeLegManager authorized to drive deposits, withdrawals, and rolls. Set once. + address public manager; + + /// @notice Maximum slippage tolerated on PT buys and exits, in basis points. uint256 public maxSlippageBps = 50; - uint256 public approxWindowBps = 1000; // PT-buy search window above expected + /// @dev Width of the PT-buy binary-search window above the expected fill, in + /// basis points. Widening tolerates larger spot-vs-TWAP divergence. + uint256 public approxWindowBps = 1000; + + /// @dev Seconds in a year, used to annualize the implied rate. uint256 internal constant YEAR = 365 days; + // ============================================================================ + // Events, errors, and modifiers + // ============================================================================ + + /// @notice Emitted when deposit asset is spent to buy PT. + /// @param assets The deposit asset amount spent, in asset native units. + /// @param ptOut The PT received, in PT native units. event Deposited(uint256 assets, uint256 ptOut); + + /// @notice Emitted when PT is exited back to the deposit asset. + /// @param amountWad The requested withdrawal amount, in WAD asset terms. + /// @param ptIn The PT spent on the exit, in PT native units. + /// @param assetsOut The deposit asset delivered, in asset native units. + /// @param viaRedemption True if PT was redeemed at par (matured), false if swapped through the market. event Withdrawn(uint256 amountWad, uint256 ptIn, uint256 assetsOut, bool viaRedemption); + + /// @notice Emitted when the whole position is rolled from one market to the next. + /// @param fromMarket The market exited. + /// @param toMarket The market entered. + /// @param assetsMoved The deposit asset carried across the roll, in asset native units. + /// @param ptOut The PT bought in the new market, in PT native units. event Rolled(address indexed fromMarket, address indexed toMarket, uint256 assetsMoved, uint256 ptOut); + + /// @notice Emitted when the maximum slippage bound is updated. + /// @param bps The new maximum slippage, in basis points. event SlippageSet(uint256 bps); + + /// @notice Emitted when a market's Pendle oracle cardinality is warmed up ahead of binding. + /// @param market The market whose oracle was prepared. + /// @param cardinality The observation cardinality requested. event MarketPrepared(address indexed market, uint16 cardinality); + /// @notice Caller is neither the manager nor the owner. error NotAuthorized(); + + /// @notice A one-time setter was called after it was already set. error AlreadySet(); + + /// @notice The market's oracle TWAP window is not yet satisfied. error OracleNotReady(); + + /// @notice The market cannot accept the deposit asset or redeem to the redeem token, or has expired. error IncompatibleMarket(); + + /// @notice A slippage parameter was outside its allowed range. error BadSlippage(); + + /// @notice A wrapper-unwrap exit delivered fewer assets than the slippage bound allows. error SlippageExceeded(); + /// @notice Restricts a function to the manager or the owner. + /// @dev Reverts NotAuthorized for any other caller. The owner is always + /// allowed so it can act as a fallback manager. modifier onlyManager() { if (msg.sender != manager && msg.sender != owner()) revert NotAuthorized(); _; } + // ============================================================================ + // Setup (owner, one-time wiring) + // ============================================================================ + + /// @notice Deploy the adapter, bind its first market, and set its owner. + /// @dev Records the WAD scale factor from the asset's decimals, validates the + /// redeem token (asset itself or an ERC-4626 vault on the asset), binds + /// the initial market, and grants the router a max asset approval. + /// @param router_ The Pendle router. + /// @param oracle_ The Pendle PY/LP oracle. + /// @param market_ The initial Pendle market to bind. + /// @param asset_ The deposit asset the adapter accepts. + /// @param redeemToken_ The token the SY redeems to (the asset, or an ERC-4626 vault on it). + /// @param assetDecimals The asset's token decimals, used to derive the WAD scale. + /// @param twapDuration_ The TWAP window (seconds) for oracle rate reads. + /// @param owner_ The initial owner address. constructor( address router_, address oracle_, @@ -102,11 +203,15 @@ contract PendlePTAdapter is IPTAdapter, Ownable { asset.safeApprove(router_, type(uint256).max); } + /// @notice Set the manager address once. Can only be assigned while unset. + /// @param manager_ The SafeLegManager authorized to drive the position. function setManager(address manager_) external onlyOwner { if (manager != address(0)) revert AlreadySet(); manager = manager_; } + /// @notice Update the maximum slippage bound applied to PT buys and exits. + /// @param bps The new maximum slippage, in basis points (capped at 500). function setMaxSlippage(uint256 bps) external onlyOwner { if (bps > 500) revert BadSlippage(); maxSlippageBps = bps; @@ -117,17 +222,22 @@ contract PendlePTAdapter is IPTAdapter, Ownable { /// (bps). Wider tolerates larger spot-vs-TWAP divergence before the /// router search range reverts (audit M2). Bounded to keep the /// search from overflowing router math. + /// @param bps The new search window above expected, in basis points (100 to 5000). function setApproxWindowBps(uint256 bps) external onlyOwner { if (bps < 100 || bps > 5000) revert BadSlippage(); approxWindowBps = bps; } /// @notice Return un-deposited deposit asset to the manager (audit M2). + /// @param amount The deposit asset amount to return, in asset native units. + /// @param to The recipient of the returned asset. function reclaim(uint256 amount, address to) external onlyManager { asset.safeTransfer(to, amount); } - // ---------- IPTAdapter ---------- + // ============================================================================ + // IPTAdapter: valuation, deposit, and withdraw + // ============================================================================ /// @notice PT position marked at the oracle TWAP rate, WAD asset terms. function value() public view returns (uint256) { @@ -146,10 +256,18 @@ contract PendlePTAdapter is IPTAdapter, Ownable { return uint256(-lnRate).divWad(timeLeftWad); } + /// @notice Spend deposit asset held by this adapter to buy PT in the bound market. + /// @param assets The deposit asset amount to spend, in asset native units. function deposit(uint256 assets) external onlyManager { _buyPt(assets); } + /// @notice Exit enough PT to raise the requested amount and send the asset to `to`. + /// @dev Sizes the PT to spend from the oracle rate, caps it at the held + /// balance, and exits at par when matured or through the market otherwise. + /// @param amountWad The target withdrawal amount, in WAD asset terms. + /// @param to The recipient of the withdrawn deposit asset. + /// @return assetsOut The deposit asset delivered, in asset native units. function withdraw(uint256 amountWad, address to) external onlyManager returns (uint256 assetsOut) { uint256 rate = _rate(); uint256 ptBal = SafeTransferLib.balanceOf(pt, address(this)); @@ -164,12 +282,15 @@ contract PendlePTAdapter is IPTAdapter, Ownable { emit Withdrawn(amountWad, ptIn, assetsOut, matured); } - // ---------- maturity roll ---------- + // ============================================================================ + // Maturity roll + // ============================================================================ /// @notice Move the entire position into a new market in one transaction. /// The old position exits at the oracle-bounded price (redemption /// at par if matured); the new market must accept the deposit /// asset directly and have a ready oracle. + /// @param newMarket The next Pendle market to bind and re-enter. function rollToMarket(address newMarket) external onlyManager { address oldMarket = market; uint256 ptBal = SafeTransferLib.balanceOf(pt, address(this)); @@ -194,14 +315,22 @@ contract PendlePTAdapter is IPTAdapter, Ownable { /// market setup) so the TWAP window can start filling before the /// roll. Owner-only convenience; the underlying market call is itself /// permissionless, so it can also be triggered directly on the market. + /// @param market_ The market whose oracle cardinality to warm up ahead of binding. function prepareMarket(address market_) external onlyOwner { (bool increaseRequired, uint16 cardinalityRequired,) = oracle.getOracleState(market_, twapDuration); if (increaseRequired) IPendleMarket(market_).increaseObservationsCardinalityNext(cardinalityRequired); emit MarketPrepared(market_, cardinalityRequired); } - // ---------- internal ---------- + // ============================================================================ + // Internal helpers + // ============================================================================ + /// @dev Bind the adapter to a market: validate the SY accepts the asset and + /// redeems to the redeem token, warm and require a ready oracle, cache + /// the PT/YT/maturity/scale, and approve PT to the router. Reverts if + /// the market is incompatible, its oracle is not ready, or it has expired. + /// @param market_ The Pendle market to bind. function _bindMarket(address market_) internal { (address sy, address pt_, address yt_) = IPendleMarket(market_).readTokens(); if (!IStandardizedYield(sy).isValidTokenIn(asset) || !IStandardizedYield(sy).isValidTokenOut(redeemToken)) { @@ -225,6 +354,11 @@ contract PendlePTAdapter is IPTAdapter, Ownable { pt_.safeApprove(address(router), type(uint256).max); } + /// @dev Buy PT with the given deposit asset amount, bounding the fill at + /// maxSlippageBps below the oracle-expected PT and searching within an + /// oracle-anchored window above it. + /// @param assets The deposit asset amount to spend, in asset native units. + /// @return netPtOut The PT received, in PT native units. function _buyPt(uint256 assets) internal returns (uint256 netPtOut) { // expected PT = assets / rate; bound the fill at maxSlippageBps below uint256 expectedPt = (assets * assetScale).divWad(_rate()) / ptScale; @@ -245,11 +379,18 @@ contract PendlePTAdapter is IPTAdapter, Ownable { emit Deposited(assets, netPtOut); } + /// @dev Minimum acceptable asset output for exiting ptIn, the oracle-fair + /// value discounted by maxSlippageBps, in asset native units. + /// @param ptIn The PT being exited, in PT native units. + /// @param rate The oracle PT/asset rate, in WAD. + /// @return The slippage-bounded minimum asset output, in asset native units. function _minAssetsOut(uint256 ptIn, uint256 rate) internal view returns (uint256) { uint256 fairWad = (ptIn * ptScale).mulWad(rate); return (fairWad * (10_000 - maxSlippageBps) / 10_000) / assetScale; } + /// @dev The current oracle PT/asset TWAP rate over twapDuration. + /// @return The PT/asset rate, in WAD. function _rate() internal view returns (uint256) { return oracle.getPtToAssetRate(market, twapDuration); } @@ -258,6 +399,10 @@ contract PendlePTAdapter is IPTAdapter, Ownable { /// SY redeems to a 4626 wrapper instead of the asset, unwrap it and /// enforce the slippage bound on the FINAL asset amount, since the /// router leg is denominated in wrapper shares. + /// @param ptIn The PT to exit, in PT native units. + /// @param matured True to redeem PT at par, false to swap it through the market. + /// @param minAssetsOut The minimum acceptable asset output, in asset native units. + /// @return assetsOut The deposit asset obtained, in asset native units. function _exitPt(uint256 ptIn, bool matured, uint256 minAssetsOut) internal returns (uint256 assetsOut) { bool direct = redeemToken == asset; uint256 routerMin = direct ? minAssetsOut : 1; @@ -276,6 +421,10 @@ contract PendlePTAdapter is IPTAdapter, Ownable { } } + /// @dev Build the router TokenOutput that redeems the SY to the redeem token + /// with no external aggregator swap. + /// @param minOut The minimum token output enforced by the router leg. + /// @return The populated TokenOutput for the exit. function _tokenOutput(uint256 minOut) internal view returns (TokenOutput memory) { return TokenOutput({ tokenOut: redeemToken, @@ -286,5 +435,7 @@ contract PendlePTAdapter is IPTAdapter, Ownable { }); } + /// @dev An empty limit-order struct: this adapter never uses Pendle limit orders. + /// @return limit A zero-initialized LimitOrderData. function _emptyLimit() internal pure returns (LimitOrderData memory limit) {} } diff --git a/src/RiskyLegManager.sol b/src/RiskyLegManager.sol index 038b161..4d4bf88 100644 --- a/src/RiskyLegManager.sol +++ b/src/RiskyLegManager.sol @@ -19,21 +19,55 @@ contract RiskyLegManager is ILeg, Ownable { using SafeTransferLib for address; using FixedPointMathLib for uint256; + // ============================================================================ + // Configuration and state + // ============================================================================ + + /// @notice WETH: the base risky-leg exposure and the front-line sell asset. address public immutable weth; + + /// @notice wstETH: the optional, capped yield-bearing fraction of the leg. address public immutable wsteth; + + /// @notice Oracle price source used to mark both tokens in USD WAD. IPriceSource public priceSource; + + /// @notice The execution module; the only non-owner address allowed to pull tokens out. address public executor; + + /// @notice The keeper role, kept for periphery symmetry. It is never granted + /// custody here (onlyRouter excludes it), so a compromised keeper + /// cannot move risky-leg tokens. address public keeper; - /// @notice Max wstETH share of the risky leg (spec: <= 50%). + /// @notice Target wstETH share of the risky leg, in basis points (spec: <= 50%). uint16 public wstethTargetBps = 0; + + /// @notice Hard cap on the wstETH target share, in basis points (50%). uint16 public constant WSTETH_CAP_BPS = 5000; + // ============================================================================ + // Events, errors, and modifiers + // ============================================================================ + + /// @notice Emitted when tokens are handed to a recipient on a de-risk or trim flow. + /// @param to The recipient (the executor). + /// @param amountWad The requested value delivered, in USD WAD. + /// @param wethOut The WETH transferred out, in wei. + /// @param wstethOut The wstETH transferred out, in wei. event Provided(address indexed to, uint256 amountWad, uint256 wethOut, uint256 wstethOut); + + /// @notice Emitted when the wstETH target share is updated. + /// @param bps The new wstETH target, in basis points. event WstethTargetSet(uint16 bps); + /// @notice Caller is not an authorized router (the executor or the owner). error NotAuthorized(); + + /// @notice The requested wstETH target exceeds the hard cap. error AboveCap(); + + /// @notice The leg cannot cover the requested value. error InsufficientValue(); /// @dev Value routing to a caller-chosen recipient. Excludes the keeper by @@ -45,43 +79,68 @@ contract RiskyLegManager is ILeg, Ownable { _; } + // ============================================================================ + // Wiring (owner setup) + // ============================================================================ + + /// @notice Deploy the risky leg over its two token addresses. + /// @param weth_ The WETH token address. + /// @param wsteth_ The wstETH token address. + /// @param owner_ The initial owner. constructor(address weth_, address wsteth_, address owner_) { weth = weth_; wsteth = wsteth_; _initializeOwner(owner_); } + /// @notice Wire the price source and the executor/keeper roles. + /// @param priceSource_ The oracle price source used to mark the tokens. + /// @param executor_ The execution module allowed to pull tokens. + /// @param keeper_ The keeper address. function setPeriphery(IPriceSource priceSource_, address executor_, address keeper_) external onlyOwner { priceSource = priceSource_; executor = executor_; keeper = keeper_; } + /// @notice Set the target wstETH share of the leg, bounded by the hard cap. + /// @param bps The new wstETH target, in basis points (must be <= WSTETH_CAP_BPS). function setWstethTarget(uint16 bps) external onlyOwner { if (bps > WSTETH_CAP_BPS) revert AboveCap(); wstethTargetBps = bps; emit WstethTargetSet(bps); } - // ---------- ILeg ---------- + // ============================================================================ + // ILeg views + // ============================================================================ + /// @notice Total risky-leg value: WETH plus wstETH, each marked in USD WAD. + /// @return The leg value, in USD WAD. function value() public view returns (uint256) { return _wethBal().mulWad(priceSource.ethUsdWad()) + _wstethBal().mulWad(priceSource.wstethUsdWad()); } /// @notice wstETH share of the current risky leg, bps. Keeper input for /// composition rebalancing via the executor. + /// @return The wstETH share of leg value, in basis points (0 when the leg is empty). function wstethShareBps() external view returns (uint256) { uint256 total = value(); if (total == 0) return 0; return _wstethBal().mulWad(priceSource.wstethUsdWad()) * 10_000 / total; } - // ---------- flows ---------- + // ============================================================================ + // Flows + // ============================================================================ /// @notice Hand tokens worth `amountWad` USD to `to` (the executor, which /// swaps them to the deposit asset). WETH leaves first; wstETH is /// the reserve for when WETH is exhausted. + /// @param amountWad The value to deliver, in USD WAD. + /// @param to The recipient (the executor). + /// @return wethOut The WETH transferred out, in wei. + /// @return wstethOut The wstETH transferred out, in wei. function provide(uint256 amountWad, address to) external onlyRouter returns (uint256 wethOut, uint256 wstethOut) { uint256 ethUsd = priceSource.ethUsdWad(); uint256 wstUsd = priceSource.wstethUsdWad(); @@ -104,17 +163,26 @@ contract RiskyLegManager is ILeg, Ownable { } /// @notice Hand a specific token to the executor for composition trims. + /// @param token The token to transfer (must be WETH or wstETH). + /// @param amount The token amount to transfer, in the token's native units. + /// @param to The recipient (the executor). function provideToken(address token, uint256 amount, address to) external onlyRouter { if (token != weth && token != wsteth) revert NotAuthorized(); token.safeTransfer(to, amount); } - // ---------- internal ---------- + // ============================================================================ + // Internal helpers + // ============================================================================ + /// @notice This leg's current WETH balance. + /// @return The WETH balance, in wei. function _wethBal() internal view returns (uint256) { return SafeTransferLib.balanceOf(weth, address(this)); } + /// @notice This leg's current wstETH balance. + /// @return The wstETH balance, in wei. function _wstethBal() internal view returns (uint256) { return SafeTransferLib.balanceOf(wsteth, address(this)); } diff --git a/src/SafeLegManager.sol b/src/SafeLegManager.sol index fbf0239..f82c97d 100644 --- a/src/SafeLegManager.sol +++ b/src/SafeLegManager.sol @@ -8,6 +8,8 @@ import {IPTAdapter} from "./interfaces/IPTAdapter.sol"; /// @notice Minimal view the manager needs from the vault for buffer sizing. interface INavSource { + /// @notice Total value in the vault system, used to size the buffer bands. + /// @return The vault's total NAV, in WAD. function totalNav() external view returns (uint256); } @@ -22,25 +24,71 @@ interface INavSource { contract SafeLegManager is ILeg, Ownable { using SafeTransferLib for address; + // ============================================================================ + // Configuration and state + // ============================================================================ + + /// @notice The vault this leg serves; the NAV source used for buffer sizing. address public immutable vault; + + /// @notice The deposit asset (e.g. USDC) held in the liquid buffer. address public immutable asset; + + /// @dev Scale factor to convert the asset's native decimals to WAD (18 decimals). uint256 internal immutable assetScale; + /// @notice The PT adapter holding the fixed-yield floor funding behind the buffer. IPTAdapter public pt; - address public executor; // may pull for risky-leg buys + + /// @notice The execution module authorized to pull buffer funds for risky-leg buys. + address public executor; + + /// @notice The keeper authorized to run recipient-less buffer maintenance. address public keeper; + /// @notice Target buffer size as a fraction of vault totalNav, in basis points. uint16 public bufferTargetBps = 300; + + /// @notice Lower band for the buffer as a fraction of vault totalNav, in basis points. uint16 public bufferMinBps = 100; + + /// @notice Upper band for the buffer as a fraction of vault totalNav, in basis points. uint16 public bufferMaxBps = 500; + // ============================================================================ + // Events, errors, and modifiers + // ============================================================================ + + /// @notice Emitted when transferred-in assets are allocated across buffer and PT. + /// @param assetsWad The buffer balance observed at allocation time, in WAD. + /// @param toBufferWad The amount retained in the buffer, in WAD. + /// @param toPtWad The amount routed into PT, in WAD. event Inflow(uint256 assetsWad, uint256 toBufferWad, uint256 toPtWad); + + /// @notice Emitted when value is delivered out of the safe leg to a recipient. + /// @param to The recipient of the delivered assets. + /// @param amountWad The requested delivery amount, in WAD. + /// @param fromBufferWad The portion sourced from the buffer, in WAD. + /// @param fromPtWad The portion sourced from PT, in WAD. event Provided(address indexed to, uint256 amountWad, uint256 fromBufferWad, uint256 fromPtWad); + + /// @notice Emitted when keeper maintenance moves value between buffer and PT. + /// @param deltaWad Signed change in the buffer applied by the rebalance, in WAD. event BufferRebalanced(int256 deltaWad); + + /// @notice Emitted when the buffer bands are updated. + /// @param minBps The new lower band, in basis points. + /// @param targetBps The new target, in basis points. + /// @param maxBps The new upper band, in basis points. event BandsSet(uint16 minBps, uint16 targetBps, uint16 maxBps); + /// @notice Caller is not an authorized router or ops address for this action. error NotAuthorized(); + + /// @notice Buffer bands were set out of order or above the hard cap. error BadBands(); + + /// @notice The leg cannot cover the requested value. error InsufficientValue(); /// @dev Value routing to a caller-chosen recipient. Excludes the keeper: @@ -62,6 +110,15 @@ contract SafeLegManager is ILeg, Ownable { _; } + // ============================================================================ + // Wiring (owner setup) + // ============================================================================ + + /// @notice Deploy the safe leg bound to a vault and its deposit asset. + /// @param vault_ The vault this leg serves and reads totalNav from. + /// @param asset_ The deposit asset held in the buffer. + /// @param assetDecimals The asset's native decimals, used to derive the WAD scale. + /// @param owner_ The initial owner. constructor(address vault_, address asset_, uint8 assetDecimals, address owner_) { vault = vault_; asset = asset_; @@ -69,12 +126,20 @@ contract SafeLegManager is ILeg, Ownable { _initializeOwner(owner_); } + /// @notice Wire the PT adapter and the executor/keeper roles. + /// @param pt_ The PT adapter holding the floor funding. + /// @param executor_ The execution module allowed to pull for risky-leg buys. + /// @param keeper_ The keeper allowed to run buffer maintenance. function setPeriphery(IPTAdapter pt_, address executor_, address keeper_) external onlyOwner { pt = pt_; executor = executor_; keeper = keeper_; } + /// @notice Update the buffer bands, enforcing min <= target <= max <= 1000 bps. + /// @param minBps The new lower band, in basis points. + /// @param targetBps The new target, in basis points. + /// @param maxBps The new upper band, in basis points. function setBands(uint16 minBps, uint16 targetBps, uint16 maxBps) external onlyOwner { if (minBps > targetBps || targetBps > maxBps || maxBps > 1000) revert BadBands(); bufferTargetBps = targetBps; @@ -83,21 +148,31 @@ contract SafeLegManager is ILeg, Ownable { emit BandsSet(minBps, targetBps, maxBps); } - // ---------- ILeg ---------- + // ============================================================================ + // ILeg views + // ============================================================================ + /// @notice Total safe-leg value: the liquid buffer plus the PT tranche. + /// @return The leg value, in WAD. function value() public view returns (uint256) { return bufferWad() + pt.value(); } + /// @notice The liquid deposit-asset buffer currently held by this leg. + /// @return The buffer balance, in WAD. function bufferWad() public view returns (uint256) { return SafeTransferLib.balanceOf(asset, address(this)) * assetScale; } + /// @notice The implied fixed rate of the underlying PT, used by the strategy. + /// @return The implied rate, in WAD. function impliedRateWad() external view returns (uint256) { return pt.impliedRateWad(); } - // ---------- flows ---------- + // ============================================================================ + // Flows + // ============================================================================ /// @notice Allocate assets already transferred to this contract: refill /// the buffer to target, buy PT with the rest. @@ -118,6 +193,8 @@ contract SafeLegManager is ILeg, Ownable { /// back to the buffer. onInflow runs inside the permissionless /// emergency rebalance, so a PT-buy revert must not unwind the /// de-risk. Returns whether the buy succeeded. + /// @param assets The deposit-asset amount to move into PT, in native units. + /// @return True if the PT deposit succeeded, false if it was reclaimed to the buffer. function _buyPtBestEffort(uint256 assets) internal returns (bool) { asset.safeTransfer(address(pt), assets); try pt.deposit(assets) { @@ -138,6 +215,9 @@ contract SafeLegManager is ILeg, Ownable { /// bound, the buffer portion is still delivered and the shortfall /// simply reduces `deliveredAssets`, so the emergency de-risk and /// redemption funding are never bricked by PT market conditions. + /// @param amountWad The value to deliver, in WAD. + /// @param to The recipient of the delivered deposit asset. + /// @return deliveredAssets The deposit asset actually delivered, in native units. function provide(uint256 amountWad, address to) external onlyRouter returns (uint256 deliveredAssets) { uint256 buf = bufferWad(); // The buffer-only payout must stay oracle-independent (audit M1 residual): @@ -200,8 +280,15 @@ contract SafeLegManager is ILeg, Ownable { } } - // ---------- internal ---------- + // ============================================================================ + // Internal helpers + // ============================================================================ + /// @notice Band size as a fraction of vault totalNav. + /// @dev Reverts if totalNav reverts (e.g. a PT-oracle outage through + /// safeLeg.value()); use _bandWadOrZero on the outbound payout path. + /// @param bps The band as a fraction of totalNav, in basis points. + /// @return The band size, in WAD. function _bandWad(uint256 bps) internal view returns (uint256) { return INavSource(vault).totalNav() * bps / 10_000; } diff --git a/src/interfaces/IExecutionPeriphery.sol b/src/interfaces/IExecutionPeriphery.sol index 61f2ece..2b8789b 100644 --- a/src/interfaces/IExecutionPeriphery.sol +++ b/src/interfaces/IExecutionPeriphery.sol @@ -1,18 +1,41 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -/// @notice USD price source for the risky-leg assets, WAD. Implemented by the -/// OracleHub (Chainlink + wstETH basis checks); mocked until it lands. +/// @title IPriceSource +/// @notice USD price source for the risky-leg assets, in WAD. Feeds the +/// execution module's slippage anchoring and the risky-leg mark. +/// @dev Implemented by the OracleHub (Chainlink plus wstETH basis checks) and +/// mocked until that lands. interface IPriceSource { + /// @notice Latest ETH/USD price. + /// @return ethUsd The ETH price in USD, in WAD. function ethUsdWad() external view returns (uint256); + + /// @notice Latest wstETH/USD price. + /// @return wstethUsd The wstETH price in USD, in WAD. function wstethUsdWad() external view returns (uint256); - /// @notice False when the wstETH rate-vs-pool basis breaches its limit - /// (or the feed is stale): composition buys must not proceed. + + /// @notice Whether composition buys into wstETH are currently allowed. False + /// when the wstETH rate-vs-pool basis breaches its limit or the feed + /// is stale, in which case composition buys must not proceed. + /// @return allowed Whether wstETH buys are permitted right now. function wstethBuyAllowed() external view returns (bool); } -/// @notice Minimal Uniswap V3 SwapRouter02 surface (no deadline field). +/// @title ISwapRouter02 +/// @notice Minimal Uniswap V3 SwapRouter02 surface the execution module uses to +/// swap between deposit asset and risky-leg tokens. +/// @dev Mirrors SwapRouter02, which drops the deadline field present on the +/// original SwapRouter. interface ISwapRouter02 { + /// @notice Parameters for a single-hop exact-input swap. + /// @param tokenIn The token being sold. + /// @param tokenOut The token being bought. + /// @param fee The pool fee tier for the tokenIn/tokenOut pair. + /// @param recipient The address that receives tokenOut. + /// @param amountIn The exact amount of tokenIn to swap. + /// @param amountOutMinimum The minimum acceptable tokenOut, enforcing slippage. + /// @param sqrtPriceLimitX96 The price limit for the swap (0 for no limit). struct ExactInputSingleParams { address tokenIn; address tokenOut; @@ -23,5 +46,9 @@ interface ISwapRouter02 { uint160 sqrtPriceLimitX96; } + /// @notice Swap an exact amount of one token for as much of another as the + /// pool allows, above the caller's minimum. + /// @param params The single-hop exact-input swap parameters. + /// @return amountOut The amount of tokenOut delivered to the recipient. function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut); } diff --git a/src/interfaces/IOracleHealth.sol b/src/interfaces/IOracleHealth.sol new file mode 100644 index 0000000..173fec3 --- /dev/null +++ b/src/interfaces/IOracleHealth.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IOracleHealth +/// @notice Optional oracle-health signal the vault reads to gate user flows and +/// to widen the emergency de-risk bound while a feed is unreliable. +interface IOracleHealth { + /// @notice True when every feed the vault depends on is fresh and within its + /// sanity bounds. When false, new deposits/redeems and settlement are + /// gated and the emergency de-risk uses the wider degraded bound. + /// @return healthy_ Whether the oracle stack is currently healthy. + function healthy() external view returns (bool healthy_); + + /// @notice True when the ETH feed has been stale beyond its prolonged-staleness + /// window, i.e. the risky-leg mark is frozen and the normal CPPI trigger + /// is blind to a real decline (audit M4). Enables the permissionless + /// prolonged-staleness circuit breaker. + /// @return stale Whether the feed has been stale beyond the prolonged window. + function prolongedStale() external view returns (bool stale); +} diff --git a/src/interfaces/IPTAdapter.sol b/src/interfaces/IPTAdapter.sol index 55a919a..9bfd53b 100644 --- a/src/interfaces/IPTAdapter.sol +++ b/src/interfaces/IPTAdapter.sol @@ -1,31 +1,46 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; +/// @title IPTAdapter /// @notice Fixed-yield tranche of the safe leg. v1 implementation is a Pendle /// PT position; the mock stands in until the fork-tested adapter lands. /// @dev All values WAD asset terms. Implementations own their PT tokens and /// price them via the PT oracle; selling before maturity realizes /// whatever the market pays (duration risk is the holder's). interface IPTAdapter { - /// @notice Current value of the PT position, WAD asset terms. - function value() external view returns (uint256); + /// @notice Current value of the PT position, WAD asset terms. The safe leg + /// reads it to mark the capital-protection side into vault NAV. + /// @return valueWad The PT position's current value, in WAD asset terms. + function value() external view returns (uint256 valueWad); - /// @notice Live PT-implied yield (feeds the controller's floor marking). - function impliedRateWad() external view returns (uint256); + /// @notice Live PT-implied yield. The controller reads it to discount the + /// protected amount when marking the floor. + /// @return rateWad The PT-implied yield, in WAD. + function impliedRateWad() external view returns (uint256 rateWad); /// @notice Buy PT with `assets` deposit-asset units held by the caller. /// Caller must have transferred the assets to the adapter first. + /// The vault uses it to deploy idle asset into the fixed-yield leg. + /// @param assets The deposit-asset units to spend on PT, in native decimals. function deposit(uint256 assets) external; /// @notice Return up to `amount` of un-deposited deposit asset held by the /// adapter to `to` (the manager). Used to recover funds when a PT /// buy is skipped/failed so nothing strands (audit M2). + /// @param amount The maximum deposit-asset units to return, in native decimals. + /// @param to The recipient that receives the returned asset (the manager). function reclaim(uint256 amount, address to) external; - /// @notice Sell/redeem PT worth `amountWad` and send proceeds to `to`. - /// @return assetsOut deposit-asset units actually delivered + /// @notice Sell/redeem PT worth `amountWad` and send proceeds to `to`. The + /// vault calls it to free asset for redemption settlement or to + /// shift value out of the safe leg on a rebalance. + /// @param amountWad The PT value to sell/redeem, in WAD asset terms. + /// @param to The recipient that receives the sale proceeds. + /// @return assetsOut The deposit-asset units actually delivered, in native decimals. function withdraw(uint256 amountWad, address to) external returns (uint256 assetsOut); - /// @notice Maturity timestamp of the currently held PT series. - function maturity() external view returns (uint256); + /// @notice Maturity timestamp of the currently held PT series. The vault + /// reads it to align the term with the PT's zero-coupon maturity. + /// @return maturityTs The PT series maturity, as a Unix timestamp. + function maturity() external view returns (uint256 maturityTs); } diff --git a/src/interfaces/IVaultPeriphery.sol b/src/interfaces/IVaultPeriphery.sol index a22f8a4..0953b6e 100644 --- a/src/interfaces/IVaultPeriphery.sol +++ b/src/interfaces/IVaultPeriphery.sol @@ -1,24 +1,45 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -/// @notice A vault leg (safe or risky) reporting its value in WAD asset terms. +/// @title ILeg +/// @notice A vault leg (safe or risky) that reports the current value of the +/// assets it holds, so the vault can mark NAV without knowing the leg's +/// internal composition. interface ILeg { + /// @notice Current mark of everything the leg holds, in WAD asset terms. + /// @return valueWad The leg's value in WAD asset terms. function value() external view returns (uint256); } -/// @notice Execution module: routes rebalance flows between legs and frees -/// idle assets for redemption settlement. Implementations must be -/// atomic; the emergency path depends on it (spec invariant 5). +/// @title IExecutionModule +/// @notice Execution module that routes rebalance flows between the safe and +/// risky legs and frees idle assets for redemption settlement. The +/// vault relies on this surface for both scheduled rebalances and the +/// permissionless emergency de-risk. +/// @dev Implementations must be atomic; the emergency path depends on it +/// (spec invariant 5). interface IExecutionModule { - /// @param deltaWad positive: buy risky with safe-side value; negative: sell risky - /// @param maxSlippageBps execution bound, oracle-anchored + /// @notice Move exposure between legs to apply a signed rebalance delta, + /// subject to an oracle-anchored slippage bound. The vault calls + /// this to reach the controller's target risky exposure. + /// @param deltaWad Signed change in risky exposure, in WAD: positive buys + /// risky with safe-side value, negative sells risky. + /// @param maxSlippageBps Execution slippage bound in basis points, + /// oracle-anchored. function executeRebalance(int256 deltaWad, uint256 maxSlippageBps) external; - /// @notice Unwind safe-side value into idle deposit asset held by the vault. + /// @notice Unwind safe-side value into idle deposit asset held by the vault, + /// so the keeper can pre-fund a redemption settlement. + /// @param amountWad The amount of safe-side value to free, in WAD asset terms. function freeAssets(uint256 amountWad) external; } -/// @notice Live PT-implied yield source (clamped downstream by the controller). +/// @title IRateOracle +/// @notice Live PT-implied yield source the CPPI strategy reads to mark its +/// floor and size the risky allocation. +/// @dev The rate is clamped downstream by the controller. interface IRateOracle { + /// @notice Current PT-implied yield used by the controller. + /// @return rate The implied rate, in WAD (1e18 == 100%). function rateWad() external view returns (uint256); } diff --git a/src/interfaces/pendle/IPendle.sol b/src/interfaces/pendle/IPendle.sol index c96b670..948ff00 100644 --- a/src/interfaces/pendle/IPendle.sol +++ b/src/interfaces/pendle/IPendle.sol @@ -1,11 +1,23 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; -// Minimal Pendle V2 interfaces for the PT adapter: RouterV4 swap actions, -// market token discovery, and the canonical PY/LP oracle. Structs mirror -// Pendle's IPAllActionTypeV3 ABI exactly; we always pass empty limit-order -// data and no external aggregator (fully onchain path). - +/// @notice Minimal Pendle V2 interfaces for the PT adapter: RouterV4 swap +/// actions, market token discovery, and the canonical PY/LP oracle. +/// @dev The structs mirror Pendle's IPAllActionTypeV3 ABI exactly. The adapter +/// always passes empty limit-order data and no external aggregator (a fully +/// onchain path), so the aggregator-related fields are left at their zero +/// values. + +// ============================================================================ +// Router action types (mirror Pendle's ABI) +// ============================================================================ + +/// @notice External-aggregator routing data for a swap. Left empty (swapType +/// NONE) on the adapter's fully-onchain path. +/// @param swapType The aggregator to route through (NONE for onchain-only). +/// @param extRouter The external router address (unused when NONE). +/// @param extCalldata The external router calldata (unused when NONE). +/// @param needScale Whether the external call needs input scaling. struct SwapData { SwapType swapType; address extRouter; @@ -13,17 +25,25 @@ struct SwapData { bool needScale; } +/// @notice The external aggregator to route a swap through. The adapter always +/// uses NONE (fully onchain). enum SwapType { - NONE, + NONE, // no external aggregator; swap entirely through the SY/market KYBERSWAP, ODOS, - ETH_WETH, + ETH_WETH, // wrap/unwrap only OKX, ONE_INCH, RESERVE_1, RESERVE_2 } +/// @notice Input specification for minting SY / buying PT from a token. +/// @param tokenIn The token supplied. +/// @param netTokenIn The amount of tokenIn supplied. +/// @param tokenMintSy The token the SY is minted from. +/// @param pendleSwap The Pendle swap helper (unused on the onchain path). +/// @param swapData The external-aggregator routing data (empty on the onchain path). struct TokenInput { address tokenIn; uint256 netTokenIn; @@ -32,6 +52,12 @@ struct TokenInput { SwapData swapData; } +/// @notice Output specification for redeeming SY / selling PT to a token. +/// @param tokenOut The token to receive. +/// @param minTokenOut The minimum acceptable tokenOut (slippage bound). +/// @param tokenRedeemSy The token the SY is redeemed to. +/// @param pendleSwap The Pendle swap helper (unused on the onchain path). +/// @param swapData The external-aggregator routing data (empty on the onchain path). struct TokenOutput { address tokenOut; uint256 minTokenOut; @@ -40,6 +66,12 @@ struct TokenOutput { SwapData swapData; } +/// @notice Binary-search parameters bounding Pendle's PT approximation. +/// @param guessMin Lower bound of the search. +/// @param guessMax Upper bound of the search. +/// @param guessOffchain Optional offchain-computed guess (0 to ignore). +/// @param maxIteration Max search iterations. +/// @param eps Convergence tolerance, in WAD. struct ApproxParams { uint256 guessMin; uint256 guessMax; @@ -48,6 +80,7 @@ struct ApproxParams { uint256 eps; } +/// @notice Side of a Pendle limit order. Unused by the adapter (no limit orders). enum OrderType { SY_FOR_PT, PT_FOR_SY, @@ -55,6 +88,19 @@ enum OrderType { YT_FOR_SY } +/// @notice A Pendle limit order. Unused by the adapter; documented to mirror the ABI. +/// @param salt Order salt for uniqueness. +/// @param expiry Order expiry timestamp. +/// @param nonce Maker nonce. +/// @param orderType The order side. +/// @param token The token involved. +/// @param YT The yield token of the market. +/// @param maker The order maker. +/// @param receiver The fill receiver. +/// @param makingAmount The amount offered by the maker. +/// @param lnImpliedRate The log implied rate of the order. +/// @param failSafeRate The fail-safe rate bound. +/// @param permit Optional permit calldata. struct Order { uint256 salt; uint256 expiry; @@ -70,12 +116,22 @@ struct Order { bytes permit; } +/// @notice Parameters to fill a single limit order. Unused by the adapter. +/// @param order The order to fill. +/// @param signature The maker's signature. +/// @param makingAmount The amount to fill. struct FillOrderParams { Order order; bytes signature; uint256 makingAmount; } +/// @notice Limit-order routing block. The adapter always passes this empty. +/// @param limitRouter The limit-order router (address(0) when unused). +/// @param epsSkipMarket Tolerance for skipping the AMM in favor of orders. +/// @param normalFills Standard fills to attempt. +/// @param flashFills Flash fills to attempt. +/// @param optData Optional extra routing data. struct LimitOrderData { address limitRouter; uint256 epsSkipMarket; @@ -84,7 +140,23 @@ struct LimitOrderData { bytes optData; } +// ============================================================================ +// Router, market, SY, and oracle interfaces +// ============================================================================ + +/// @notice The Pendle RouterV4 actions the adapter uses: buy PT, sell PT, and +/// redeem PY at maturity. interface IPendleRouter { + /// @notice Swap an exact amount of a token for PT (buy PT). + /// @param receiver The address that receives the PT. + /// @param market The Pendle market to trade in. + /// @param minPtOut The minimum acceptable PT out (slippage bound). + /// @param guessPtOut The approximation search bounds for the fill. + /// @param input The token-input specification. + /// @param limit The limit-order routing block (empty on the onchain path). + /// @return netPtOut The PT received. + /// @return netSyFee The SY fee paid. + /// @return netSyInterm The intermediate SY amount routed. function swapExactTokenForPt( address receiver, address market, @@ -94,6 +166,15 @@ interface IPendleRouter { LimitOrderData calldata limit ) external payable returns (uint256 netPtOut, uint256 netSyFee, uint256 netSyInterm); + /// @notice Swap an exact amount of PT for a token (sell PT before maturity). + /// @param receiver The address that receives the token. + /// @param market The Pendle market to trade in. + /// @param exactPtIn The PT amount to sell. + /// @param output The token-output specification (carries minTokenOut). + /// @param limit The limit-order routing block (empty on the onchain path). + /// @return netTokenOut The token received. + /// @return netSyFee The SY fee paid. + /// @return netSyInterm The intermediate SY amount routed. function swapExactPtForToken( address receiver, address market, @@ -102,25 +183,68 @@ interface IPendleRouter { LimitOrderData calldata limit ) external returns (uint256 netTokenOut, uint256 netSyFee, uint256 netSyInterm); + /// @notice Redeem PY (PT at/after maturity) to a token at par. + /// @param receiver The address that receives the token. + /// @param YT The yield token identifying the PY. + /// @param netPyIn The PY amount to redeem. + /// @param output The token-output specification (carries minTokenOut). + /// @return netTokenOut The token received. + /// @return netSyInterm The intermediate SY amount routed. function redeemPyToToken(address receiver, address YT, uint256 netPyIn, TokenOutput calldata output) external returns (uint256 netTokenOut, uint256 netSyInterm); } +/// @notice The Pendle market surface the adapter reads for token discovery, +/// oracle warmup, and maturity. interface IPendleMarket { + /// @notice The market's SY, PT, and YT token addresses. + /// @return sy The standardized-yield token. + /// @return pt The principal token. + /// @return yt The yield token. function readTokens() external view returns (address sy, address pt, address yt); + + /// @notice Grow the market's oracle observation cardinality (permissionless setup). + /// @param cardinalityNext The target observation cardinality. function increaseObservationsCardinalityNext(uint16 cardinalityNext) external; + + /// @notice The market's maturity timestamp. + /// @return The maturity timestamp. function expiry() external view returns (uint256); + + /// @notice Whether the market has passed maturity. + /// @return True if expired. function isExpired() external view returns (bool); } +/// @notice The market's SY surface used to validate the deposit/redeem token. interface IStandardizedYield { + /// @notice Whether a token can mint this SY. + /// @param token The token to check. + /// @return True if the token is a valid mint input. function isValidTokenIn(address token) external view returns (bool); + + /// @notice Whether this SY can redeem to a token. + /// @param token The token to check. + /// @return True if the token is a valid redeem output. function isValidTokenOut(address token) external view returns (bool); } +/// @notice The canonical Pendle PY/LP oracle used for valuation and warmup. interface IPendlePYLpOracle { + /// @notice PT-to-asset TWAP rate for a market over a duration. + /// @param market The Pendle market. + /// @param duration The TWAP window, in seconds. + /// @return The PT/asset rate, in WAD. function getPtToAssetRate(address market, uint32 duration) external view returns (uint256); + + /// @notice Oracle readiness for a market/duration: whether cardinality must + /// grow and whether the TWAP window is already satisfied. + /// @param market The Pendle market. + /// @param duration The TWAP window, in seconds. + /// @return increaseCardinalityRequired Whether cardinality must be increased. + /// @return cardinalityRequired The cardinality needed for the duration. + /// @return oldestObservationSatisfied Whether the window is already covered. function getOracleState(address market, uint32 duration) external view diff --git a/src/libraries/CPPIMath.sol b/src/libraries/CPPIMath.sol index 7e5f2a8..b38df2a 100644 --- a/src/libraries/CPPIMath.sol +++ b/src/libraries/CPPIMath.sol @@ -12,37 +12,72 @@ import {FixedPointMathLib} from "solady/utils/FixedPointMathLib.sol"; /// of exactly floorValue() accretes to the protected amount at /// maturity with no dependence on the risky asset. library CPPIMath { + // ============================================================================ + // Constants + // ============================================================================ + + /// @dev Fixed-point scale: 1e18 represents 1.0. uint256 internal constant WAD = 1e18; + + /// @dev Basis-point scale: 10_000 bps represents 100%. uint256 internal constant BPS = 10_000; + + /// @dev Seconds in a year, the denominator for annualized-rate discounting. uint256 internal constant YEAR = 365 days; - /// @notice Present value of the protected amount `secondsLeft` before maturity. - /// @param protectedAmount amount guaranteed at maturity (WAD-scaled asset units) - /// @param rateWad continuously compounded safe-leg rate, e.g. 0.04e18 + // ============================================================================ + // CPPI math + // ============================================================================ + + /// @notice Present value of the protected amount `secondsLeft` before + /// maturity, discounting continuously at the safe-leg rate: + /// protectedAmount * e^(-rateWad * secondsLeft / YEAR). At maturity + /// (secondsLeft == 0) the present value equals the protected amount. + /// @param protectedAmount Amount guaranteed at maturity, in WAD-scaled asset units. + /// @param rateWad Continuously compounded safe-leg rate, WAD (e.g. 0.04e18). + /// @param secondsLeft Seconds remaining until maturity. + /// @return The discounted present value of the protected amount, WAD. function floorValue(uint256 protectedAmount, uint256 rateWad, uint256 secondsLeft) internal pure returns (uint256) { if (secondsLeft == 0) return protectedAmount; int256 exponent = -int256(rateWad * secondsLeft / YEAR); return FixedPointMathLib.mulWad(protectedAmount, uint256(FixedPointMathLib.expWad(exponent))); } + /// @notice The cushion, nav - floor, floored at zero when NAV is below the floor. + /// @param nav Total value in the system, WAD. + /// @param floor The protected floor value, WAD. + /// @return The non-negative cushion, WAD. function cushion(uint256 nav, uint256 floor) internal pure returns (uint256) { return nav > floor ? nav - floor : 0; } /// @notice Target risky exposure: m * cushion, clamped to the whole NAV. + /// @param nav Total value in the system, WAD. + /// @param floor The protected floor value, WAD. + /// @param multiplierWad The CPPI multiplier m, WAD. + /// @return The target risky exposure, WAD, never exceeding nav. function targetRisky(uint256 nav, uint256 floor, uint256 multiplierWad) internal pure returns (uint256) { uint256 target = FixedPointMathLib.mulWad(multiplierWad, cushion(nav, floor)); return FixedPointMathLib.min(target, nav); } - /// @notice Absolute deviation of current risky exposure from target, in bps of NAV. + /// @notice Absolute deviation of current risky exposure from target, in bps + /// of NAV: |currentRisky - target| * BPS / nav. Returns 0 when nav is 0. + /// @param currentRisky The current risky-leg exposure, WAD. + /// @param target The target risky exposure, WAD. + /// @param nav Total value in the system, WAD. + /// @return The drift, in basis points of NAV. function driftBps(uint256 currentRisky, uint256 target, uint256 nav) internal pure returns (uint256) { if (nav == 0) return 0; uint256 dev = FixedPointMathLib.dist(currentRisky, target); return dev * BPS / nav; } - /// @notice Cushion as bps of NAV; the health metric the emergency path watches. + /// @notice Cushion as bps of NAV; the health metric the emergency path + /// watches: cushion(nav, floor) * BPS / nav. Returns 0 when nav is 0. + /// @param nav Total value in the system, WAD. + /// @param floor The protected floor value, WAD. + /// @return The cushion, in basis points of NAV. function cushionBps(uint256 nav, uint256 floor) internal pure returns (uint256) { if (nav == 0) return 0; return cushion(nav, floor) * BPS / nav; @@ -50,6 +85,8 @@ library CPPIMath { /// @notice Largest single-move drop the strategy survives before the floor /// can break: 1/m, in bps. Independent of floor and NAV. + /// @param multiplierWad The CPPI multiplier m, WAD. + /// @return The maximum survivable single-move gap, in basis points. function maxSurvivableGapBps(uint256 multiplierWad) internal pure returns (uint256) { return WAD * BPS / multiplierWad; } diff --git a/src/libraries/FloorPolicy.sol b/src/libraries/FloorPolicy.sol index 599a487..49cf5f1 100644 --- a/src/libraries/FloorPolicy.sol +++ b/src/libraries/FloorPolicy.sol @@ -19,26 +19,48 @@ import {CPPIMath} from "./CPPIMath.sol"; library FloorPolicy { using FixedPointMathLib for uint256; + // ============================================================================ + // Types and config + // ============================================================================ + + /// @notice The floor-policy family: a fixed floor, a Step ratchet, or a TIPP + /// continuous ratchet. enum Kind { + /// @notice Fixed: the floor is the discounted present value of the protected amount only. Fixed, + /// @notice Step: the protected amount ratchets up in discrete steps as navPerShare rises. Step, + /// @notice TIPP: the floor ratchets continuously against a high-water navPerShare. Tipp } + /// @notice Immutable policy parameters for a term. + /// @param kind Which floor policy applies. + /// @param termStart Term start timestamp. + /// @param termEnd Term end (maturity) timestamp. + /// @param protectionWad P: fraction of term-start navPerShare promised at maturity, WAD. + /// @param triggerWad T (Step only): a step fires when navPerShare >= T x floorPerShare, WAD. + /// @param stepWad k (Step only): the protectedPerShare multiplier applied per step, WAD. + /// @param ratchetWad TIPP only: floorPerShare >= ratchet x high-water navPerShare, WAD. struct Config { Kind kind; uint64 termStart; uint64 termEnd; - 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 + uint256 protectionWad; + uint256 triggerWad; + uint256 stepWad; + uint256 ratchetWad; } + /// @notice Mutable per-term floor state. + /// @param protectedPerShareWad Per-share protected amount; the Step policy raises this, WAD. + /// @param hwmNavPerShareWad TIPP high-water navPerShare, WAD. + /// @param lastFloorPerShareWad Monotonicity clamp: the highest per-share floor seen this term, WAD. + /// @param stepCount Number of Step ratchets applied so far this term. struct State { - uint256 protectedPerShareWad; // per-share protected amount; Step raises this - uint256 hwmNavPerShareWad; // TIPP high-water navPerShare - uint256 lastFloorPerShareWad; // monotonicity clamp (per share) + uint256 protectedPerShareWad; + uint256 hwmNavPerShareWad; + uint256 lastFloorPerShareWad; uint32 stepCount; } @@ -46,11 +68,26 @@ library FloorPolicy { /// 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; + + /// @dev Minimum Step multiplier k, WAD. Bounds how fast the protected amount + /// can ratchet and underpins the MAX_STEPS_PER_UPDATE loop bound. uint256 internal constant MIN_STEP = 1.05e18; + + /// @dev Fixed-point scale: 1e18 represents 1.0. uint256 internal constant WAD = 1e18; + /// @notice A config field was outside its permitted range or ordering. error InvalidConfig(); + // ============================================================================ + // Policy + // ============================================================================ + + /// @notice Revert unless the config is internally consistent: protection must + /// be in (0, 1); the term must end after it starts; for Step, k must + /// be in [MIN_STEP, T) and T must not exceed 2e18; for TIPP, the + /// ratchet must be in (0, 1). + /// @param c The floor policy config to check. function validate(Config memory c) internal pure { if (c.protectionWad == 0 || c.protectionWad >= WAD) revert InvalidConfig(); if (c.termEnd <= c.termStart) revert InvalidConfig(); @@ -64,6 +101,10 @@ library FloorPolicy { } } + /// @notice Seed a term's floor state from the entry navPerShare and config. + /// @param s the floor state to initialize + /// @param c the floor-policy config for this share class + /// @param termStartNavPerShare navPerShare at term start, WAD function initialize(State storage s, Config memory c, uint256 termStartNavPerShare) internal { s.protectedPerShareWad = termStartNavPerShare.mulWad(c.protectionWad); s.hwmNavPerShareWad = termStartNavPerShare; diff --git a/src/libraries/RebalancePolicy.sol b/src/libraries/RebalancePolicy.sol index 748b9fd..b85dd54 100644 --- a/src/libraries/RebalancePolicy.sol +++ b/src/libraries/RebalancePolicy.sol @@ -10,31 +10,65 @@ pragma solidity ^0.8.24; /// both paths. Spike triggers kill latency risk; they cannot help with /// a true gap, which only the multiplier survives. library RebalancePolicy { + // ============================================================================ + // Types and config + // ============================================================================ + + /// @notice Which rebalance path may fire. enum Trigger { + /// @notice No rebalance is due. None, + /// @notice The keeper-gated scheduled path (cadence elapsed and drift above the small band). Scheduled, + /// @notice The permissionless emergency path (drift above the large band or cushion below its floor). Emergency } + /// @notice Trigger thresholds for the two-tier rebalance policy. + /// @param minInterval Hard anti-thrash floor, in seconds, applied to every path. + /// @param cadence Scheduled path spacing, in seconds. + /// @param driftSmallBps Scheduled path fires at drift >= this, in basis points. + /// @param driftLargeBps Emergency path fires at drift >= this, in basis points. + /// @param cushionFloorBps Emergency path fires at cushion/nav <= this, in basis points. struct Config { - uint64 minInterval; // hard anti-thrash floor for every path - uint64 cadence; // scheduled path spacing - uint16 driftSmallBps; // scheduled path fires at drift >= this - uint16 driftLargeBps; // emergency path fires at drift >= this - uint16 cushionFloorBps; // emergency path fires at cushion/nav <= this + uint64 minInterval; + uint64 cadence; + uint16 driftSmallBps; + uint16 driftLargeBps; + uint16 cushionFloorBps; } + /// @notice A config field was outside its permitted range or ordering. error InvalidConfig(); + // ============================================================================ + // Policy + // ============================================================================ + + /// @notice Revert unless the config is internally consistent: the small + /// drift band must not exceed the large one, cadence must be at + /// least the minimum interval, and the large drift band must be + /// non-zero. + /// @param c The rebalance policy config to check. function validate(Config memory c) internal pure { if (c.driftSmallBps > c.driftLargeBps) revert InvalidConfig(); if (c.cadence < c.minInterval) revert InvalidConfig(); if (c.driftLargeBps == 0) revert InvalidConfig(); } - /// @notice Classify what may fire now. Callers enforce keeper gating for + /// @notice Classify what may fire now. Within minInterval of the last + /// rebalance nothing fires; otherwise Emergency wins when drift is + /// at or above the large band or cushion is at or below its floor, + /// then Scheduled fires when the cadence has elapsed and drift is at + /// or above the small band. Callers enforce keeper gating for /// Scheduled; Emergency is permissionless by design so anyone can /// save the vault if the keeper is down during a crash. + /// @param c The rebalance policy config. + /// @param lastRebalanceAt Timestamp of the last rebalance (0 if none yet). + /// @param nowTs The current timestamp. + /// @param driftBps_ Current drift of risky exposure from target, in basis points of NAV. + /// @param cushionBps_ Current cushion as basis points of NAV. + /// @return The trigger that may fire now. function classify(Config memory c, uint256 lastRebalanceAt, uint256 nowTs, uint256 driftBps_, uint256 cushionBps_) internal pure