Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 67 additions & 10 deletions src/facets/strategies/PendlePtStrategyFacet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -55,13 +59,20 @@ 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
// -----------------------------------------------------------------------

/// @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
// -----------------------------------------------------------------------
Expand All @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
32 changes: 32 additions & 0 deletions src/interfaces/external/IPYLpOracle.sol
Original file line number Diff line number Diff line change
@@ -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);
}
21 changes: 21 additions & 0 deletions test/mocks/MockPendle.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
88 changes: 86 additions & 2 deletions test/unit/PendleStrategy.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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") { }
Expand All @@ -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");
Expand All @@ -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);
Expand Down Expand Up @@ -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)
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
}