diff --git a/src/feeds/PendleNAVFeed.sol b/src/feeds/PendleNAVFeed.sol new file mode 100644 index 00000000..ce9c290a --- /dev/null +++ b/src/feeds/PendleNAVFeed.sol @@ -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); +} \ No newline at end of file diff --git a/src/feeds/USDeNavBeforeMaturityFeed.sol b/src/feeds/USDeNavBeforeMaturityFeed.sol new file mode 100644 index 00000000..ee1a8680 --- /dev/null +++ b/src/feeds/USDeNavBeforeMaturityFeed.sol @@ -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; + + 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; + } +} diff --git a/test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol b/test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol new file mode 100644 index 00000000..d04f1627 --- /dev/null +++ b/test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol @@ -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 + ); + } +} diff --git a/test/marketForkTests/MarketBaseForkTest.sol b/test/marketForkTests/MarketBaseForkTest.sol index f8c64ece..4a5119ba 100644 --- a/test/marketForkTests/MarketBaseForkTest.sol +++ b/test/marketForkTests/MarketBaseForkTest.sol @@ -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( diff --git a/test/marketForkTests/MarketForkTest.sol b/test/marketForkTests/MarketForkTest.sol index 8473c1a2..76eaa28e 100644 --- a/test/marketForkTests/MarketForkTest.sol +++ b/test/marketForkTests/MarketForkTest.sol @@ -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); diff --git a/test/marketForkTests/PendlePTUSDeMarketForkTest.t.sol b/test/marketForkTests/PendlePTsUSDe27Mar25MarketForkTest.t.sol similarity index 97% rename from test/marketForkTests/PendlePTUSDeMarketForkTest.t.sol rename to test/marketForkTests/PendlePTsUSDe27Mar25MarketForkTest.t.sol index 3f0c5908..fa3123b2 100644 --- a/test/marketForkTests/PendlePTUSDeMarketForkTest.t.sol +++ b/test/marketForkTests/PendlePTsUSDe27Mar25MarketForkTest.t.sol @@ -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); diff --git a/test/marketForkTests/PendlePTsUSDe29May25MarketForkTest.t.sol b/test/marketForkTests/PendlePTsUSDe29May25MarketForkTest.t.sol new file mode 100644 index 00000000..0d529528 --- /dev/null +++ b/test/marketForkTests/PendlePTsUSDe29May25MarketForkTest.t.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "./MarketBaseForkTest.sol"; +import {USDeNavBeforeMaturityFeed} from "src/feeds/USDeNavBeforeMaturityFeed.sol"; +import {ChainlinkBasePriceFeed} from "src/feeds/ChainlinkBasePriceFeed.sol"; +import {FeedSwitch} from "src/util/FeedSwitch.sol"; +import {PendleNAVFeed} from "src/feeds/PendleNAVFeed.sol"; +contract PendlePTsUSDe29May25MarketForkTest is MarketBaseForkTest { + address USDeFeed = address(0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961); + address sUSDeFeed = address(0xFF3BC18cCBd5999CE63E788A1c250a88626aD099); + address sUSDe = address(0x9D39A5DE30e57443BfF2A8307A4256c8797A3497); + address pendlePT = address(0xb7de5dFCb74d25c2f21841fbd6230355C50d9308); // PT sUSDe 29 May 25 + address pendlePTHolder = + address(0x8C0824fFccBE9A3CDda4c3d409A0b7447320F364); + + ChainlinkBasePriceFeed sUSDeWrappedFeed; + USDeNavBeforeMaturityFeed beforeMaturityFeed; + ChainlinkBasePriceFeed afterMaturityFeed; + address navFeed; + + uint256 baseDiscount = 0.2 ether; // 20% + FeedSwitch feedSwitch; + + address feedAddr = 0x8f5d8A77e6C1943218854B1eef22401760D4ca10; //FeedSwitch + address marketAddr = 0x2D4788893DE7a4fB42106D9Db36b65463428FBD9; + + function setUp() public { + //This will fail if there's no mainnet variable in foundry.toml + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url); + + _advancedInit(marketAddr, feedAddr, false); + } + + function _deployFeed() internal returns (address feed) { + sUSDeWrappedFeed = new ChainlinkBasePriceFeed( + gov, + sUSDeFeed, + address(0), + 24 hours + ); + navFeed = address(new PendleNAVFeed(pendlePT, baseDiscount)); + beforeMaturityFeed = new USDeNavBeforeMaturityFeed( + address(sUSDeWrappedFeed), + address(sUSDe), + address(navFeed) + ); + afterMaturityFeed = new ChainlinkBasePriceFeed( + gov, + USDeFeed, + address(0), + 24 hours + ); + + feedSwitch = new FeedSwitch( + address(navFeed), + address(beforeMaturityFeed), + address(afterMaturityFeed), + 18 hours, + pendlePT, + pauseGuardian + ); + return address(feedSwitch); + } + + // Override the function to use the PendlePTHolder to avoid error revert: stdStorage find(StdStorage): Slot(s) not found + function gibCollateral( + address _address, + uint _amount + ) internal virtual override { + vm.prank(pendlePTHolder); + IERC20(pendlePT).transfer(_address, _amount); + } +} diff --git a/test/marketForkTests/SdeUSDMarketForkTest.t.sol b/test/marketForkTests/SdeUSDMarketForkTest.t.sol index 7b335e62..adcfe610 100644 --- a/test/marketForkTests/SdeUSDMarketForkTest.t.sol +++ b/test/marketForkTests/SdeUSDMarketForkTest.t.sol @@ -17,7 +17,7 @@ contract SdeUSDMarketForkTest is MarketBaseForkTest { function setUp() public virtual { //This will fail if there's no mainnet variable in foundry.toml string memory url = vm.rpcUrl("mainnet"); - vm.createSelectFork(url, 21880783); + vm.createSelectFork(url, 21880983); address curveFeed = address( new ChainlinkCurveFeed(dolaFeed, curvePool, k, targetIndex) ); diff --git a/test/util/BaseFeedSwitchNavFork.t.sol b/test/util/BaseFeedSwitchNavFork.t.sol new file mode 100644 index 00000000..d9c8c96d --- /dev/null +++ b/test/util/BaseFeedSwitchNavFork.t.sol @@ -0,0 +1,563 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; +import {FeedSwitch, IPendlePT, IChainlinkFeed} from "src/util/FeedSwitch.sol"; +import {ConfigAddr} from "test/ConfigAddr.sol"; +import {console} from "forge-std/console.sol"; +import {MockFeed} from "test/mocks/MockFeed.sol"; + + +contract MockPendlePT { + function expiry() external pure returns (uint256) { + return 100; + } +} + + +interface INavFeed { + function getDiscount(uint256 timeLeft) external view returns (uint256) ; + function maturity() external view returns (uint256); + function decimals() external view returns (uint8); +} + +abstract contract BaseFeedSwitchNavForkTest is Test, ConfigAddr { + FeedSwitch feedSwitch; + IChainlinkFeed navFeed; + IChainlinkFeed beforeMaturityFeed; + IChainlinkFeed afterMaturityFeed; + address guardian = pauseGuardian; + address pendlePT = address(0xb7de5dFCb74d25c2f21841fbd6230355C50d9308); + uint256 baseDiscount; + uint256 timeLockPeriod = 18 hours; + + function initialize(address _beforeMaturityFeed, address _afterMaturityFeed, address _pendlePT, uint256 _baseDiscount, address _navFeed) public { + afterMaturityFeed = IChainlinkFeed(_afterMaturityFeed); + pendlePT = _pendlePT; + baseDiscount = _baseDiscount; + navFeed = IChainlinkFeed(_navFeed); + beforeMaturityFeed = IChainlinkFeed(_beforeMaturityFeed); + feedSwitch = new FeedSwitch( + address(navFeed), + address(beforeMaturityFeed), + address(afterMaturityFeed), + timeLockPeriod, + pendlePT, + guardian + ); + } + function test_Deployment() public view { + assertEq(address(feedSwitch.feed()), address(navFeed)); + assertEq( + address(feedSwitch.beforeMaturityFeed()), + address(beforeMaturityFeed) + ); + assertEq( + address(feedSwitch.afterMaturityFeed()), + address(afterMaturityFeed) + ); + assertEq(feedSwitch.timelockPeriod(), 18 hours); + assertEq(feedSwitch.maturity(), IPendlePT(pendlePT).expiry()); + assertEq(feedSwitch.guardian(), guardian); + (bool isQueued, uint256 timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + + assertEq(INavFeed(address(navFeed)).maturity(), IPendlePT(pendlePT).expiry()); + assertEq(INavFeed(address(navFeed)).decimals(), 18); + } + + function test_updateAt_NAVFeed() public { + INavFeed nav = INavFeed(address(navFeed)); + vm.warp(nav.maturity() - 365 days); + uint256 discount = nav.getDiscount(365 days); + (uint80 roundId, int256 price, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = navFeed.latestRoundData(); + assertEq(uint(price), 1 ether - discount); + assertEq(startedAt, 0); + assertEq(updatedAt, block.timestamp); + assertEq(answeredInRound, 0); + } + function test_NavDiscount() public { + INavFeed nav = INavFeed(address(navFeed)); + + uint256 discount = nav.getDiscount(365 days); + assertEq(discount, baseDiscount); + // 1 year before maturity + vm.warp(nav.maturity() - 365 days); + assertEq(uint(feedSwitch.latestAnswer()), 1 ether - discount); + + discount = nav.getDiscount(365 days / 2); + // 6 months before maturity + vm.warp(block.timestamp + 365 days / 2); + assertEq(discount, baseDiscount / 2); + assertEq(uint(feedSwitch.latestAnswer()), 1 ether - discount); + + discount = nav.getDiscount(0); + // At maturity + vm.warp(nav.maturity()); + assertEq(discount, 0); + assertNotEq(uint(feedSwitch.latestAnswer()), 1 ether - discount); + // Already uses after maturity feed + assertEq(uint(feedSwitch.latestAnswer()), uint(afterMaturityFeed.latestAnswer())); + } + + function test_InitiateFeedSwitch() public { + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq( + feedSwitch.switchCompletedAt(), + block.timestamp + feedSwitch.timelockPeriod() + ); + (bool isQueued, uint256 timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod()); + } + + function test_Fail_InitiateFeedSwitchNotGuardian() public { + vm.expectRevert(FeedSwitch.NotGuardian.selector); + feedSwitch.initiateFeedSwitch(); + } + + function test_SwitchFeed_before_maturity() public { + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + (,int256 navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + (bool isQueued, uint256 timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod()); + + vm.warp(block.timestamp + 0.5 days); + int256 price = feedSwitch.latestAnswer(); + (,navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq(uint(price), uint(navFeedPrice), "initial feed"); + // Not yet switched + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod() - 0.5 days); + + vm.warp(block.timestamp + 1 days); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + + // After switch, not queued anymore + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + } + + function test_SwitchFeed_after_maturity_after_switch() public { + (,int256 navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + vm.warp(block.timestamp + 1 days); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + (bool isQueued, uint256 timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + vm.warp(IPendlePT(pendlePT).expiry() + 1); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(afterMaturityFeed.latestAnswer()) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + } + + function test_SwitchFeed_after_maturity() public { + (,int256 navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + (bool isQueued, uint256 timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + + vm.warp(IPendlePT(pendlePT).expiry() + 1); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(afterMaturityFeed.latestAnswer()) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + } + + function test_SwitchFeed_before_maturity_and_after_maturity() public { + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + (,int256 navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + (bool isQueued, uint256 timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod()); + + // Before Maturity + vm.warp(block.timestamp + 1 days); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + + vm.warp(IPendlePT(pendlePT).expiry() + 1); + // After Maturity + assertEq( + uint(feedSwitch.latestAnswer()), + uint(afterMaturityFeed.latestAnswer()) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + } + + function test_Cancel_feed_switch() public { + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), block.timestamp + 18 hours); + (,int256 navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + vm.warp(block.timestamp + 0.5 days); + + (bool isQueued, uint timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod() - 0.5 days); + + // Cancel the feed switch + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), 0); + (,navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice), + "before feed switch" + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + + vm.warp(block.timestamp + 1 days); + assertEq(feedSwitch.switchCompletedAt(), 0); + (,navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + } + + function test_Cancel_feed_switch_and_reswitch() public { + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), block.timestamp + 18 hours); + (,int256 navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + vm.warp(block.timestamp + 0.5 days); + (bool isQueued, uint256 timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod() - 0.5 days); + + // Cancel the feed switch + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), 0); + (,navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice), + "before feed switch" + ); + assertEq(feedSwitch.switchCompletedAt(), 0); + // Not queued anymore + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + + // After the feed is canceled, it keeps using the navFeed + vm.warp(block.timestamp + 1 days); + (,navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + // Initiate a feed switch again + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), block.timestamp + 18 hours); + (,navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod()); + + vm.warp(block.timestamp + 1 days); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + // Feed switched so not queued anymore + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + } + + function test_Cancel_feed_switch_with_beforeMaturityFeed_and_reswitch() + public + { + // Switch feed to beforeMaturityFeed + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), block.timestamp + 18 hours); + (,int256 navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + (bool isQueued, uint256 timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod()); + + vm.warp(block.timestamp + 1 days); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + + // Initiate a feed switch again + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), block.timestamp + 18 hours); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + + // Cancel it when it is in the timelock period and keep using beforeMaturityFeed + vm.warp(block.timestamp + 0.5 days); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod() - 0.5 days); + + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), 0); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + // Initiate a feed switch again + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq(feedSwitch.switchCompletedAt(), block.timestamp + 18 hours); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod()); + + vm.warp(block.timestamp + 1 days); + (,navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + } + + function test_SwitchFeed_twice_before_maturity() public { + // Previous Feed is not initialized and current feed is navFeed + assertEq(address(feedSwitch.previousFeed()), address(0)); + assertEq(address(feedSwitch.feed()), address(navFeed)); + (,int256 navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + // Initiate a feed switch + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + // Feed switch initiated + assertEq(address(feedSwitch.previousFeed()), address(navFeed)); + assertEq(address(feedSwitch.feed()), address(beforeMaturityFeed)); + // Before timelock period, navFeed is still the one used + (,navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + (bool isQueued, uint timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod()); + + // After timelock period, beforeMaturityFeed is used + vm.warp(block.timestamp + 1 days); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + + // After the switch is completed, the feed is switched back to navFeed + assertEq(address(feedSwitch.previousFeed()), address(navFeed)); + assertEq(address(feedSwitch.feed()), address(beforeMaturityFeed)); + + vm.prank(guardian); + feedSwitch.initiateFeedSwitch(); + assertEq( + address(feedSwitch.previousFeed()), + address(beforeMaturityFeed) + ); + assertEq(address(feedSwitch.feed()), address(navFeed)); + // Before timelock period, beforeMaturityFeed is still the one used + assertEq( + uint(feedSwitch.latestAnswer()), + uint(beforeMaturityFeed.latestAnswer()) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, true); + assertEq(timeLeft, feedSwitch.timelockPeriod()); + + vm.warp(block.timestamp + 1 days); + // After timelock period, navFeed is used + (,navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq( + uint(feedSwitch.latestAnswer()), + uint(navFeedPrice) + ); + (isQueued, timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + } + + function test_Fail_initiateFeedSwitch_after_maturity() public { + vm.warp(IPendlePT(pendlePT).expiry() + 1); + vm.prank(guardian); + vm.expectRevert(FeedSwitch.MaturityPassed.selector); + feedSwitch.initiateFeedSwitch(); + } + + function test_LatestRoundData() public view { + ( + uint80 roundId, + int256 price, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = feedSwitch.latestRoundData(); + (,int256 navFeedPrice,, uint256 updatedAtNav,) = navFeed.latestRoundData(); + + assertEq(updatedAt, updatedAtNav); + assertEq(uint(price), uint(navFeedPrice)); + } + + function test_LatestAnswer() public view { + int256 price = feedSwitch.latestAnswer(); + (,int256 navFeedPrice,,,) = navFeed.latestRoundData(); + assertEq(uint(price), uint(navFeedPrice)); + } + + function test_Decimals() public view { + uint8 decimals = feedSwitch.decimals(); + assertEq(decimals, 18); + } + + function test_isFeedSwitchQueued() public view { + (bool isQueued, uint256 timeLeft) = feedSwitch.isFeedSwitchQueued(); + assertEq(isQueued, false); + assertEq(timeLeft, 0); + } + + function test_Deploy_Revert_Wrong_Decimals() public { + MockFeed wrongDecimalsFeed = new MockFeed(8, 1e18); + vm.expectRevert(FeedSwitch.FeedDecimalsMismatch.selector); + FeedSwitch feedSwitch2 = new FeedSwitch( + address(wrongDecimalsFeed), + address(beforeMaturityFeed), + address(afterMaturityFeed), + 18 hours, + pendlePT, + guardian + ); + + vm.expectRevert(FeedSwitch.FeedDecimalsMismatch.selector); + feedSwitch2 = new FeedSwitch( + address(navFeed), + address(wrongDecimalsFeed), + address(afterMaturityFeed), + 18 hours, + pendlePT, + guardian + ); + + vm.expectRevert(FeedSwitch.FeedDecimalsMismatch.selector); + feedSwitch2 = new FeedSwitch( + address(navFeed), + address(beforeMaturityFeed), + address(wrongDecimalsFeed), + 18 hours, + pendlePT, + guardian + ); + } + + function test_Deploy_maturity_in_past() public { + MockPendlePT MockPendlePT = new MockPendlePT(); + vm.expectRevert(FeedSwitch.MaturityInPast.selector); + FeedSwitch feedSwitch2 = new FeedSwitch( + address(navFeed), + address(beforeMaturityFeed), + address(afterMaturityFeed), + 18 hours, + address(MockPendlePT), + guardian + ); + } +} diff --git a/test/util/FeedSwitchNavSUSDe29May25.t.sol b/test/util/FeedSwitchNavSUSDe29May25.t.sol new file mode 100644 index 00000000..dcbdc014 --- /dev/null +++ b/test/util/FeedSwitchNavSUSDe29May25.t.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.18; + +import {FeedSwitch, IChainlinkFeed} from "src/util/FeedSwitch.sol"; +import {BaseFeedSwitchNavForkTest} from "test/util/BaseFeedSwitchNavFork.t.sol"; +import {USDeNavBeforeMaturityFeed} from "src/feeds/USDeNavBeforeMaturityFeed.sol"; +import {PendleNAVFeed} from "src/feeds/PendleNAVFeed.sol"; + +contract FeedSwitchNavSUSDe29May25Test is BaseFeedSwitchNavForkTest { + address _beforeMaturityFeed; + address _afterMaturityFeed = address(0xB3C1D801A02d88adC96A294123c2Daa382345058); // USDe Chainlink Wrapper + address _pendlePT = address(0xb7de5dFCb74d25c2f21841fbd6230355C50d9308); // PT sUSDe 29 May 25 + uint256 _baseDiscount = 0.2 ether; // 20% + address sUSDeWrapper = address(0xD723a0910e261de49A90779d38A94aFaAA028F15); + address sUSDe = address(0x9D39A5DE30e57443BfF2A8307A4256c8797A3497); + + function setUp() public { + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url, 22018716); + + PendleNAVFeed _navFeed = new PendleNAVFeed(_pendlePT, _baseDiscount); + _beforeMaturityFeed = address(new USDeNavBeforeMaturityFeed(sUSDeWrapper,sUSDe,address(_navFeed))); // USDeBeforeMaturityFeed: USDe/USD Feed using sUSDe Chainlink feed and sUSDe/USDe rate and NAV + + initialize(address(_beforeMaturityFeed), address(_afterMaturityFeed), _pendlePT , _baseDiscount, address(_navFeed)); + } +}