diff --git a/src/facets/strategies/PendlePtStrategyFacet.sol b/src/facets/strategies/PendlePtStrategyFacet.sol index db02d0f..3378445 100644 --- a/src/facets/strategies/PendlePtStrategyFacet.sol +++ b/src/facets/strategies/PendlePtStrategyFacet.sol @@ -8,6 +8,7 @@ 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"; +import { IPYLpOracle } from "../../interfaces/external/IPYLpOracle.sol"; /// @title PendleStrategyFacet /// @notice Strategy facet that buys Pendle PT with the vault's underlying asset, @@ -24,11 +25,14 @@ import { IPPrincipalToken } from "../../interfaces/external/IPPrincipalToken.sol /// 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. +/// Pre-maturity: marks the PT position to market via PendlePYLpOracle +/// (getPtToAssetRate), so the reported value reflects the discount PT +/// trades at before expiry rather than its face value. If no oracle has +/// been configured the facet falls back to face value (a slight +/// overstatement) so the strategy still functions on markets without a +/// seeded oracle. +/// Post-maturity: PT redeems 1:1, so face value is exact and the oracle is +/// bypassed. /// /// WITHDRAWAL PATH /// Pre-maturity: PendleRouterV4.swapExactPtForToken (sell on AMM) @@ -55,6 +59,10 @@ contract PendlePtStrategyFacet { /// @notice Thrown when the requested withdrawal amount exceeds PT balance. error PendleInsufficientPt(uint256 requested, uint256 available); + /// @notice Thrown when the oracle is configured with a zero address or a + /// zero TWAP duration. + error PendleInvalidOracle(); + // ----------------------------------------------------------------------- // Events // ----------------------------------------------------------------------- @@ -62,6 +70,9 @@ contract PendlePtStrategyFacet { /// @notice Emitted when the facet is configured (or reconfigured). event PendleConfigSet(address indexed router, address indexed market, address indexed pt); + /// @notice Emitted when the mark-to-market oracle is set (or cleared). + event PendleOracleSet(address indexed oracle, uint32 twapDuration); + // ----------------------------------------------------------------------- // Storage // ----------------------------------------------------------------------- @@ -76,6 +87,11 @@ contract PendlePtStrategyFacet { address market; /// @notice The PT token this strategy holds. IPPrincipalToken pt; + /// @notice PendlePYLpOracle used to mark the PT position to market + /// pre-maturity. Zero address => fall back to face value. + IPYLpOracle oracle; + /// @notice TWAP window (seconds) passed to the oracle. + uint32 twapDuration; } function _ps() internal pure returns (PendleStorage storage s) { @@ -110,20 +126,53 @@ contract PendlePtStrategyFacet { emit PendleConfigSet(address(router), market, address(pt)); } + /// @notice Set the PendlePYLpOracle and TWAP window used to mark the PT + /// position to market pre-maturity. + /// @dev Owner-gated and orthogonal to pendleSetConfig — kept separate so a + /// market can be wired up first and priced accurately once its oracle + /// is seeded. Until this is called, pendleTotalAssets reports face + /// value. The caller is responsible for confirming the market's oracle + /// is ready (see IPYLpOracle.getOracleState) for the chosen duration. + /// @param oracle PendlePYLpOracle address. + /// @param twapDuration TWAP window in seconds (must be non-zero). + function pendleSetOracle(IPYLpOracle oracle, uint32 twapDuration) external { + LibDiamond.enforceIsContractOwner(); + if (address(oracle) == address(0) || twapDuration == 0) revert PendleInvalidOracle(); + + PendleStorage storage s = _ps(); + s.oracle = oracle; + s.twapDuration = twapDuration; + + emit PendleOracleSet(address(oracle), twapDuration); + } + // ----------------------------------------------------------------------- // 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. + /// @dev Pre-maturity, with an oracle configured: marks the PT balance to + /// market via PendlePYLpOracle.getPtToAssetRate (rate scaled 1e18), so + /// the discount PT trades at is reflected. Post-maturity, or when no + /// oracle is set, returns PT face value — exact after expiry, a slight + /// overstatement before it. function pendleTotalAssets() external view returns (uint256) { PendleStorage storage s = _ps(); if (address(s.pt) == address(0)) return 0; - return s.pt.balanceOf(address(this)); + + uint256 ptBalance = s.pt.balanceOf(address(this)); + + // Face value when the position is empty, post-maturity (redeems 1:1), + // or no oracle is configured. + if (ptBalance == 0 || address(s.oracle) == address(0) || s.pt.isExpired()) { + return ptBalance; + } + + // PT and the underlying asset share decimals in Pendle, so the 1e18 + // rate converts the balance directly with no decimal adjustment. + uint256 rate = s.oracle.getPtToAssetRate(s.market, s.twapDuration); + return ptBalance * rate / 1e18; } /// @notice Buy PT with `amount` of the vault's underlying asset. @@ -253,6 +302,14 @@ contract PendlePtStrategyFacet { return _ps().pt; } + function pendleOracle() external view returns (IPYLpOracle) { + return _ps().oracle; + } + + function pendleTwapDuration() external view returns (uint32) { + return _ps().twapDuration; + } + function pendleIsExpired() external view returns (bool) { PendleStorage storage s = _ps(); if (address(s.pt) == address(0)) revert PendleNotConfigured(); diff --git a/src/interfaces/external/IPYLpOracle.sol b/src/interfaces/external/IPYLpOracle.sol new file mode 100644 index 0000000..e0aa090 --- /dev/null +++ b/src/interfaces/external/IPYLpOracle.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// @title IPYLpOracle +/// @notice Minimal interface for Pendle's PendlePYLpOracle (a.k.a. PtYtLpOracle), +/// the canonical on-chain mark-to-market oracle for PT / YT / LP. +/// @dev Reference: +/// https://github.com/pendle-finance/pendle-core-v2-public/blob/main/contracts/oracles/PtYtLpOracle/PendlePYLpOracle.sol +interface IPYLpOracle { + /// @notice TWAP rate of PT denominated in the SY's underlying asset, scaled + /// to 1e18. Multiply a PT balance by this and divide by 1e18 to get + /// the asset value. Pre-maturity this sits below 1e18 (PT trades at a + /// discount); it converges to 1e18 at expiry. + /// @param market Pendle market (PT/SY pool) address. + /// @param duration TWAP window in seconds. + function getPtToAssetRate(address market, uint32 duration) external view returns (uint256); + + /// @notice Reports whether the market's built-in oracle is ready to serve a + /// TWAP over `duration`. Callers must ensure the oracle is seeded + /// (cardinality increased, oldest observation old enough) before + /// relying on getPtToAssetRate, otherwise the rate read can revert. + /// @return increaseCardinalityRequired True if the market needs more slots. + /// @return cardinalityRequired The cardinality needed for `duration`. + /// @return oldestObservationSatisfied True if the window is fully covered. + function getOracleState( + address market, + uint32 duration + ) + external + view + returns (bool increaseCardinalityRequired, uint16 cardinalityRequired, bool oldestObservationSatisfied); +} diff --git a/test/mocks/MockPendle.sol b/test/mocks/MockPendle.sol index fcf892b..7e2b3f6 100644 --- a/test/mocks/MockPendle.sol +++ b/test/mocks/MockPendle.sol @@ -5,6 +5,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { IPendleRouter } from "../../src/interfaces/external/IPendleRouter.sol"; +import { IPYLpOracle } from "../../src/interfaces/external/IPYLpOracle.sol"; /// @title MockPrincipalToken /// @notice Test-only stand-in for a Pendle PT. An ERC20 plus the maturity @@ -132,3 +133,23 @@ contract MockPendleRouter is IPendleRouter { asset.transfer(receiver, netTokenOut); } } + +/// @title MockPYLpOracle +/// @notice Test-only PendlePYLpOracle stand-in. Returns a settable PT->asset +/// rate (1e18 = par; 0.95e18 = PT marked at a 5% discount) so tests can +/// drive the facet's mark-to-market branch deterministically. +contract MockPYLpOracle is IPYLpOracle { + uint256 public rate = 1e18; + + function setRate(uint256 rate_) external { + rate = rate_; + } + + function getPtToAssetRate(address, uint32) external view returns (uint256) { + return rate; + } + + function getOracleState(address, uint32) external pure returns (bool, uint16, bool) { + return (false, 0, true); + } +} diff --git a/test/unit/PendleStrategy.t.sol b/test/unit/PendleStrategy.t.sol index a0020ee..5124518 100644 --- a/test/unit/PendleStrategy.t.sol +++ b/test/unit/PendleStrategy.t.sol @@ -17,10 +17,11 @@ import { AllocatorFacet } from "../../src/facets/AllocatorFacet.sol"; import { PendlePtStrategyFacet } from "../../src/facets/strategies/PendlePtStrategyFacet.sol"; import { IPendleRouter } from "../../src/interfaces/external/IPendleRouter.sol"; import { IPPrincipalToken } from "../../src/interfaces/external/IPPrincipalToken.sol"; +import { IPYLpOracle } from "../../src/interfaces/external/IPYLpOracle.sol"; import { LibAllocator } from "../../src/libraries/LibAllocator.sol"; import { LibDiamond } from "../../src/libraries/LibDiamond.sol"; -import { MockPrincipalToken, MockPendleRouter } from "../mocks/MockPendle.sol"; +import { MockPrincipalToken, MockPendleRouter, MockPYLpOracle } from "../mocks/MockPendle.sol"; contract MockUSDC is ERC20 { constructor() ERC20("USD Coin", "USDC") { } @@ -45,6 +46,7 @@ contract PendleStrategyTest is Test { MockUSDC internal usdc; MockPrincipalToken internal pt; MockPendleRouter internal router; + MockPYLpOracle internal oracle; Vault internal vault; address internal owner = makeAddr("owner"); @@ -56,12 +58,14 @@ contract PendleStrategyTest is Test { uint256 internal expiry; bytes32 internal constant PENDLE_ID = bytes32("pendle"); + uint32 internal constant TWAP = 900; function setUp() public { usdc = new MockUSDC(); expiry = block.timestamp + 365 days; pt = new MockPrincipalToken(6, expiry, yt, sy); router = new MockPendleRouter(IERC20(address(usdc)), pt); + oracle = new MockPYLpOracle(); vault = _deployVault(); // The router needs underlying liquidity to settle sells / redemptions. usdc.mint(address(router), 100_000_000 * 1e6); @@ -248,6 +252,77 @@ contract PendleStrategyTest is Test { PendlePtStrategyFacet(address(vault)).pendleWithdraw(400 * 1e6); } + // ----------------------------------------------------------------------- + // Oracle config — gating & validation + // ----------------------------------------------------------------------- + + function test_SetOracle_SetsAndEmits() public { + vm.expectEmit(true, false, false, true, address(vault)); + emit PendlePtStrategyFacet.PendleOracleSet(address(oracle), TWAP); + + vm.prank(owner); + PendlePtStrategyFacet(address(vault)).pendleSetOracle(IPYLpOracle(address(oracle)), TWAP); + + assertEq(address(PendlePtStrategyFacet(address(vault)).pendleOracle()), address(oracle), "oracle readable"); + assertEq(PendlePtStrategyFacet(address(vault)).pendleTwapDuration(), TWAP, "twap readable"); + } + + function test_SetOracle_RevertsOnZeroOracle() public { + vm.prank(owner); + vm.expectRevert(PendlePtStrategyFacet.PendleInvalidOracle.selector); + PendlePtStrategyFacet(address(vault)).pendleSetOracle(IPYLpOracle(address(0)), TWAP); + } + + function test_SetOracle_RevertsOnZeroDuration() public { + vm.prank(owner); + vm.expectRevert(PendlePtStrategyFacet.PendleInvalidOracle.selector); + PendlePtStrategyFacet(address(vault)).pendleSetOracle(IPYLpOracle(address(oracle)), 0); + } + + function test_SetOracle_RevertsForNonOwner() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(LibDiamond.NotContractOwner.selector, alice, owner)); + PendlePtStrategyFacet(address(vault)).pendleSetOracle(IPYLpOracle(address(oracle)), TWAP); + } + + // ----------------------------------------------------------------------- + // Total assets — mark-to-market vs face value + // ----------------------------------------------------------------------- + + function test_TotalAssets_MarksToMarketWhenOracleSet() public { + _configure(); + _setOracle(0.95e18); // PT marked at a 5% discount pre-maturity + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + PendlePtStrategyFacet(address(vault)).pendleDeposit(amount); + + // Face value is 1000 USDC, but the oracle marks it down to 950. + assertEq(pt.balanceOf(address(vault)), amount, "holds PT at face"); + assertEq(PendlePtStrategyFacet(address(vault)).pendleTotalAssets(), 950 * 1e6, "marked to market via oracle"); + } + + function test_TotalAssets_FaceValueWhenNoOracle() public { + _configure(); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + PendlePtStrategyFacet(address(vault)).pendleDeposit(amount); + + // No oracle configured -> face value fallback. + assertEq(PendlePtStrategyFacet(address(vault)).pendleTotalAssets(), amount, "face value without oracle"); + } + + function test_TotalAssets_PostMaturityIgnoresOracle() public { + _configure(); + _setOracle(0.95e18); + uint256 amount = 1000 * 1e6; + usdc.mint(address(vault), amount); + PendlePtStrategyFacet(address(vault)).pendleDeposit(amount); + + vm.warp(expiry + 1); // PT now redeems 1:1 — oracle discount must be bypassed + + assertEq(PendlePtStrategyFacet(address(vault)).pendleTotalAssets(), amount, "face value post-maturity"); + } + // ----------------------------------------------------------------------- // Harvest — no-op (PT has no claimable rewards) // ----------------------------------------------------------------------- @@ -328,6 +403,12 @@ contract PendleStrategyTest is Test { .pendleSetConfig(IPendleRouter(address(router)), market, IPPrincipalToken(address(pt))); } + function _setOracle(uint256 rate) internal { + oracle.setRate(rate); + vm.prank(owner); + PendlePtStrategyFacet(address(vault)).pendleSetOracle(IPYLpOracle(address(oracle)), TWAP); + } + function _register() internal { vm.prank(owner); AllocatorFacet(address(vault)).registerStrategy(PENDLE_ID, _pendleStrategyConfig()); @@ -431,7 +512,7 @@ contract PendleStrategyTest is Test { } function _pendleSelectors() internal pure returns (bytes4[] memory s) { - s = new bytes4[](10); + s = new bytes4[](13); s[0] = PendlePtStrategyFacet.pendleSetConfig.selector; s[1] = PendlePtStrategyFacet.pendleTotalAssets.selector; s[2] = PendlePtStrategyFacet.pendleDeposit.selector; @@ -442,5 +523,8 @@ contract PendleStrategyTest is Test { s[7] = PendlePtStrategyFacet.pendlePT.selector; s[8] = PendlePtStrategyFacet.pendleIsExpired.selector; s[9] = PendlePtStrategyFacet.pendleExpiry.selector; + s[10] = PendlePtStrategyFacet.pendleSetOracle.selector; + s[11] = PendlePtStrategyFacet.pendleOracle.selector; + s[12] = PendlePtStrategyFacet.pendleTwapDuration.selector; } }