Skip to content
Open
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
93 changes: 93 additions & 0 deletions src/feeds/PTDiscountedNAVFeed.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import {IChainlinkBasePriceFeed} from "src/interfaces/IChainlinkFeed.sol";
import {IERC20} from "src/interfaces/IERC20.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

interface IPT {
function expiry() external view returns (uint256);
function decimals() external view returns(uint8);
}
/// @title PT Token discounted NAV Feed
/// @notice A contract to get the discounted NAV price using underlying Chainlink Wrapper feed to price PT tokens
contract PTDiscountedNAVFeed {
error DecimalsMismatch();
error MaturityPassed();
error DiscountOverflow();

IChainlinkBasePriceFeed public immutable underlyingFeed;
uint public immutable maturity;
uint public immutable baseDiscountPerYear;
string public description;

constructor(address _underlyingFeed, address ptToken, uint _baseDiscountPerYear) {
underlyingFeed = IChainlinkBasePriceFeed(_underlyingFeed);
baseDiscountPerYear = _baseDiscountPerYear;
if (underlyingFeed.decimals() != 18 || IPT(ptToken).decimals() != 18)
revert DecimalsMismatch();
maturity = IPT(ptToken).expiry();
if(maturity <= block.timestamp) revert MaturityPassed();
if(getDiscount() > 1e18) revert DiscountOverflow();
description = string(
abi.encodePacked(
underlyingFeed.description(), " with yearly discount rate of ", Strings.toString(_baseDiscountPerYear)
)
);
}

/**
* @return roundId The round ID of underlying Chainlink price feed
* @return discountedNAVPrice The latest discounted NAV price of the PT token
* @return startedAt The timestamp when the latest round of Chainlink price feed started
* @return updatedAt The timestamp when the latest round of Chainlink price feed was updated
* @return answeredInRound The round ID in which the answer was computed
*/
function latestRoundData()
public
view
returns (uint80, int256, uint256, uint256, uint80)
{
(
uint80 roundId,
int256 underlyingPrice,
uint startedAt,
uint updatedAt,
uint80 answeredInRound
) = underlyingFeed.latestRoundData();

uint256 discount = getDiscount();

//If discount is 100% or more, we price the asset at the lowest positive price possible
int256 discountedNavPrice = int256(1e18 - discount) * underlyingPrice / 1e18;
//Make sure a 0 price isn't returned
if(discountedNavPrice == 0) discountedNavPrice = 1;

return (roundId, discountedNavPrice, startedAt, updatedAt, answeredInRound);
}

function getDiscount() public view returns (uint256) {
if(maturity <= block.timestamp) return 0;
uint timeLeft = maturity - block.timestamp;
uint discount = (timeLeft * baseDiscountPerYear) / 365 days;
//Bound discount to avoid overflow
return discount > 1e18 ? 1e18 : discount;
}

/**
@notice Retrieves the latest discounted NAV price of the PT token
@return price The latest discounted NAV price
*/
function latestAnswer() external view returns (int256) {
(, int256 price, , , ) = latestRoundData();
return price;
}

/**
* @notice Retrieves number of decimals for the price feed
* @return decimals The number of decimals for the price feed
*/
function decimals() public pure returns (uint8) {
return 18;
}
}
152 changes: 152 additions & 0 deletions test/feedForkTests/USDePTDiscountedNAVFeed.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "forge-std/Test.sol";
import {PTDiscountedNAVFeed} from "src/feeds/PTDiscountedNAVFeed.sol";
import {ERC4626Feed} from "src/feeds/ERC4626Feed.sol";
import {ChainlinkBasePriceFeed, IChainlinkFeed} from "src/feeds/ChainlinkBasePriceFeed.sol";
import "lib/openzeppelin-contracts/contracts/interfaces/IERC4626.sol";
import "forge-std/console.sol";

interface PendleSparkLinearDiscountOracleFactory {
function createWithPt(address pt, uint256 baseDiscountPerYear) external returns (address);
}

interface INavFeed {
function getDiscount(uint256 timeLeft) external view returns (uint256) ;
function maturity() external view returns (uint256);
function decimals() external view returns (uint8);
}

contract USDePTDiscountedNAVFeedTest is Test {
PTDiscountedNAVFeed feed;
ChainlinkBasePriceFeed USDeWrappedFeed;
address USDeFeed = address(0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961);
address gov = address(0x926dF14a23BE491164dCF93f4c468A50ef659D5B);
address pendlePT = address(0xb7de5dFCb74d25c2f21841fbd6230355C50d9308); // PT sUSDe 29 May 25
uint discountRate = 0.2 ether;

function setUp() public {
string memory url = vm.rpcUrl("mainnet");
vm.createSelectFork(url);
USDeWrappedFeed = new ChainlinkBasePriceFeed(
gov,
USDeFeed,
address(0),
24 hours
);
feed = new PTDiscountedNAVFeed(
address(USDeWrappedFeed),
pendlePT,
discountRate
);
}

function test_decimals() public {
assertEq(feed.underlyingFeed().decimals(), 18);
assertEq(feed.decimals(), 18);
}

function test_description() public {
string memory expected = string(
abi.encodePacked(
"USDe / USD with yearly discount rate of 200000000000000000"
)
);
assertEq(feed.description(), expected);
}

function test_latestRoundData() public {
(
uint80 roundId,
int256 discountedPrice,
uint startedAt,
uint updatedAt,
uint80 answeredInRound
) = feed.latestRoundData();
(
uint80 roundIdCl,
int256 USDePrice,
uint startedAtCl,
uint updatedAtCl,
uint80 answeredInRoundCl
) = USDeWrappedFeed.latestRoundData();
assertEq(roundId, roundIdCl, "roundId not equal");
assertEq(startedAt, startedAtCl, "start timestamp not equal");
assertEq(updatedAt, updatedAtCl, "updated at not equal");
assertEq(answeredInRound, answeredInRoundCl, "answered round not equal");

if(block.timestamp > feed.maturity()){
assertEq(discountedPrice, USDePrice, "discountedPrice not eq before maturity");
} else {
assertLt(discountedPrice, USDePrice, "discounted price not less after maturity");
}
}

function test_latestAnswer() public {
int256 USDePrice = USDeWrappedFeed.latestAnswer();
int256 discountedPrice = feed.latestAnswer();

if(block.timestamp < feed.maturity()){
assertLt(discountedPrice, USDePrice);
} else {
assertEq(discountedPrice, USDePrice);
}

vm.warp(feed.maturity() + 1);

USDePrice = USDeWrappedFeed.latestAnswer();
discountedPrice = feed.latestAnswer();

assertEq(discountedPrice, USDePrice);
}

function test_NAV_fuzzed(uint secondsBeforeMaturity) public {
secondsBeforeMaturity = secondsBeforeMaturity % 1824 days; //5 years - 1 day
uint256 maturity = feed.maturity();
vm.warp(maturity - secondsBeforeMaturity);
uint256 discount = feed.getDiscount();
uint timeLeft = maturity - block.timestamp;
uint expectedDiscount = discountRate * timeLeft / 365 days;
int256 USDePrice = USDeWrappedFeed.latestAnswer();
int256 discountedPrice = feed.latestAnswer();
int256 expectedDiscountedPrice = USDePrice * int((1e18 - expectedDiscount)) / 1e18;

assertApproxEqAbs(discount, expectedDiscount, 0.0001 ether);
assertApproxEqAbs(feed.latestAnswer(), expectedDiscountedPrice, 0.00000001 ether);
if(secondsBeforeMaturity > 12){
assertLt(discountedPrice, USDePrice);
}
}

function test_STALE_sUSDeFeed() public {
vm.mockCall(
address(USDeFeed),
abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector),
abi.encode(0, 1.1e8, 0, 0, 0)
);
(
uint80 roundId,
int256 discountedPrice,
uint startedAt,
uint updatedAt,
uint80 answeredInRound
) = feed.latestRoundData();
int256 USDePrice = USDeWrappedFeed.latestAnswer();
assertEq(roundId, 0);
assertLt(discountedPrice, USDePrice);
assertEq(startedAt, 0);
assertEq(updatedAt, 0);
assertEq(answeredInRound, 0);
}

function test_maturity_passed() public {
vm.warp(feed.maturity() + 1);
vm.expectRevert(PTDiscountedNAVFeed.MaturityPassed.selector);
feed = new PTDiscountedNAVFeed(
address(USDeWrappedFeed),
pendlePT,
discountRate
);
}
}
Loading