-
Notifications
You must be signed in to change notification settings - Fork 7
Feat: USDe before maturity feed with NAV #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c085b9d
d90491a
8c2c4ae
7a13fb8
aa97bd9
8690cf0
c80d052
84c1f35
4640e8c
3c8163d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| } |
| 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); | ||
|
|
||
| // 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
| } | ||
| } | ||
| 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 | ||
| ); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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