Skip to content
Closed
45 changes: 45 additions & 0 deletions src/feeds/PendleNAVFeed.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract PendleNAVFeed {
uint256 private constant SECONDS_PER_YEAR = 365 days;
uint256 private constant ONE = 1e18;

address public immutable PT;
uint256 public immutable maturity;
uint256 public immutable baseDiscountPerYear; // 100% = 1e18

constructor(address _pt, uint256 _baseDiscountPerYear) {
require(_baseDiscountPerYear <= 1e18, "invalid discount");
require(_pt != address(0), "zero address");

PT = _pt;
maturity = PTExpiry(PT).expiry();
baseDiscountPerYear = _baseDiscountPerYear;
}

function latestRoundData()
external
view
returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
{
uint256 timeLeft = (maturity > block.timestamp) ? maturity - block.timestamp : 0;
uint256 discount = getDiscount(timeLeft);

require(discount <= ONE, "discount overflow");

return (0, int256(ONE - discount), 0, block.timestamp, 0);
}

function decimals() external pure returns (uint8) {
return 18;
}

function getDiscount(uint256 timeLeft) public view returns (uint256) {
return (timeLeft * baseDiscountPerYear) / SECONDS_PER_YEAR;
}
}

interface PTExpiry {
function expiry() external view returns (uint256);
}
88 changes: 88 additions & 0 deletions src/feeds/USDeNavBeforeMaturityFeed.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import {IChainlinkBasePriceFeed} from "src/interfaces/IChainlinkFeed.sol";
import {IERC4626} from "lib/openzeppelin-contracts/contracts/interfaces/IERC4626.sol";

interface INavFeed {
function maturity() external view returns (uint256);
function decimals() external view returns (uint8);
function latestRoundData()
external
view
returns (uint80, int256, uint256, uint256, uint80);
}
/// @title USDeFeed Before Maturity using NAV
/// @notice A contract to get the USDe price using sUSDe Chainlink Wrapper feed and sUSDe/USDe rate and NAV
contract USDeNavBeforeMaturityFeed {
error DecimalsMismatch();
error MaturityPassed();

IChainlinkBasePriceFeed public immutable sUSDeFeed;
IERC4626 public immutable sUSDe;
INavFeed public immutable navFeed;

string public description;

constructor(address _sUSDeFeed, address _sUSDe, address _navFeed) {
sUSDeFeed = IChainlinkBasePriceFeed(_sUSDeFeed);
sUSDe = IERC4626(_sUSDe);
navFeed = INavFeed(_navFeed);
if (sUSDeFeed.decimals() != 18 || sUSDe.decimals() != 18 || navFeed.decimals() != 18)
revert DecimalsMismatch();
if(navFeed.maturity() <= block.timestamp) revert MaturityPassed();
description = string(
abi.encodePacked(
"USDe/USD Feed using sUSDe Chainlink feed and sUSDe/USDe rate with NAV"
)
);
}

/**
* @return roundId The round ID of sUSDe Chainlink price feed
* @return USDeUsdPrice The latest USDe price in USD using NAV
* @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 sUSDePrice,
uint startedAt,
uint updatedAt,
uint80 answeredInRound
) = sUSDeFeed.latestRoundData();

uint256 sUSDeToUSDeRate = sUSDe.convertToAssets(1e18);
Copy link

@wavey0x wavey0x Mar 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if token is capable of taking withdraw fees, .previewRedeem() may be more accurate


// divide sUSDe/USD by sUSDe/USDe rate to get USDe/USD price
int256 USDeUsdPrice = (sUSDePrice * 1e18) / int256(sUSDeToUSDeRate);

(,int256 navDiscountedPrice,,,)= navFeed.latestRoundData();
int256 usdeDiscountPrice = (USDeUsdPrice * navDiscountedPrice) / 1e18;
Copy link

@wavey0x wavey0x Mar 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i believe this particular token is subject to a cooldown.

thus, a simple share price formula may be a somewhat misleading way to get the actual market price as the market should theoretically apply a discount based on time it takes to cool down.

just wanted to highlight this note, though i'm sure you've already considered.


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

/**
@notice Retrieves the latest USDe price
@return price The latest USDe 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;
}
}
158 changes: 158 additions & 0 deletions test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

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

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

contract USDeNavBeforeMaturityFeedTest is Test {
USDeNavBeforeMaturityFeed feed;
ChainlinkBasePriceFeed sUSDeWrappedFeed;
address sUSDeFeed = address(0xFF3BC18cCBd5999CE63E788A1c250a88626aD099);
IERC4626 sUSDe = IERC4626(0x9D39A5DE30e57443BfF2A8307A4256c8797A3497);
address gov = address(0x926dF14a23BE491164dCF93f4c468A50ef659D5B);
address pendlePT = address(0xb7de5dFCb74d25c2f21841fbd6230355C50d9308); // PT sUSDe 29 May 25

function setUp() public {
string memory url = vm.rpcUrl("mainnet");
vm.createSelectFork(url);
sUSDeWrappedFeed = new ChainlinkBasePriceFeed(
gov,
sUSDeFeed,
address(0),
24 hours
);
address navFeed = address(new PendleNAVFeed(pendlePT, 0.2 ether)); // 20% discount
feed = new USDeNavBeforeMaturityFeed(
address(sUSDeWrappedFeed),
address(sUSDe),
navFeed
);
}

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

function test_description() public {
string memory expected = string(
abi.encodePacked(
"USDe/USD Feed using sUSDe Chainlink feed and sUSDe/USDe rate with NAV"
)
);
assertEq(feed.description(), expected);
}

function test_latestRoundData() public {
(
uint80 roundId,
int256 USDeUsdPrice,
uint startedAt,
uint updatedAt,
uint80 answeredInRound
) = feed.latestRoundData();
(
uint80 roundIdCl,
int256 sUSDeUsdPrice,
uint startedAtCl,
uint updatedAtCl,
uint80 answeredInRoundCl
) = sUSDeWrappedFeed.latestRoundData();
assertEq(roundId, roundIdCl);
assertEq(startedAt, startedAtCl);
assertEq(updatedAt, updatedAtCl);
assertEq(answeredInRound, answeredInRoundCl);

int256 USDeUsdPriceEst = (sUSDeUsdPrice * 1e18) /
int256(sUSDe.convertToAssets(1e18));
(,int256 navDiscountedPrice,,,) = feed.navFeed().latestRoundData();
int256 discountPrice = (USDeUsdPriceEst * navDiscountedPrice) / 1e18;
assertEq(discountPrice, USDeUsdPrice);
}

function test_latestAnswer() public {
int256 USDeUsdPrice = feed.latestAnswer();
int256 USDeUsdPriceEst = (sUSDeWrappedFeed.latestAnswer() * 1e18) /
int256(sUSDe.convertToAssets(1e18));
(,int256 navDiscountedPrice,,,) = feed.navFeed().latestRoundData();
int256 discountPrice = (USDeUsdPriceEst * navDiscountedPrice) / 1e18;
assertEq(discountPrice, USDeUsdPrice);
}

function test_NAV() public {
uint256 maturity = INavFeed(address(feed.navFeed())).maturity();
vm.warp(maturity - 365 days/6); //2 months before expiry
uint256 discount = INavFeed(address(feed.navFeed())).getDiscount(365 days/6);
assertApproxEqAbs(discount, 0.0333 ether, 0.0001 ether);
(,int256 navDiscountedPrice,,,) = feed.navFeed().latestRoundData();
assertApproxEqAbs(navDiscountedPrice, 0.966666666 ether, 0.00000001 ether);
int256 USDeUsdPrice = feed.latestAnswer();
int256 USDeUsdPriceEst = (sUSDeWrappedFeed.latestAnswer() * 1e18) /
int256(sUSDe.convertToAssets(1e18));
int256 discountPrice = (USDeUsdPriceEst * navDiscountedPrice) / 1e18;
assertEq(discountPrice, USDeUsdPrice);

vm.warp(maturity - 365 days/12); //1 months before expiry
uint256 discount2 = INavFeed(address(feed.navFeed())).getDiscount(365 days/12);
assertApproxEqAbs(discount2, 0.016666666 ether, 0.0001 ether);
(,int256 navDiscountedPrice2,,,) = feed.navFeed().latestRoundData();
assertApproxEqAbs(navDiscountedPrice2, 0.983333333 ether, 0.00000001 ether);
int256 USDeUsdPrice2 = feed.latestAnswer();
int256 USDeUsdPriceEst2 = (sUSDeWrappedFeed.latestAnswer() * 1e18) /
int256(sUSDe.convertToAssets(1e18));
int256 discountPrice2 = (USDeUsdPriceEst2 * navDiscountedPrice2) / 1e18;
assertEq(discountPrice2, USDeUsdPrice2);
// Check if the discount is decreasing
assertGt(discount, discount2);
// Check if the price is increasing
assertLt(navDiscountedPrice, navDiscountedPrice2);
assertLt(USDeUsdPrice, USDeUsdPrice2);
assertLt(discountPrice, discountPrice2);
}
function test_STALE_sUSDeFeed() public {
vm.mockCall(
address(sUSDeFeed),
abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector),
abi.encode(0, 1.1e8, 0, 0, 0)
);
(
uint80 roundId,
int256 USDeUsdPrice,
uint startedAt,
uint updatedAt,
uint80 answeredInRound
) = feed.latestRoundData();
int256 USDeUsdPriceEst = (sUSDeWrappedFeed.latestAnswer() * 1e18) /
int256(sUSDe.convertToAssets(1e18));
(,int256 navDiscountedPrice,,,) = feed.navFeed().latestRoundData();
int256 discountPrice = (USDeUsdPriceEst * navDiscountedPrice) / 1e18;
assertEq(roundId, 0);
assertEq(USDeUsdPrice, discountPrice);
assertEq(startedAt, 0);
assertEq(updatedAt, 0);
assertEq(answeredInRound, 0);
}

function test_maturity_passed() public {
uint256 maturity = INavFeed(address(feed.navFeed())).maturity();
vm.warp(maturity);
address navFeed = address(new PendleNAVFeed(pendlePT, 0.2 ether));
vm.expectRevert(USDeNavBeforeMaturityFeed.MaturityPassed.selector);
feed = new USDeNavBeforeMaturityFeed(
address(sUSDeWrappedFeed),
address(sUSDe),
navFeed
);
}
}
36 changes: 36 additions & 0 deletions test/marketForkTests/MarketBaseForkTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,42 @@ abstract contract MarketBaseForkTest is MarketForkTest {
market.borrow(borrowAmount);
}

function testBorrow_Fails_When_Exceeds_Threshold() public {
gibCollateral(user, testAmount);
gibDBR(user, testAmount);
vm.startPrank(user, user);

deposit(testAmount);

uint256 stalenessThreshold = borrowController.stalenessThreshold(address(market));
vm.warp(
block.timestamp + stalenessThreshold + 1
);

(IChainlinkFeed feed, ) = oracle.feeds(address(collateral));
(
,
,
,
uint updatedAt,
) = feed.latestRoundData();

uint borrowAmount = market.getCreditLimit(user);

if(block.timestamp - updatedAt > stalenessThreshold) {
vm.expectRevert("Denied by borrow controller");
market.borrow(borrowAmount);
} else {
uint initialDolaBalance = DOLA.balanceOf(user);
market.borrow(borrowAmount);
assertEq(
DOLA.balanceOf(user),
initialDolaBalance + borrowAmount,
"User balance did not increase by borrowAmount"
);
}
}

function testBorrow_Fails_When_DeniedByBorrowController() public {
vm.startPrank(gov);
market.setBorrowController(
Expand Down
4 changes: 4 additions & 0 deletions test/marketForkTests/MarketForkTest.sol
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ contract MarketForkTest is Test, ConfigAddr {
);
borrowController.setDailyLimit(address(market), 10_000_000 * 1e18);
borrowController.setMinDebt(address(market), 1);
borrowController.setStalenessThreshold(
address(market),
1 days
);
dbr.addMarket(address(market));
fed.changeMarketCeiling(IMarket(address(market)), type(uint).max);
fed.changeSupplyCeiling(type(uint).max);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {ChainlinkBasePriceFeed} from "src/feeds/ChainlinkBasePriceFeed.sol";
import {DolaFixedPriceFeed} from "src/feeds/DolaFixedPriceFeed.sol";
import {FeedSwitch} from "src/util/FeedSwitch.sol";

contract PendlePTUSDeMarketForkTest is MarketBaseForkTest {
contract PendlePTsUSDe27Mar25MarketForkTest is MarketBaseForkTest {
address USDeFeed = address(0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961);
address sUSDeFeed = address(0xFF3BC18cCBd5999CE63E788A1c250a88626aD099);
address sUSDe = address(0x9D39A5DE30e57443BfF2A8307A4256c8797A3497);
Expand Down
Loading