diff --git a/src/facets/strategies/PendlePtStrategyFacet.sol b/src/facets/strategies/PendlePtStrategyFacet.sol new file mode 100644 index 0000000..682c962 --- /dev/null +++ b/src/facets/strategies/PendlePtStrategyFacet.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { IERC4626 } from "@openzeppelin/contracts/interfaces/IERC4626.sol"; + +import { LibDiamond } from "../../libraries/LibDiamond.sol"; +import { IPendleRouter } from "../../interfaces/external/IPendleRouter.sol"; +import { IPPrincipalToken } from "../../interfaces/external/IPPrincipalToken.sol"; + +/// @title PendleStrategyFacet +/// @notice Strategy facet that buys Pendle PT with the vault's underlying asset, +/// holds it until maturity (or sells early via the Pendle AMM), and +/// reports the position value back to the allocator. +/// +/// @dev Selectors are prefixed with `pendle*` to coexist with other strategy +/// facets in the same Diamond without selector collisions. +/// State lives at EIP-7201 slot `vaultrouter.strategy.pendle`. +/// +/// YIELD MECHANISM +/// PT is a zero-coupon bond — you buy it at a discount and redeem 1:1 for +/// the underlying at maturity. The "yield" is the discount captured at +/// purchase. There are no claimable reward tokens; pendleHarvest is a no-op. +/// +/// TOTAL ASSETS REPORTING +/// Pre-maturity: reports PT face value (1:1 to underlying). This slightly +/// overstates the immediately-realisable value because PT trades at a +/// discount before expiry. A production deployment should replace this with +/// a call to PendlePYLpOracle for accurate mark-to-market pricing. +/// Post-maturity: face value equals redeemable value exactly. +/// +/// WITHDRAWAL PATH +/// Pre-maturity: PendleRouterV4.swapExactPtForToken (sell on AMM) +/// Post-maturity: PendleRouterV4.redeemPyToToken (burn PT, skip YT) +contract PendlePtStrategyFacet { + using SafeERC20 for IERC20; + + // ----------------------------------------------------------------------- + // Errors + // ----------------------------------------------------------------------- + + /// @notice Thrown when the facet has not been configured yet. + error PendleNotConfigured(); + + /// @notice Thrown on deposit when the market has already expired. + error PendleMarketExpired(); + + /// @notice Thrown when a deposit produces zero PT. + error PendleDepositFailed(uint256 received); + + /// @notice Thrown when a withdrawal produces zero underlying. + error PendleWithdrawFailed(uint256 minExpected, uint256 received); + + /// @notice Thrown when the requested withdrawal amount exceeds PT balance. + error PendleInsufficientPt(uint256 requested, uint256 available); + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + /// @notice Emitted when the facet is configured (or reconfigured). + event PendleConfigSet(address indexed router, address indexed market, address indexed pt); + + // ----------------------------------------------------------------------- + // Storage + // ----------------------------------------------------------------------- + + /// @dev erc7201:vaultrouter.strategy.pendle + bytes32 internal constant PENDLE_STORAGE_SLOT = 0xb0e016db49ce2cfbe35770c2200cbf5f1a9b502bca57dbaaddf328cb9e0cef00; + + struct PendleStorage { + /// @notice PendleRouterV4 — handles all swap and redemption paths. + IPendleRouter router; + /// @notice Pendle market address (PT/SY AMM pool). + address market; + /// @notice The PT token this strategy holds. + IPPrincipalToken pt; + } + + function _ps() internal pure returns (PendleStorage storage s) { + bytes32 slot = PENDLE_STORAGE_SLOT; + assembly { + s.slot := slot + } + } + + // ----------------------------------------------------------------------- + // Configuration + // ----------------------------------------------------------------------- + + /// @notice Configure the Pendle router, market, and PT for this strategy. + /// @dev Owner-gated. Must be called before the strategy is registered with + /// the allocator. Can be called again to switch to a different market + /// or PT maturity (e.g. rolling a position forward). + /// @param router PendleRouterV4 address. + /// @param market Pendle market (PT/SY pool) address. + /// @param pt PendlePrincipalToken address for the chosen market. + function pendleSetConfig(IPendleRouter router, address market, IPPrincipalToken pt) external { + LibDiamond.enforceIsContractOwner(); + if (address(router) == address(0) || market == address(0) || address(pt) == address(0)) { + revert PendleNotConfigured(); + } + + PendleStorage storage s = _ps(); + s.router = router; + s.market = market; + s.pt = pt; + + emit PendleConfigSet(address(router), market, address(pt)); + } + + // ----------------------------------------------------------------------- + // Strategy surface (pendle* prefix) + // ----------------------------------------------------------------------- + + /// @notice Current asset value of the Pendle position, denominated in the + /// vault's underlying asset. + /// @dev Returns PT face value (1:1 to underlying). Pre-maturity this is a + /// slight overstatement because PT trades at a discount. Post-maturity + /// it is exact — PT redeems 1:1. + /// TODO: replace with PendlePYLpOracle call for accurate pre-maturity pricing. + function pendleTotalAssets() external view returns (uint256) { + PendleStorage storage s = _ps(); + if (address(s.pt) == address(0)) return 0; + return s.pt.balanceOf(address(this)); + } + + /// @notice Buy PT with `amount` of the vault's underlying asset. + /// @dev Calls PendleRouterV4.swapExactTokenForPt with a direct token->SY->PT + /// path (no external swap aggregator). Reverts if the market is expired + /// or if zero PT is received. + /// @param amount Quantity of underlying asset to spend. + function pendleDeposit(uint256 amount) external { + PendleStorage storage s = _ps(); + if (address(s.router) == address(0)) revert PendleNotConfigured(); + if (s.pt.isExpired()) revert PendleMarketExpired(); + + IERC20 underlying = IERC20(IERC4626(address(this)).asset()); + underlying.forceApprove(address(s.router), amount); + + // Direct token -> SY path. tokenMintSy == tokenIn means the SY + // wrapper accepts the underlying directly (true for most USDC SYs). + IPendleRouter.TokenInput memory input = IPendleRouter.TokenInput({ + tokenIn: address(underlying), + netTokenIn: amount, + tokenMintSy: address(underlying), + pendleSwap: address(0), + swapData: IPendleRouter.SwapData({ + swapType: IPendleRouter.SwapType.NONE, extRouter: address(0), extCalldata: "", needScale: false + }) + }); + + // Loose binary-search bounds — the router will converge within 256 + // iterations to within 0.1% of the optimal PT amount. + IPendleRouter.ApproxParams memory approx = IPendleRouter.ApproxParams({ + guessMin: 0, guessMax: type(uint256).max, guessOffchain: 0, maxIteration: 256, eps: 1e15 + }); + + // Empty limit order — strategy does not participate in the limit book. + IPendleRouter.LimitOrderData memory limit; + + uint256 ptBefore = s.pt.balanceOf(address(this)); + + s.router + .swapExactTokenForPt( + address(this), // PT receiver is the vault itself + s.market, + 0, // minPtOut: checked post-call below + approx, + input, + limit + ); + + uint256 ptReceived = s.pt.balanceOf(address(this)) - ptBefore; + if (ptReceived == 0) revert PendleDepositFailed(ptReceived); + } + + /// @notice Return `amount` of underlying from the Pendle position to the vault. + /// @dev Routes through the appropriate path depending on maturity: + /// - Pre-maturity: sells PT on the Pendle AMM via swapExactPtForToken. + /// - Post-maturity: redeems PT at face value via redeemPyToToken. + /// + /// `amount` is treated as the PT quantity to liquidate (face value units). + /// The underlying received may be slightly less pre-maturity due to + /// the AMM discount; post-maturity it is 1:1. + /// @param amount PT quantity to liquidate (denominated in underlying units). + function pendleWithdraw(uint256 amount) external { + PendleStorage storage s = _ps(); + if (address(s.router) == address(0)) revert PendleNotConfigured(); + + uint256 ptBalance = s.pt.balanceOf(address(this)); + if (amount > ptBalance) revert PendleInsufficientPt(amount, ptBalance); + + IERC20 underlying = IERC20(IERC4626(address(this)).asset()); + uint256 underlyingBefore = underlying.balanceOf(address(this)); + + IERC20(address(s.pt)).forceApprove(address(s.router), amount); + + if (s.pt.isExpired()) { + // Post-maturity: PT redeems 1:1. minTokenOut = 99% (dust tolerance). + IPendleRouter.TokenOutput memory output = IPendleRouter.TokenOutput({ + tokenOut: address(underlying), + minTokenOut: amount * 99 / 100, + tokenRedeemSy: address(underlying), + pendleSwap: address(0), + swapData: IPendleRouter.SwapData({ + swapType: IPendleRouter.SwapType.NONE, extRouter: address(0), extCalldata: "", needScale: false + }) + }); + + // redeemPyToToken burns PT (YT is implicitly 0 post-maturity). + s.router.redeemPyToToken(address(this), s.pt.YT(), amount, output); + } else { + // Pre-maturity: sell PT on the Pendle AMM. + // minTokenOut = 0 here; slippage is validated post-call. + IPendleRouter.TokenOutput memory output = IPendleRouter.TokenOutput({ + tokenOut: address(underlying), + minTokenOut: 0, + tokenRedeemSy: address(underlying), + pendleSwap: address(0), + swapData: IPendleRouter.SwapData({ + swapType: IPendleRouter.SwapType.NONE, extRouter: address(0), extCalldata: "", needScale: false + }) + }); + + IPendleRouter.LimitOrderData memory limit; + + s.router.swapExactPtForToken(address(this), s.market, amount, output, limit); + } + + uint256 received = underlying.balanceOf(address(this)) - underlyingBefore; + if (received == 0) revert PendleWithdrawFailed(amount, received); + } + + /// @notice No-op. PT yield accrues entirely to face value at maturity — + /// there are no claimable reward tokens to harvest. + function pendleHarvest() external pure { } + + // ----------------------------------------------------------------------- + // Readers + // ----------------------------------------------------------------------- + + function pendleRouter() external view returns (IPendleRouter) { + return _ps().router; + } + + function pendleMarket() external view returns (address) { + return _ps().market; + } + + function pendlePT() external view returns (IPPrincipalToken) { + return _ps().pt; + } + + function pendleIsExpired() external view returns (bool) { + PendleStorage storage s = _ps(); + if (address(s.pt) == address(0)) revert PendleNotConfigured(); + return s.pt.isExpired(); + } + + function pendleExpiry() external view returns (uint256) { + PendleStorage storage s = _ps(); + if (address(s.pt) == address(0)) revert PendleNotConfigured(); + return s.pt.expiry(); + } +} diff --git a/src/interfaces/external/IPPrincipalToken.sol b/src/interfaces/external/IPPrincipalToken.sol new file mode 100644 index 0000000..51d1b9e --- /dev/null +++ b/src/interfaces/external/IPPrincipalToken.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IPPrincipalToken +/// @notice Minimal interface for Pendle's PendlePrincipalToken (PT). +/// @dev Reference: +/// https://github.com/pendle-finance/pendle-core-v2-public/blob/main/contracts/core/YieldContracts/PendlePrincipalToken.sol +interface IPPrincipalToken { + /// @notice Returns true if the PT has passed its expiry timestamp. + function isExpired() external view returns (bool); + + /// @notice Unix timestamp at which this PT matures and redeems 1:1. + function expiry() external view returns (uint256); + + /// @notice The SY token this PT is backed by. + function SY() external view returns (address); + + /// @notice The paired YT contract address. + function YT() external view returns (address); + + /// @notice ERC-20 balance. + function balanceOf(address account) external view returns (uint256); + + /// @notice ERC-20 decimals (matches underlying asset). + function decimals() external view returns (uint8); + + /// @notice ERC-20 approve. + function approve(address spender, uint256 amount) external returns (bool); +} diff --git a/src/interfaces/external/IPendleRouter.sol b/src/interfaces/external/IPendleRouter.sol new file mode 100644 index 0000000..760b7aa --- /dev/null +++ b/src/interfaces/external/IPendleRouter.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IPendleRouter +/// @notice Minimal interface for PendleRouterV4 — only the functions the +/// Pendle strategy facet calls. Full router ABI is much larger. +/// @dev Reference: https://github.com/pendle-finance/pendle-core-v2-public/blob/main/contracts/router +interface IPendleRouter { + // ----------------------------------------------------------------------- + // Swap aggregator type — NONE means no external swap, token goes straight + // into the SY wrapper. + // ----------------------------------------------------------------------- + enum SwapType { + NONE, + KYBERSWAP, + ONE_INCH, + // ETH <-> WETH + ETH_WETH + } + + /// @notice Calldata for an optional swap through an external aggregator + /// before the token is wrapped into SY. Set all fields to zero / + /// false when going directly token -> SY (no aggregator hop). + struct SwapData { + SwapType swapType; + address extRouter; + bytes extCalldata; + bool needScale; + } + + /// @notice Describes the input token path when buying PT or adding liquidity. + /// @param tokenIn Token the caller is spending (e.g. USDC). + /// @param netTokenIn Amount of tokenIn to spend. + /// @param tokenMintSy Token the SY wrapper actually accepts. Equal to + /// tokenIn when no aggregator swap is needed. + /// @param pendleSwap Pendle swap helper address (address(0) = none). + /// @param swapData External aggregator data (all-zero = none). + struct TokenInput { + address tokenIn; + uint256 netTokenIn; + address tokenMintSy; + address pendleSwap; + SwapData swapData; + } + + /// @notice Describes the output token path when selling PT or removing liquidity. + /// @param tokenOut Token the caller receives (e.g. USDC). + /// @param minTokenOut Minimum acceptable output — reverts if not met. + /// @param tokenRedeemSy Token the SY wrapper redeems into. Equal to + /// tokenOut when no aggregator swap is needed. + /// @param pendleSwap Pendle swap helper address (address(0) = none). + /// @param swapData External aggregator data (all-zero = none). + struct TokenOutput { + address tokenOut; + uint256 minTokenOut; + address tokenRedeemSy; + address pendleSwap; + SwapData swapData; + } + + /// @notice Binary-search bounds for approximating PT output amounts. + /// @param guessMin Lower bound for the binary search. + /// @param guessMax Upper bound for the binary search. + /// @param guessOffchain Off-chain hint to seed the search (0 = no hint). + /// @param maxIteration Maximum binary-search iterations. + /// @param eps Acceptable relative error (1e18 = 100%). 1e15 = 0.1%. + struct ApproxParams { + uint256 guessMin; + uint256 guessMax; + uint256 guessOffchain; + uint256 maxIteration; + uint256 eps; + } + + // Limit order structs — included for ABI completeness; the strategy passes + // empty arrays and zero values (no limit orders used). + struct Order { + uint256 salt; + uint256 expiry; + uint256 nonce; + uint8 orderType; + address token; + address YT; + address maker; + address receiver; + uint256 makingAmount; + uint256 lnImpliedRate; + uint256 failSafeRate; + bytes permit; + } + + struct FillOrderParams { + Order order; + bytes signature; + uint256 makingAmount; + } + + struct LimitOrderData { + address limitRouter; + uint256 epsSkipMarket; + FillOrderParams[] normalFills; + FillOrderParams[] flashFills; + bytes optData; + } + + // ----------------------------------------------------------------------- + // Core functions used by the strategy facet + // ----------------------------------------------------------------------- + + /// @notice Swap an exact amount of a token for PT. + /// @param receiver Address that receives the PT. + /// @param market Pendle market address (PT/SY pair). + /// @param minPtOut Minimum PT output — reverts if not met. + /// @param guessPtOut Binary-search params for approximating PT amount. + /// @param input Input token path descriptor. + /// @param limit Limit order data (pass empty for no limit orders). + /// @return netPtOut PT tokens received. + /// @return netSyFee SY fee paid to the protocol. + /// @return netSyInterm Intermediate SY amount used internally. + function swapExactTokenForPt( + address receiver, + address market, + uint256 minPtOut, + ApproxParams calldata guessPtOut, + TokenInput calldata input, + LimitOrderData calldata limit + ) + external + payable + returns (uint256 netPtOut, uint256 netSyFee, uint256 netSyInterm); + + /// @notice Swap an exact amount of PT for a token. + /// @param receiver Address that receives the output token. + /// @param market Pendle market address. + /// @param exactPtIn Exact amount of PT to sell. + /// @param output Output token path descriptor (includes minTokenOut). + /// @param limit Limit order data (pass empty for no limit orders). + /// @return netTokenOut Underlying tokens received. + /// @return netSyFee SY fee paid to the protocol. + /// @return netSyInterm Intermediate SY amount used internally. + function swapExactPtForToken( + address receiver, + address market, + uint256 exactPtIn, + TokenOutput calldata output, + LimitOrderData calldata limit + ) + external + returns (uint256 netTokenOut, uint256 netSyFee, uint256 netSyInterm); + + /// @notice Redeem PT (and optionally YT) for the underlying token post-maturity. + /// @dev After expiry the YT has zero value. Pass netPyIn = PT balance and + /// the router will redeem PT-only when YT amount is implicitly zero. + /// @param receiver Address that receives the underlying token. + /// @param YT Address of the YT contract (= PT's paired YT). + /// @param netPyIn Amount of PT to redeem. + /// @param output Output token path descriptor. + /// @return netTokenOut Underlying tokens received. + /// @return netSyFee SY fee paid. + function redeemPyToToken( + address receiver, + address YT, + uint256 netPyIn, + TokenOutput calldata output + ) + external + returns (uint256 netTokenOut, uint256 netSyFee); +}