From 014d96a2be33f8012cc5b1cea18f2d4399a00364 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 18 Feb 2025 18:23:10 -0500 Subject: [PATCH 01/14] add initial sdeUSD feed --- src/feeds/SdeUSDFeed.sol | 98 +++++++++++++++++++++++++ src/interfaces/ICurvePool.sol | 2 + test/feedForkTests/SdeUSDFeedFork.t.sol | 89 ++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 src/feeds/SdeUSDFeed.sol create mode 100644 test/feedForkTests/SdeUSDFeedFork.t.sol diff --git a/src/feeds/SdeUSDFeed.sol b/src/feeds/SdeUSDFeed.sol new file mode 100644 index 00000000..65aaf110 --- /dev/null +++ b/src/feeds/SdeUSDFeed.sol @@ -0,0 +1,98 @@ +// 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"; +import {ICurvePool} from "src/interfaces/ICurvePool.sol"; + +contract SdeUSDFeed { + error DecimalsMismatch(); + + ICurvePool public immutable curvePool; + uint256 public immutable k; + IERC4626 public immutable sdeUSD; + IChainlinkBasePriceFeed public immutable dolaFeed; + uint256 public constant SCALE = 1e18; + string public description; + + constructor( + address _curvePool, + uint256 _k, + address _sdeUSD, + address _dolaFeed + ) { + curvePool = ICurvePool(_curvePool); + k = _k; + sdeUSD = IERC4626(_sdeUSD); + dolaFeed = IChainlinkBasePriceFeed(_dolaFeed); + if ( + curvePool.decimals() != 18 || + sdeUSD.decimals() != 18 || + dolaFeed.decimals() != 18 + ) revert DecimalsMismatch(); + + description = string( + abi.encodePacked( + "sdeUSD/USD using DOLA feed, sdeUSD/DOLA pool and sdeUSD/deUSD rate" + ) + ); + } + + /** + * @return roundId The round ID of DOLA price feed + * @return sdeUSDToUsdPrice The latest sdeUSD price in USD + * @return startedAt The timestamp when the latest round of DOLA feed started + * @return updatedAt The timestamp when the latest round of DOLA feed was updated + * @return answeredInRound The round ID in which the answer was computed + */ + function latestRoundData() + public + view + returns (uint80, int256, uint256, uint256, uint80) + { + uint256 sdeUSDNormalizedToDola = curvePool.price_oracle(k); + + uint256 sdeUSDToDeUSDRate = sdeUSD.convertToAssets(SCALE); + + // Multiply Normalized sdeUSD/DOLA price by sdeUSD/DeUSD rate to get sdeUSD/DOLA price + int256 sdeUSDToDolaPrice = int256( + (sdeUSDNormalizedToDola * sdeUSDToDeUSDRate) / SCALE + ); + + ( + uint80 roundId, + int256 dolaPrice, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = dolaFeed.latestRoundData(); + + // Multiply sdeUSD/DOLA price by DOLA/USD price to get sdeUSD/USD price + int256 sdeUSDToUsdPrice = (sdeUSDToDolaPrice * dolaPrice) / + int256(SCALE); + return ( + roundId, + sdeUSDToUsdPrice, + startedAt, + updatedAt, + answeredInRound + ); + } + + /** + @notice Retrieves the latest sdeUSD price + @return price The latest sdeUSD 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/src/interfaces/ICurvePool.sol b/src/interfaces/ICurvePool.sol index cce8cf5b..3bbe0e00 100644 --- a/src/interfaces/ICurvePool.sol +++ b/src/interfaces/ICurvePool.sol @@ -75,4 +75,6 @@ interface ICurvePool { function symbol() external view returns (string memory); function lp_token() external view returns (address); + + function decimals() external view returns (uint256); } diff --git a/test/feedForkTests/SdeUSDFeedFork.t.sol b/test/feedForkTests/SdeUSDFeedFork.t.sol new file mode 100644 index 00000000..b5950618 --- /dev/null +++ b/test/feedForkTests/SdeUSDFeedFork.t.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "src/feeds/SdeUSDFeed.sol"; +import "forge-std/console.sol"; + +contract SdeUSDFeedForkTest is Test { + SdeUSDFeed feed; + address curvePool = address(0x82202CAEC5E6d85014eADC68D4912F3C90093e7C); + uint256 k = 0; + address sdeUSD = address(0x5C5b196aBE0d54485975D1Ec29617D42D9198326); + address dolaFeed = address(0x6255981e2a1EBeA600aFC506185590eD383517be); + + function setUp() public { + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url); + feed = new SdeUSDFeed(curvePool, k, sdeUSD, dolaFeed); + } + + function test_decimals() public { + assertEq(feed.decimals(), 18); + } + + function test_latestRoundData() public { + ( + uint80 roundId, + int256 sdeUSDToUsdPrice, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = feed.latestRoundData(); + assertEq(feed.latestAnswer(), _calculateSdeUSDPrice()); + ( + uint80 roundId2, + , + uint256 startedAt2, + uint256 updatedAt2, + uint80 answeredInRound2 + ) = IChainlinkBasePriceFeed(dolaFeed).latestRoundData(); + // Data are + assertEq(roundId, roundId2); + assertEq(sdeUSDToUsdPrice, _calculateSdeUSDPrice()); + assertEq(startedAt, startedAt2); + assertEq(updatedAt, updatedAt2); + assertEq(answeredInRound, answeredInRound2); + } + + function test_sdeUSD_upward_depeg() public { + int256 answer = feed.latestAnswer(); + assertEq(feed.latestAnswer(), _calculateSdeUSDPrice()); + uint256 mockRate = 2e18; + _mockVaultRate(sdeUSD, mockRate); + assertEq(feed.latestAnswer(), _calculateSdeUSDPrice()); + assertGt(feed.latestAnswer(), answer); + } + + function test_sdeUSD_downward_depeg() public { + int256 answer = feed.latestAnswer(); + assertEq(feed.latestAnswer(), _calculateSdeUSDPrice()); + uint256 mockRate = 0.5e18; + _mockVaultRate(sdeUSD, mockRate); + assertEq(feed.latestAnswer(), _calculateSdeUSDPrice()); + assertLt(feed.latestAnswer(), answer); + } + + function _calculateSdeUSDPrice() internal view returns (int256) { + uint256 sdeUSDNormalizedToDola = ICurvePool(curvePool).price_oracle( + feed.k() + ); + + uint256 sdeUSDToDeUSDRate = IERC4626(sdeUSD).convertToAssets(1e18); + int256 sdeUSDToDolaPrice = int256( + (sdeUSDNormalizedToDola * sdeUSDToDeUSDRate) / 1e18 + ); + int256 dolaPrice = IChainlinkBasePriceFeed(feed.dolaFeed()) + .latestAnswer(); + + return (sdeUSDToDolaPrice * dolaPrice) / int256(1e18); + } + + function _mockVaultRate(address vault, uint256 mockRate) internal { + vm.mockCall( + vault, + abi.encodeWithSelector(IERC4626.convertToAssets.selector, 1e18), + abi.encode(mockRate) + ); + } +} From fb6e11ce092eeab52a4e1a7786c38d65359297c9 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Wed, 19 Feb 2025 09:54:19 -0500 Subject: [PATCH 02/14] add sdeUSD market test --- .../SdeUSDMarketForkTest.t.sol | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 test/marketForkTests/SdeUSDMarketForkTest.t.sol diff --git a/test/marketForkTests/SdeUSDMarketForkTest.t.sol b/test/marketForkTests/SdeUSDMarketForkTest.t.sol new file mode 100644 index 00000000..8d967adf --- /dev/null +++ b/test/marketForkTests/SdeUSDMarketForkTest.t.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "./MarketBaseForkTest.sol"; +import "src/feeds/SdeUSDFeed.sol"; +import "src/Market.sol"; + +contract SdeUSDMarketForkTest is MarketBaseForkTest { + address curvePool = address(0x82202CAEC5E6d85014eADC68D4912F3C90093e7C); + uint256 k = 0; + address sdeUSD = address(0x5C5b196aBE0d54485975D1Ec29617D42D9198326); + address dolaFeed = address(0x6255981e2a1EBeA600aFC506185590eD383517be); + + function setUp() public { + //This will fail if there's no mainnet variable in foundry.toml + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url, 21880783); + + address feedAddr = address( + new SdeUSDFeed(curvePool, k, sdeUSD, dolaFeed) + ); + + address marketAddr = address( + new Market( + gov, + lender, + pauseGuardian, + simpleERC20EscrowAddr, + IDolaBorrowingRights(dbrAddr), + IERC20(sdeUSD), + IOracle(oracleAddr), + 5000, + 1000, + 1000, + false + ) + ); + _advancedInit(marketAddr, feedAddr, false); + } +} From 5344ed91370403fc1a5eab97e3f280a83f528b93 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 20 Feb 2025 18:00:29 -0500 Subject: [PATCH 03/14] add generalized ERC4626 feed for ChainlinkCurve feeds, update tests --- src/feeds/ERC4626Feed.sol | 89 +++++++++++++++++ src/feeds/SdeUSDFeed.sol | 98 ------------------- src/interfaces/IChainlinkCurveFeed.sol | 31 ++++++ test/feedForkTests/SdeUSDFeedFork.t.sol | 30 ++++-- .../SdeUSDMarketForkTest.t.sol | 10 +- 5 files changed, 146 insertions(+), 112 deletions(-) create mode 100644 src/feeds/ERC4626Feed.sol delete mode 100644 src/feeds/SdeUSDFeed.sol create mode 100644 src/interfaces/IChainlinkCurveFeed.sol diff --git a/src/feeds/ERC4626Feed.sol b/src/feeds/ERC4626Feed.sol new file mode 100644 index 00000000..f03ca195 --- /dev/null +++ b/src/feeds/ERC4626Feed.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {IChainlinkCurveFeed} from "src/interfaces/IChainlinkCurveFeed.sol"; +import {IERC4626} from "lib/openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; + +/// @title ERC4626Feed +/// @notice This contract is a generalized contract for an ERC4626 vault which has a feed in a Normalized Asset to USD price +/// @dev It will convert the normalized asset to USD price to the Asset to USD price using the vault's rate +/// @dev This is contract is meant to be used in combination with ChainlinkCurveFeed or ChainlinkCurve2CoinsFeed contracts. + +contract ERC4626Feed { + error DecimalsMismatch(); + + IChainlinkCurveFeed public immutable feed; + IERC4626 public immutable vault; + uint256 public constant SCALE = 1e18; + string public description; + + constructor(address _vault, address _feed) { + feed = IChainlinkCurveFeed(_feed); + vault = IERC4626(_vault); + + if (feed.decimals() != 18 || vault.decimals() != 18) + revert DecimalsMismatch(); + + description = string( + abi.encodePacked( + feed.description(), + " using ", + vault.symbol(), + " vault rate" + ) + ); + } + + /** + * @return roundId The round ID from the feed + * @return assetToUsdPrice The latest asset price in USD + * @return startedAt The timestamp when the latest round of feed started + * @return updatedAt The timestamp when the latest round of 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 normalizedAssetToUsdPrice, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = feed.latestRoundData(); + + uint256 assetToUnderlyingRate = vault.convertToAssets(SCALE); + + // Multiply Normalized Asset/USD price by asset/underlying rate to get Asset/USD price + int256 assetToUsdPrice = int256( + (uint256(normalizedAssetToUsdPrice) * assetToUnderlyingRate) / SCALE + ); + + return ( + roundId, + assetToUsdPrice, + startedAt, + updatedAt, + answeredInRound + ); + } + + /** + @notice Retrieves the latest sdeUSD price + @return price The latest sdeUSD 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/src/feeds/SdeUSDFeed.sol b/src/feeds/SdeUSDFeed.sol deleted file mode 100644 index 65aaf110..00000000 --- a/src/feeds/SdeUSDFeed.sol +++ /dev/null @@ -1,98 +0,0 @@ -// 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"; -import {ICurvePool} from "src/interfaces/ICurvePool.sol"; - -contract SdeUSDFeed { - error DecimalsMismatch(); - - ICurvePool public immutable curvePool; - uint256 public immutable k; - IERC4626 public immutable sdeUSD; - IChainlinkBasePriceFeed public immutable dolaFeed; - uint256 public constant SCALE = 1e18; - string public description; - - constructor( - address _curvePool, - uint256 _k, - address _sdeUSD, - address _dolaFeed - ) { - curvePool = ICurvePool(_curvePool); - k = _k; - sdeUSD = IERC4626(_sdeUSD); - dolaFeed = IChainlinkBasePriceFeed(_dolaFeed); - if ( - curvePool.decimals() != 18 || - sdeUSD.decimals() != 18 || - dolaFeed.decimals() != 18 - ) revert DecimalsMismatch(); - - description = string( - abi.encodePacked( - "sdeUSD/USD using DOLA feed, sdeUSD/DOLA pool and sdeUSD/deUSD rate" - ) - ); - } - - /** - * @return roundId The round ID of DOLA price feed - * @return sdeUSDToUsdPrice The latest sdeUSD price in USD - * @return startedAt The timestamp when the latest round of DOLA feed started - * @return updatedAt The timestamp when the latest round of DOLA feed was updated - * @return answeredInRound The round ID in which the answer was computed - */ - function latestRoundData() - public - view - returns (uint80, int256, uint256, uint256, uint80) - { - uint256 sdeUSDNormalizedToDola = curvePool.price_oracle(k); - - uint256 sdeUSDToDeUSDRate = sdeUSD.convertToAssets(SCALE); - - // Multiply Normalized sdeUSD/DOLA price by sdeUSD/DeUSD rate to get sdeUSD/DOLA price - int256 sdeUSDToDolaPrice = int256( - (sdeUSDNormalizedToDola * sdeUSDToDeUSDRate) / SCALE - ); - - ( - uint80 roundId, - int256 dolaPrice, - uint256 startedAt, - uint256 updatedAt, - uint80 answeredInRound - ) = dolaFeed.latestRoundData(); - - // Multiply sdeUSD/DOLA price by DOLA/USD price to get sdeUSD/USD price - int256 sdeUSDToUsdPrice = (sdeUSDToDolaPrice * dolaPrice) / - int256(SCALE); - return ( - roundId, - sdeUSDToUsdPrice, - startedAt, - updatedAt, - answeredInRound - ); - } - - /** - @notice Retrieves the latest sdeUSD price - @return price The latest sdeUSD 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/src/interfaces/IChainlinkCurveFeed.sol b/src/interfaces/IChainlinkCurveFeed.sol new file mode 100644 index 00000000..1b44f91b --- /dev/null +++ b/src/interfaces/IChainlinkCurveFeed.sol @@ -0,0 +1,31 @@ +pragma solidity ^0.8.13; + +import {IChainlinkBasePriceFeed} from "src/interfaces/IChainlinkFeed.sol"; +import {ICurvePool} from "src/interfaces/ICurvePool.sol"; + +interface IChainlinkCurveFeed { + function decimals() external view returns (uint8 decimals); + + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 crvUsdPrice, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ); + + function latestAnswer() external view returns (int256 price); + + function description() external view returns (string memory description); + + function assetToUsd() external view returns (IChainlinkBasePriceFeed feed); + + function curvePool() external view returns (ICurvePool pool); + + function targetIndex() external view returns (uint256 index); + + function assetOrTargetK() external view returns (uint256 k); +} diff --git a/test/feedForkTests/SdeUSDFeedFork.t.sol b/test/feedForkTests/SdeUSDFeedFork.t.sol index b5950618..91643aa4 100644 --- a/test/feedForkTests/SdeUSDFeedFork.t.sol +++ b/test/feedForkTests/SdeUSDFeedFork.t.sol @@ -2,26 +2,35 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import "src/feeds/SdeUSDFeed.sol"; +import "src/feeds/ERC4626Feed.sol"; +import "src/interfaces/IChainlinkFeed.sol"; +import {ChainlinkCurveFeed, ICurvePool} from "src/feeds/ChainlinkCurveFeed.sol"; import "forge-std/console.sol"; contract SdeUSDFeedForkTest is Test { - SdeUSDFeed feed; + ChainlinkCurveFeed curveFeed; + ERC4626Feed feed; address curvePool = address(0x82202CAEC5E6d85014eADC68D4912F3C90093e7C); uint256 k = 0; + uint256 targetIndex = 1; address sdeUSD = address(0x5C5b196aBE0d54485975D1Ec29617D42D9198326); address dolaFeed = address(0x6255981e2a1EBeA600aFC506185590eD383517be); function setUp() public { string memory url = vm.rpcUrl("mainnet"); vm.createSelectFork(url); - feed = new SdeUSDFeed(curvePool, k, sdeUSD, dolaFeed); + curveFeed = new ChainlinkCurveFeed(dolaFeed, curvePool, k, targetIndex); + feed = new ERC4626Feed(sdeUSD, address(curveFeed)); } function test_decimals() public { assertEq(feed.decimals(), 18); } + function test_description() public { + assertEq(feed.description(), "sdeUSD / USD using sdeUSD vault rate"); + } + function test_latestRoundData() public { ( uint80 roundId, @@ -66,17 +75,18 @@ contract SdeUSDFeedForkTest is Test { function _calculateSdeUSDPrice() internal view returns (int256) { uint256 sdeUSDNormalizedToDola = ICurvePool(curvePool).price_oracle( - feed.k() + curveFeed.assetOrTargetK() ); - uint256 sdeUSDToDeUSDRate = IERC4626(sdeUSD).convertToAssets(1e18); - int256 sdeUSDToDolaPrice = int256( - (sdeUSDNormalizedToDola * sdeUSDToDeUSDRate) / 1e18 + int256 dolaToUsdPrice = curveFeed.assetToUsd().latestAnswer(); + int256 sdeUSDNormalizedToUsdPrice = int256( + (sdeUSDNormalizedToDola * uint(dolaToUsdPrice)) / 1e18 ); - int256 dolaPrice = IChainlinkBasePriceFeed(feed.dolaFeed()) - .latestAnswer(); - return (sdeUSDToDolaPrice * dolaPrice) / int256(1e18); + uint256 sdeUSDToDeUSDRate = IERC4626(sdeUSD).convertToAssets(1e18); + return + (sdeUSDNormalizedToUsdPrice * int(sdeUSDToDeUSDRate)) / + int256(1e18); } function _mockVaultRate(address vault, uint256 mockRate) internal { diff --git a/test/marketForkTests/SdeUSDMarketForkTest.t.sol b/test/marketForkTests/SdeUSDMarketForkTest.t.sol index 8d967adf..a84ffc08 100644 --- a/test/marketForkTests/SdeUSDMarketForkTest.t.sol +++ b/test/marketForkTests/SdeUSDMarketForkTest.t.sol @@ -3,12 +3,14 @@ pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "./MarketBaseForkTest.sol"; -import "src/feeds/SdeUSDFeed.sol"; +import "src/feeds/ERC4626Feed.sol"; import "src/Market.sol"; +import {ChainlinkCurveFeed} from "src/feeds/ChainlinkCurveFeed.sol"; contract SdeUSDMarketForkTest is MarketBaseForkTest { address curvePool = address(0x82202CAEC5E6d85014eADC68D4912F3C90093e7C); uint256 k = 0; + uint256 targetIndex = 1; address sdeUSD = address(0x5C5b196aBE0d54485975D1Ec29617D42D9198326); address dolaFeed = address(0x6255981e2a1EBeA600aFC506185590eD383517be); @@ -16,10 +18,10 @@ contract SdeUSDMarketForkTest is MarketBaseForkTest { //This will fail if there's no mainnet variable in foundry.toml string memory url = vm.rpcUrl("mainnet"); vm.createSelectFork(url, 21880783); - - address feedAddr = address( - new SdeUSDFeed(curvePool, k, sdeUSD, dolaFeed) + address curveFeed = address( + new ChainlinkCurveFeed(dolaFeed, curvePool, k, targetIndex) ); + address feedAddr = address(new ERC4626Feed(sdeUSD, curveFeed)); address marketAddr = address( new Market( From d76af76e9cc54c1c1aaa0d4799302ff9e9245133 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 20 Feb 2025 18:09:50 -0500 Subject: [PATCH 04/14] update natspec --- src/feeds/ERC4626Feed.sol | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/feeds/ERC4626Feed.sol b/src/feeds/ERC4626Feed.sol index f03ca195..63280a86 100644 --- a/src/feeds/ERC4626Feed.sol +++ b/src/feeds/ERC4626Feed.sol @@ -12,9 +12,13 @@ import {IERC4626} from "lib/openzeppelin-contracts/contracts/interfaces/IERC4626 contract ERC4626Feed { error DecimalsMismatch(); + // ChainlinkCurve feed for the normalized asset to USD price IChainlinkCurveFeed public immutable feed; + // ERC4626 vault asset IERC4626 public immutable vault; + // Scaling factor uint256 public constant SCALE = 1e18; + // Description of the feed string public description; constructor(address _vault, address _feed) { @@ -71,8 +75,8 @@ contract ERC4626Feed { } /** - @notice Retrieves the latest sdeUSD price - @return price The latest sdeUSD price + @notice Retrieves the latest asset price + @return price The latest asset price */ function latestAnswer() external view returns (int256) { (, int256 price, , , ) = latestRoundData(); From b2666fb91393b09a9e6f6b31cd280cb8b4ab52e5 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 25 Feb 2025 13:09:06 -0500 Subject: [PATCH 05/14] add sdeUSD ale tests, add standard ale test refactor --- .../SdeUSDMarketForkTest.t.sol | 2 +- .../util/aleTests/ALEBaseSimpleForkTest.t.sol | 1161 +++++++++++++++++ test/util/aleTests/ALESdeUSDForkTest.t.sol | 33 + 3 files changed, 1195 insertions(+), 1 deletion(-) create mode 100644 test/util/aleTests/ALEBaseSimpleForkTest.t.sol create mode 100644 test/util/aleTests/ALESdeUSDForkTest.t.sol diff --git a/test/marketForkTests/SdeUSDMarketForkTest.t.sol b/test/marketForkTests/SdeUSDMarketForkTest.t.sol index a84ffc08..7b335e62 100644 --- a/test/marketForkTests/SdeUSDMarketForkTest.t.sol +++ b/test/marketForkTests/SdeUSDMarketForkTest.t.sol @@ -14,7 +14,7 @@ contract SdeUSDMarketForkTest is MarketBaseForkTest { address sdeUSD = address(0x5C5b196aBE0d54485975D1Ec29617D42D9198326); address dolaFeed = address(0x6255981e2a1EBeA600aFC506185590eD383517be); - function setUp() public { + 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); diff --git a/test/util/aleTests/ALEBaseSimpleForkTest.t.sol b/test/util/aleTests/ALEBaseSimpleForkTest.t.sol new file mode 100644 index 00000000..e3d5d865 --- /dev/null +++ b/test/util/aleTests/ALEBaseSimpleForkTest.t.sol @@ -0,0 +1,1161 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "test/marketForkTests/SdeUSDMarketForkTest.t.sol"; +import {ALE} from "src/util/ALE.sol"; + +contract MockExchangeProxy { + IOracle oracle; + IERC20 dola; + + constructor(address _oracle, address _dola) { + oracle = IOracle(_oracle); + dola = IERC20(_dola); + } + + function swapDolaIn( + IERC20 collateral, + uint256 dolaAmount + ) external returns (bool success, bytes memory ret) { + dola.transferFrom(msg.sender, address(this), dolaAmount); + uint256 collateralAmount = (dolaAmount * 1e18) / + oracle.viewPrice(address(collateral), 0); + collateral.transfer(msg.sender, collateralAmount); + success = true; + } + + function swapDolaOut( + IERC20 collateral, + uint256 collateralAmount + ) external returns (bool success, bytes memory ret) { + collateral.transferFrom(msg.sender, address(this), collateralAmount); + uint256 dolaAmount = (collateralAmount * + oracle.viewPrice(address(collateral), 0)) / 1e18; + dola.transfer(msg.sender, dolaAmount); + success = true; + } +} + +interface IFlashMinter { + function setMaxFlashLimit(uint256 limit) external; +} + +abstract contract ALEBaseSimpleForkTest is MarketForkTest { + bytes exceededLimit = "Exceeded credit limit"; + bytes repaymentGtThanDebt = "Repayment greater than debt"; + + error NothingToDeposit(); + + MockExchangeProxy exchangeProxy; + ALE ale; + address triDBR = 0xC7DE47b9Ca2Fc753D6a2F167D8b3e19c6D18b19a; + IFlashMinter flash; + + function getMaxLeverageBorrowAmount( + uint256 collateralAmount, + uint256 iterations + ) internal view returns (uint256) { + uint256 maxDolaAmount = getMaxBorrowAmount(collateralAmount); + uint256 totalDola = maxDolaAmount; + for (uint i = 0; i < iterations; i++) { + uint256 dolaAmount = getMaxBorrowAmount( + convertDolaToCollat(maxDolaAmount) + ); + maxDolaAmount = dolaAmount; + totalDola += dolaAmount; + } + return totalDola; + } + + function test_depositAndLeveragePosition_buyDBR(uint256 amount) public { + vm.assume(amount < 50000 ether); + vm.assume(amount > 0.000001 ether); + // We are going to deposit and leverage the position + // uint amount = 13606; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + + uint maxBorrowAmount = getMaxBorrowAmount(amount) / 10; // we want to borrow only 10% of the max amount to exchange + + // recharge mocked proxy for swap, we need to swap DOLA to collateral + + deal( + address(market.collateral()), + address(exchangeProxy), + convertDolaToCollat(maxBorrowAmount) + ); + + vm.startPrank(userPk, userPk); + + // Calculate the amount of DOLA needed to borrow to buy the DBR needed to cover for the borrowing period + (uint256 dolaForDBR, uint256 dbrAmount) = ale + .approximateDolaAndDbrNeeded(maxBorrowAmount, 365 days, 8); + + // Sign Message for borrow on behalf + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + maxBorrowAmount + dolaForDBR, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData = ALE.DBRHelper( + dolaForDBR, + (dbrAmount * 98) / 100, + 0 + ); // DBR buy + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaIn.selector, + collateral, + maxBorrowAmount + ); + + assertEq(dbr.balanceOf(userPk), 0); + + collateral.approve(address(ale), amount); + + // We set amount as initial deposit + ale.depositAndLeveragePosition( + amount, + maxBorrowAmount, + address(market), + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData, + false + ); + + // Balance in escrow is equal to the collateral deposited + the extra collateral swapped from the leverage + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + amount + convertDolaToCollat(maxBorrowAmount) + ); + assertEq(DOLA.balanceOf(userPk), 0); + + assertGt(dbr.balanceOf(userPk), (dbrAmount * 98) / 100); + } + + function test_fail_depositAndLeveragePosition_buyDBR_with_ZERO_deposit() + public + { + // We are going to deposit and leverage the position + uint amount = 1 ether; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + + uint maxBorrowAmount = getMaxBorrowAmount(amount) / 10; // we want to borrow only 10% of the max amount to exchange + + // recharge mocked proxy for swap, we need to swap DOLA to collateral + deal( + address(market.collateral()), + address(exchangeProxy), + convertDolaToCollat(maxBorrowAmount) + ); + + vm.startPrank(userPk, userPk); + + // Calculate the amount of DOLA needed to borrow to buy the DBR needed to cover for the borrowing period + (uint256 dolaForDBR, uint256 dbrAmount) = ale + .approximateDolaAndDbrNeeded(maxBorrowAmount, 365 days, 8); + + // Sign Message for borrow on behalf + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + maxBorrowAmount + dolaForDBR, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData = ALE.DBRHelper( + dolaForDBR, + (dbrAmount * 98) / 100, // DBR buy, + 0 // Dola to borrow and withdraw after leverage + ); + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaIn.selector, + collateral, + maxBorrowAmount + ); + + assertEq(dbr.balanceOf(userPk), 0); + + collateral.approve(address(ale), amount); + + // We try to set 0 as initial deposit, reverts + vm.expectRevert(NothingToDeposit.selector); + ale.depositAndLeveragePosition( + 0, + maxBorrowAmount, + address(market), + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData, + false + ); + } + + function test_leveragePosition_buyDBR_withdrawDOLA() public { + // We are going to deposit some CRV, then leverage the position + uint amount = 1000 ether; + uint dolaToWithdraw = 100 ether; + + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + + uint maxBorrowAmount = getMaxBorrowAmount(amount); + + // recharge mocked proxy for swap, we need to swap DOLA to collateral + deal( + address(market.collateral()), + address(exchangeProxy), + convertDolaToCollat(maxBorrowAmount + dolaToWithdraw) + ); + + vm.startPrank(userPk, userPk); + // Initial CRV deposit + deposit(amount); + + // Calculate the amount of DOLA needed to borrow to buy the DBR needed to cover for the borrowing period + (uint256 dolaForDBR, uint256 dbrAmount) = ale + .approximateDolaAndDbrNeeded(maxBorrowAmount, 365 days, 8); + + // Sign Message for borrow on behalf + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + maxBorrowAmount + dolaForDBR + dolaToWithdraw, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData = ALE.DBRHelper( + dolaForDBR, + (dbrAmount * 98) / 100, // DBR buy + dolaToWithdraw // Dola to borrow and withdraw after leverage + ); + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaIn.selector, + collateral, + maxBorrowAmount + ); + + assertEq(dbr.balanceOf(userPk), 0); + + ale.leveragePosition( + maxBorrowAmount, + address(market), + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + + // Balance in escrow is equal to the collateral deposited + the extra collateral swapped from the leverage + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + amount + convertDolaToCollat(maxBorrowAmount) + ); + assertEq(DOLA.balanceOf(userPk), dolaToWithdraw); + + assertGt(dbr.balanceOf(userPk), (dbrAmount * 98) / 100); + } + + function test_leveragePosition_buyDBR() public { + // We are going to deposit some CRV, then leverage the position + uint amount = 1 ether; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + + uint maxBorrowAmount = getMaxBorrowAmount(amount); + + // recharge mocked proxy for swap, we need to swap DOLA to collateral + deal( + address(market.collateral()), + address(exchangeProxy), + convertDolaToCollat(maxBorrowAmount) + ); + + vm.startPrank(userPk, userPk); + // Initial CRV deposit + deposit(amount); + + // Calculate the amount of DOLA needed to borrow to buy the DBR needed to cover for the borrowing period + (uint256 dolaForDBR, uint256 dbrAmount) = ale + .approximateDolaAndDbrNeeded(maxBorrowAmount, 365 days, 8); + + // Sign Message for borrow on behalf + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + maxBorrowAmount + dolaForDBR, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData = ALE.DBRHelper( + dolaForDBR, + (dbrAmount * 98) / 100, // DBR buy + 0 // Dola to borrow and withdraw after leverage + ); + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaIn.selector, + collateral, + maxBorrowAmount + ); + + assertEq(dbr.balanceOf(userPk), 0); + + ale.leveragePosition( + maxBorrowAmount, + address(market), + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + + // Balance in escrow is equal to the collateral deposited + the extra collateral swapped from the leverage + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + amount + convertDolaToCollat(maxBorrowAmount) + ); + assertEq(DOLA.balanceOf(userPk), 0); + + assertGt(dbr.balanceOf(userPk), (dbrAmount * 98) / 100); + } + + function test_deleveragePosition_sellDBR() public { + uint amount = 1 ether; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + gibDBR(userPk, amount); + + // Max Amount borrowable is the one available from collateral amount + + // the extra borrow amount from the max borrow amount swapped and re-deposited as collateral + uint borrowAmount = getMaxBorrowAmount(amount) / 2; + + // recharge mocked proxy for swap, we need to swap collateral to DOLA + vm.startPrank(gov); + DOLA.mint(address(exchangeProxy), convertCollatToDola(amount / 10)); + vm.stopPrank(); + + vm.startPrank(userPk, userPk); + // CRV deposit and DOLA borrow + deposit(amount); + market.borrow(borrowAmount); + + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + amount + ); + assertEq(DOLA.balanceOf(userPk), borrowAmount); + + // We are going to withdraw only 1/10 of the collateral to deleverage + uint256 amountToWithdraw = collateral.balanceOf( + address(market.predictEscrow(userPk)) + ) / 10; + + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + amountToWithdraw, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData = ALE.DBRHelper( + dbr.balanceOf(userPk), + 0, + 0 + ); // Sell DBR + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaOut.selector, + collateral, + amountToWithdraw + ); + + dbr.approve(address(ale), dbr.balanceOf(userPk)); + + ale.deleveragePosition( + convertCollatToDola(amountToWithdraw), + address(market), + amountToWithdraw, + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + + // Some collateral has been withdrawn + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + amount - amountToWithdraw + ); + + // User still has dola and actually he has more bc he sold his DBRs + assertGt(DOLA.balanceOf(userPk), borrowAmount); + + assertEq(dbr.balanceOf(userPk), 0); + } + + function test_deleveragePosition_withdrawALL_sellDBR() public { + uint amount = 1 ether; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + gibDBR(userPk, amount); + + // Max Amount borrowable is the one available from collateral amount + + // the extra borrow amount from the max borrow amount swapped and re-deposited as collateral + uint borrowAmount = getMaxBorrowAmount(amount) / 2; + + // recharge mocked proxy for swap, we need to swap collateral to DOLA + vm.startPrank(gov); + DOLA.mint(address(exchangeProxy), convertCollatToDola(amount)); + vm.stopPrank(); + + vm.startPrank(userPk, userPk); + // CRV deposit and DOLA borrow + deposit(amount); + market.borrow(borrowAmount); + + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + amount + ); + assertEq(DOLA.balanceOf(userPk), borrowAmount); + + // We are going to withdraw ALL the collateral to deleverage + uint256 amountToWithdraw = collateral.balanceOf( + address(market.predictEscrow(userPk)) + ); + + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + amountToWithdraw, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData = ALE.DBRHelper( + dbr.balanceOf(userPk), + 0, + 0 + ); // Sell DBR + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaOut.selector, + collateral, + amountToWithdraw / 2 + ); + + dbr.approve(address(ale), dbr.balanceOf(userPk)); + + assertEq(collateral.balanceOf(userPk), 0); + + ale.deleveragePosition( + borrowAmount, + address(market), + amountToWithdraw, + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + + // No collateral left in the escrow + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + 0 + ); + + // User still has dola and actually he has more bc he sold his DBRs + assertGt(DOLA.balanceOf(userPk), borrowAmount); + + assertEq(dbr.balanceOf(userPk), 0); + + assertEq(collateral.balanceOf(userPk), amountToWithdraw / 2); + } + + function test_max_leveragePosition() public { + // We are going to deposit some CRV, then fully leverage the position + + uint amount = 1 ether; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + gibDBR(userPk, amount); + + // Max Amount borrowable is the one available from collateral amount + + // the extra borrow amount from the max borrow amount swapped and re-deposited as collateral + uint maxBorrowAmount = getMaxLeverageBorrowAmount(amount, 100); + + // recharge mocked proxy for swap, we need to swap DOLA to collateral + deal( + address(market.collateral()), + address(exchangeProxy), + convertDolaToCollat(maxBorrowAmount) + ); + + vm.startPrank(userPk, userPk); + // Initial CRV deposit + deposit(amount); + + // We are going to leverage the max amount we can borrow + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + maxBorrowAmount, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData; // NO DBR + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaIn.selector, + collateral, + maxBorrowAmount + ); + + ale.leveragePosition( + maxBorrowAmount, + address(market), + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + + // Balance in escrow is equal to the collateral deposited + the extra collateral swapped from the leverage + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + amount + convertDolaToCollat(maxBorrowAmount) + ); + assertEq(DOLA.balanceOf(userPk), 0); + } + + function test_max_deleveragePosition(uint amount) public { + // We are going to deposit some CRV, then fully leverage the position + vm.assume(amount < 40000 ether); + vm.assume(amount > 0.00000001 ether); + + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + gibDBR(userPk, amount); + + // Max Amount borrowable is the one available from collateral amount + + // the extra borrow amount from the max borrow amount swapped and re-deposited as collateral + uint maxBorrowAmount = getMaxBorrowAmount(amount); + + // recharge mocked proxy for swap, we need to swap collateral to DOLA + vm.startPrank(gov); + DOLA.mint(address(exchangeProxy), convertCollatToDola(amount)); + vm.stopPrank(); + + vm.startPrank(userPk, userPk); + // Initial CRV deposit + deposit(amount); + market.borrow(maxBorrowAmount); + + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + amount + ); + assertEq(DOLA.balanceOf(userPk), maxBorrowAmount); + + // We are going to deleverage and withdraw ALL collateral + uint256 amountToWithdraw = collateral.balanceOf( + address(market.predictEscrow(userPk)) + ); + + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + amountToWithdraw, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData; // NO DBR + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaOut.selector, + collateral, + amountToWithdraw + ); + + ale.deleveragePosition( + maxBorrowAmount, + address(market), + amountToWithdraw, + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + + // No collateral in the escrow + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + 0 + ); + // All collateral is swapped to DOLA and sent to the user + assertEq(DOLA.balanceOf(userPk), convertCollatToDola(amount)); + } + + function test_max_leverageAndDeleveragePosition(uint256 amount) public { + // We are going to deposit some CRV, then fully leverage the position + // and then fully deleverage it (withdrawing ALL the collateral) + vm.assume(amount < 40000 ether); + vm.assume(amount > 0.00000001 ether); + + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + gibDBR(userPk, amount); + + // Max Amount borrowable is the one available from collateral amount + + // the extra borrow amount from the max borrow amount swapped and re-deposited as collateral + uint maxBorrowAmount = getMaxLeverageBorrowAmount(amount, 100); + + // recharge proxy for swap, we need to swap DOLA to collateral + deal( + address(market.collateral()), + address(exchangeProxy), + convertDolaToCollat(maxBorrowAmount) + ); + // we also need to mint DOLA into the swap mock bc we will swap ALL the collateral, not only the one added from the leverage + vm.startPrank(gov); + DOLA.mint(address(exchangeProxy), convertCollatToDola(amount)); + vm.stopPrank(); + + vm.startPrank(userPk, userPk); + // Initial CRV deposit + deposit(amount); + + // We are going to leverage the max amount we can borrow + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + maxBorrowAmount, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData; // NO DBR + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaIn.selector, + collateral, + maxBorrowAmount + ); + + ale.leveragePosition( + maxBorrowAmount, + address(market), + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + + // We now deleverage and withdraw ALL the collateral (which will be swapped for DOLA) + uint256 amountToWithdraw = collateral.balanceOf( + address(market.predictEscrow(userPk)) + ); + + hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + amountToWithdraw, + 1, + block.timestamp + ) + ) + ) + ); + (v, r, s) = vm.sign(1, hash); + + permit = ALE.Permit(block.timestamp, v, r, s); + + swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaOut.selector, + collateral, + amountToWithdraw + ); + + ale.deleveragePosition( + maxBorrowAmount, + address(market), + amountToWithdraw, + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + + // We have fully deleveraged the position (no collateral left in the escrow) + // extra DOLA swapped is sent to the user (after burning) + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + 0 + ); + assertEq( + DOLA.balanceOf(userPk), + convertCollatToDola(amountToWithdraw) - maxBorrowAmount + ); + } + + function test_deleveragePosition_if_collateral_no_debt() public { + uint amount = 1 ether; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + gibDBR(userPk, amount); + + // recharge mocked proxy for swap, we need to swap collateral to DOLA + vm.startPrank(gov); + DOLA.mint(address(exchangeProxy), convertCollatToDola(amount)); + vm.stopPrank(); + + vm.startPrank(userPk, userPk); + deposit(amount); + + uint256 amountToWithdraw = amount; + + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + amountToWithdraw, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData; // NO DBR + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaOut.selector, + collateral, + amountToWithdraw + ); + + // vm.expectRevert(repaymentGtThanDebt); + // WE can deleverage even if we have no debt, will be swapped to DOLA and sent to the user + ale.deleveragePosition( + 0, + address(market), + amountToWithdraw, + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + + assertEq( + collateral.balanceOf(address(market.predictEscrow(userPk))), + 0 + ); + assertEq(DOLA.balanceOf(userPk), convertCollatToDola(amount)); + } + + function test_fail_leveragePosition_if_no_collateral() public { + // We are going to deposit some CRV, then leverage the position + uint amount = 1 ether; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + gibDBR(userPk, amount); + + uint maxBorrowAmount = getMaxBorrowAmount(amount); + + // recharge mocked proxy for swap, we need to swap DOLA to collateral + deal( + address(market.collateral()), + address(exchangeProxy), + convertDolaToCollat(maxBorrowAmount) + ); + + vm.startPrank(userPk, userPk); + + // Sign Message for borrow on behalf + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + maxBorrowAmount, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData; // NO DBR + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaIn.selector, + collateral, + maxBorrowAmount + ); + + vm.expectRevert(exceededLimit); + ale.leveragePosition( + maxBorrowAmount, + address(market), + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + } + + function test_fail_deleveragePosition_if_no_collateral() public { + uint amount = 1 ether; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + gibDBR(userPk, amount); + + // recharge mocked proxy for swap, we need to swap collateral to DOLA + vm.startPrank(gov); + DOLA.mint(address(exchangeProxy), convertCollatToDola(amount)); + vm.stopPrank(); + + vm.startPrank(userPk, userPk); + + uint256 amountToWithdraw = amount; + + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "WithdrawOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + amountToWithdraw, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData; // NO DBR + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaOut.selector, + collateral, + amountToWithdraw + ); + + // Cannot make a repayment without debt + vm.expectRevert(repaymentGtThanDebt); + ale.deleveragePosition( + 1 ether, + address(market), + amountToWithdraw, + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + } + + function test_fail_max_leveragePosition_buyDBR() public { + // We are going to deposit some CRV, then fully leverage the position + + uint amount = 1 ether; + address userPk = vm.addr(1); + deal(address(market.collateral()), userPk, amount); + gibDBR(userPk, amount); + + // Max Amount borrowable is the one available from collateral amount + + // all redeposited amount as collateral + uint maxBorrowAmount = getMaxLeverageBorrowAmount(amount, 100); + + // recharge mocked proxy for swap, we need to swap DOLA to collateral + deal( + address(market.collateral()), + address(exchangeProxy), + convertDolaToCollat(maxBorrowAmount) + ); + + vm.startPrank(userPk, userPk); + // Initial CRV deposit + deposit(amount); + + // Calculate the amount of DOLA needed to buy the DBR to cover for the borrowing period + (uint256 dolaForDBR, uint256 dbrAmount) = ale + .approximateDolaAndDbrNeeded(maxBorrowAmount, 365 days, 8); + + // We are going to leverage the max amount we can borrow + the amount needed to buy the DBR + bytes32 hash = keccak256( + abi.encodePacked( + "\x19\x01", + market.DOMAIN_SEPARATOR(), + keccak256( + abi.encode( + keccak256( + "BorrowOnBehalf(address caller,address from,uint256 amount,uint256 nonce,uint256 deadline)" + ), + address(ale), + userPk, + maxBorrowAmount + dolaForDBR, + 0, + block.timestamp + ) + ) + ) + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, hash); + + ALE.Permit memory permit = ALE.Permit(block.timestamp, v, r, s); + + ALE.DBRHelper memory dbrData = ALE.DBRHelper( + dolaForDBR, + (dbrAmount * 99) / 100, + 0 + ); // buy DBR + + bytes memory swapData = abi.encodeWithSelector( + MockExchangeProxy.swapDolaIn.selector, + collateral, + maxBorrowAmount + ); + + // Cannot MAX leverage a position and buying DBR at the same time + vm.expectRevert(exceededLimit); + ale.leveragePosition( + maxBorrowAmount, + address(market), + address(exchangeProxy), + swapData, + permit, + bytes(""), + dbrData + ); + } + + function test_fail_setMarket_NoMarket() public { + address fakeMarket = address(0x69); + + vm.expectRevert( + abi.encodeWithSelector(ALE.NoMarket.selector, fakeMarket) + ); + ale.setMarket(fakeMarket, address(0), address(0), true); + } + + function test_fail_setMarket_Wrong_BuySellToken_Without_Helper() public { + ale.updateMarketHelper(address(market), address(0)); + + address fakeBuySellToken = address(0x69); + + vm.expectRevert( + abi.encodeWithSelector( + ALE.MarketSetupFailed.selector, + address(market), + fakeBuySellToken, + address(collateral), + address(0) + ) + ); + ale.setMarket(address(market), fakeBuySellToken, address(0), true); + + vm.expectRevert(); + ale.setMarket(address(market), address(0), address(0), true); + } + + function test_fail_updateMarketHelper_NoMarket() public { + address wrongMarket = address(0x69); + address newHelper = address(0x70); + + vm.expectRevert( + abi.encodeWithSelector(ALE.MarketNotSet.selector, wrongMarket) + ); + ale.updateMarketHelper(wrongMarket, newHelper); + } +} diff --git a/test/util/aleTests/ALESdeUSDForkTest.t.sol b/test/util/aleTests/ALESdeUSDForkTest.t.sol new file mode 100644 index 00000000..86055559 --- /dev/null +++ b/test/util/aleTests/ALESdeUSDForkTest.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "test/marketForkTests/SdeUSDMarketForkTest.t.sol"; +import "test/util/aleTests/ALEBaseSimpleForkTest.t.sol"; +import "src/DBR.sol"; +import "test/mocks/ERC20.sol"; + +contract ALESdeUSDForkTest is SdeUSDMarketForkTest, ALEBaseSimpleForkTest { + function setUp() public override { + super.setUp(); + + exchangeProxy = new MockExchangeProxy( + address(market.oracle()), + address(DOLA) + ); + + ale = new ALE(address(exchangeProxy), triDBR); + // ALE setup + vm.startPrank(gov); + DOLA.addMinter(address(ale)); + borrowController.allow(address(ale)); + vm.stopPrank(); + + ale.setMarket( + address(market), + address(market.collateral()), + address(0), + true + ); + } +} From fd698491f259c0ebbf739f2fc6579f7895be8c66 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Tue, 25 Feb 2025 14:33:51 -0500 Subject: [PATCH 06/14] use previewRedeem in ERC4626Feed --- src/feeds/ERC4626Feed.sol | 2 +- test/feedForkTests/SdeUSDFeedFork.t.sol | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/feeds/ERC4626Feed.sol b/src/feeds/ERC4626Feed.sol index 63280a86..696c9d70 100644 --- a/src/feeds/ERC4626Feed.sol +++ b/src/feeds/ERC4626Feed.sol @@ -58,7 +58,7 @@ contract ERC4626Feed { uint80 answeredInRound ) = feed.latestRoundData(); - uint256 assetToUnderlyingRate = vault.convertToAssets(SCALE); + uint256 assetToUnderlyingRate = vault.previewRedeem(SCALE); // Multiply Normalized Asset/USD price by asset/underlying rate to get Asset/USD price int256 assetToUsdPrice = int256( diff --git a/test/feedForkTests/SdeUSDFeedFork.t.sol b/test/feedForkTests/SdeUSDFeedFork.t.sol index 91643aa4..fcee240e 100644 --- a/test/feedForkTests/SdeUSDFeedFork.t.sol +++ b/test/feedForkTests/SdeUSDFeedFork.t.sol @@ -83,7 +83,7 @@ contract SdeUSDFeedForkTest is Test { (sdeUSDNormalizedToDola * uint(dolaToUsdPrice)) / 1e18 ); - uint256 sdeUSDToDeUSDRate = IERC4626(sdeUSD).convertToAssets(1e18); + uint256 sdeUSDToDeUSDRate = IERC4626(sdeUSD).previewRedeem(1e18); return (sdeUSDNormalizedToUsdPrice * int(sdeUSDToDeUSDRate)) / int256(1e18); @@ -92,7 +92,7 @@ contract SdeUSDFeedForkTest is Test { function _mockVaultRate(address vault, uint256 mockRate) internal { vm.mockCall( vault, - abi.encodeWithSelector(IERC4626.convertToAssets.selector, 1e18), + abi.encodeWithSelector(IERC4626.previewRedeem.selector, 1e18), abi.encode(mockRate) ); } From 4ac3034a244b62bdd97dab312c59c8e9605d6337 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Thu, 27 Feb 2025 12:49:19 -0500 Subject: [PATCH 07/14] use try catch for previewRedeem --- src/feeds/ERC4626Feed.sol | 8 ++++- test/feedForkTests/SdeUSDFeedFork.t.sol | 39 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/feeds/ERC4626Feed.sol b/src/feeds/ERC4626Feed.sol index 696c9d70..2a26dc7c 100644 --- a/src/feeds/ERC4626Feed.sol +++ b/src/feeds/ERC4626Feed.sol @@ -58,7 +58,13 @@ contract ERC4626Feed { uint80 answeredInRound ) = feed.latestRoundData(); - uint256 assetToUnderlyingRate = vault.previewRedeem(SCALE); + uint256 assetToUnderlyingRate; + + try vault.previewRedeem(SCALE) returns (uint256 rate) { + assetToUnderlyingRate = rate; + } catch { + assetToUnderlyingRate = vault.convertToAssets(SCALE); + } // Multiply Normalized Asset/USD price by asset/underlying rate to get Asset/USD price int256 assetToUsdPrice = int256( diff --git a/test/feedForkTests/SdeUSDFeedFork.t.sol b/test/feedForkTests/SdeUSDFeedFork.t.sol index fcee240e..0db5b85d 100644 --- a/test/feedForkTests/SdeUSDFeedFork.t.sol +++ b/test/feedForkTests/SdeUSDFeedFork.t.sol @@ -73,6 +73,17 @@ contract SdeUSDFeedForkTest is Test { assertLt(feed.latestAnswer(), answer); } + function test_previewRedeemRevert_useConvertToAssets() public { + // Mock preview redeem rate + _mockVaultRate(sdeUSD, 2e18); + // Answer is equal to preview redeem rate but not equal to convert to assets + assertEq(feed.latestAnswer(), _calculateSdeUSDPrice()); + assertGt(feed.latestAnswer(), _calculateSdeUSDPriceConvertToAssets()); + // Mock preview redeem revert to use convert to assets + _mockPreviewRevert(sdeUSD); + assertEq(feed.latestAnswer(), _calculateSdeUSDPriceConvertToAssets()); + } + function _calculateSdeUSDPrice() internal view returns (int256) { uint256 sdeUSDNormalizedToDola = ICurvePool(curvePool).price_oracle( curveFeed.assetOrTargetK() @@ -89,6 +100,26 @@ contract SdeUSDFeedForkTest is Test { int256(1e18); } + function _calculateSdeUSDPriceConvertToAssets() + internal + view + returns (int256) + { + uint256 sdeUSDNormalizedToDola = ICurvePool(curvePool).price_oracle( + curveFeed.assetOrTargetK() + ); + + int256 dolaToUsdPrice = curveFeed.assetToUsd().latestAnswer(); + int256 sdeUSDNormalizedToUsdPrice = int256( + (sdeUSDNormalizedToDola * uint(dolaToUsdPrice)) / 1e18 + ); + + uint256 sdeUSDToDeUSDRate = IERC4626(sdeUSD).convertToAssets(1e18); + return + (sdeUSDNormalizedToUsdPrice * int(sdeUSDToDeUSDRate)) / + int256(1e18); + } + function _mockVaultRate(address vault, uint256 mockRate) internal { vm.mockCall( vault, @@ -96,4 +127,12 @@ contract SdeUSDFeedForkTest is Test { abi.encode(mockRate) ); } + + function _mockPreviewRevert(address vault) internal { + vm.mockCallRevert( + vault, + abi.encodeWithSelector(IERC4626.previewRedeem.selector, 1e18), + "mock revert" + ); + } } From e6820f7586bc6423dbd6864c838175ab77bdbbb7 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Fri, 28 Feb 2025 15:26:05 -0500 Subject: [PATCH 08/14] add ERC4626Feed support for asset decimals < 18, add test --- src/feeds/ERC4626Feed.sol | 21 ++++++++-- test/feedForkTests/SdeUSDFeedFork.t.sol | 54 +++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/feeds/ERC4626Feed.sol b/src/feeds/ERC4626Feed.sol index 2a26dc7c..a465321a 100644 --- a/src/feeds/ERC4626Feed.sol +++ b/src/feeds/ERC4626Feed.sol @@ -3,19 +3,26 @@ pragma solidity ^0.8.4; import {IChainlinkCurveFeed} from "src/interfaces/IChainlinkCurveFeed.sol"; import {IERC4626} from "lib/openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; +import {console} from "forge-std/console.sol"; /// @title ERC4626Feed /// @notice This contract is a generalized contract for an ERC4626 vault which has a feed in a Normalized Asset to USD price /// @dev It will convert the normalized asset to USD price to the Asset to USD price using the vault's rate /// @dev This is contract is meant to be used in combination with ChainlinkCurveFeed or ChainlinkCurve2CoinsFeed contracts. +/// @dev Undelying asset decimals must be 18 or lower. contract ERC4626Feed { + using FixedPointMathLib for uint256; error DecimalsMismatch(); // ChainlinkCurve feed for the normalized asset to USD price IChainlinkCurveFeed public immutable feed; // ERC4626 vault asset IERC4626 public immutable vault; + // Asset scale + uint256 public immutable assetScale; // Scaling factor uint256 public constant SCALE = 1e18; // Description of the feed @@ -24,9 +31,13 @@ contract ERC4626Feed { constructor(address _vault, address _feed) { feed = IChainlinkCurveFeed(_feed); vault = IERC4626(_vault); + assetScale = 10 ** IERC20Metadata(vault.asset()).decimals(); - if (feed.decimals() != 18 || vault.decimals() != 18) - revert DecimalsMismatch(); + if ( + feed.decimals() != 18 || + vault.decimals() != 18 || + assetScale > SCALE + ) revert DecimalsMismatch(); description = string( abi.encodePacked( @@ -61,9 +72,11 @@ contract ERC4626Feed { uint256 assetToUnderlyingRate; try vault.previewRedeem(SCALE) returns (uint256 rate) { - assetToUnderlyingRate = rate; + assetToUnderlyingRate = rate.divWadDown(assetScale); } catch { - assetToUnderlyingRate = vault.convertToAssets(SCALE); + assetToUnderlyingRate = vault.convertToAssets(SCALE).divWadDown( + assetScale + ); } // Multiply Normalized Asset/USD price by asset/underlying rate to get Asset/USD price diff --git a/test/feedForkTests/SdeUSDFeedFork.t.sol b/test/feedForkTests/SdeUSDFeedFork.t.sol index 0db5b85d..94a11690 100644 --- a/test/feedForkTests/SdeUSDFeedFork.t.sol +++ b/test/feedForkTests/SdeUSDFeedFork.t.sol @@ -6,6 +6,17 @@ import "src/feeds/ERC4626Feed.sol"; import "src/interfaces/IChainlinkFeed.sol"; import {ChainlinkCurveFeed, ICurvePool} from "src/feeds/ChainlinkCurveFeed.sol"; import "forge-std/console.sol"; +import {ERC20 as ERC20Mock} from "test/mocks/ERC20.sol"; +import {ERC4626, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract Mock4626 is ERC4626 { + constructor(IERC20 _asset) ERC20("MOCK", "MOCK") ERC4626(_asset) {} + + function _decimalsOffset() internal view override returns (uint8) { + return 12; + } +} contract SdeUSDFeedForkTest is Test { ChainlinkCurveFeed curveFeed; @@ -16,6 +27,10 @@ contract SdeUSDFeedForkTest is Test { address sdeUSD = address(0x5C5b196aBE0d54485975D1Ec29617D42D9198326); address dolaFeed = address(0x6255981e2a1EBeA600aFC506185590eD383517be); + ERC20Mock mock6Decimals; + Mock4626 mock6Vault; + ERC4626Feed feed6Decimals; + function setUp() public { string memory url = vm.rpcUrl("mainnet"); vm.createSelectFork(url); @@ -23,8 +38,47 @@ contract SdeUSDFeedForkTest is Test { feed = new ERC4626Feed(sdeUSD, address(curveFeed)); } + function test_6Decimals_underlying_asset_Feed_returns_18() public { + mock6Decimals = new ERC20Mock("Mock6", "M6", 6); + mock6Vault = new Mock4626(IERC20(address(mock6Decimals))); + assertEq(mock6Vault.decimals(), 18); + + feed6Decimals = new ERC4626Feed( + address(mock6Vault), + address(curveFeed) + ); + uint256 amount = 10000e6; + assertEq(feed6Decimals.decimals(), 18); + assertEq(feed6Decimals.assetScale(), 1e6); + + mock6Decimals.mint(address(this), amount); + mock6Decimals.approve(address(mock6Vault), amount); + mock6Vault.deposit(amount, address(this)); + + assertEq(mock6Vault.convertToAssets(1e18), 1e6); + assertEq(mock6Vault.previewRedeem(1e18), 1e6); + assertEq(mock6Vault.convertToShares(1e6), 1e18); + assertEq(mock6Vault.previewDeposit(1e6), 1e18); + + uint256 sdeUSDNormalizedToDola = ICurvePool(curvePool).price_oracle( + curveFeed.assetOrTargetK() + ); + + int256 dolaToUsdPrice = curveFeed.assetToUsd().latestAnswer(); + int256 sdeUSDNormalizedToUsdPrice = int256( + (sdeUSDNormalizedToDola * uint(dolaToUsdPrice)) / 1e18 + ); + + uint256 sdeUSDToDeUSDRate = mock6Vault.previewRedeem(1e18); + + uint256 price = (uint(sdeUSDNormalizedToUsdPrice) * sdeUSDToDeUSDRate) / + 1e6; + assertEq(feed6Decimals.latestAnswer(), int256(price)); + } + function test_decimals() public { assertEq(feed.decimals(), 18); + assertEq(feed.assetScale(), 1e18); } function test_description() public { From 2dd3e7676be526d07976ed664519b2d619058706 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Fri, 28 Feb 2025 15:27:43 -0500 Subject: [PATCH 09/14] rm logs --- src/feeds/ERC4626Feed.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/src/feeds/ERC4626Feed.sol b/src/feeds/ERC4626Feed.sol index a465321a..7d3eb513 100644 --- a/src/feeds/ERC4626Feed.sol +++ b/src/feeds/ERC4626Feed.sol @@ -5,7 +5,6 @@ import {IChainlinkCurveFeed} from "src/interfaces/IChainlinkCurveFeed.sol"; import {IERC4626} from "lib/openzeppelin-contracts/contracts/interfaces/IERC4626.sol"; import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; -import {console} from "forge-std/console.sol"; /// @title ERC4626Feed /// @notice This contract is a generalized contract for an ERC4626 vault which has a feed in a Normalized Asset to USD price From c541cf0c1192563198184fb78159b31e32129a22 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Fri, 28 Feb 2025 15:32:14 -0500 Subject: [PATCH 10/14] fix typo --- src/feeds/ERC4626Feed.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/feeds/ERC4626Feed.sol b/src/feeds/ERC4626Feed.sol index 7d3eb513..b1842f05 100644 --- a/src/feeds/ERC4626Feed.sol +++ b/src/feeds/ERC4626Feed.sol @@ -10,7 +10,7 @@ import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol"; /// @notice This contract is a generalized contract for an ERC4626 vault which has a feed in a Normalized Asset to USD price /// @dev It will convert the normalized asset to USD price to the Asset to USD price using the vault's rate /// @dev This is contract is meant to be used in combination with ChainlinkCurveFeed or ChainlinkCurve2CoinsFeed contracts. -/// @dev Undelying asset decimals must be 18 or lower. +/// @dev Underlying asset decimals must be 18 or lower. contract ERC4626Feed { using FixedPointMathLib for uint256; From c085b9d079bc3bf8e2a6aa383cc7c9d2708bc918 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Wed, 12 Mar 2025 13:34:27 -0500 Subject: [PATCH 11/14] add USDe before maturity with NAV, add tests with NAV --- src/feeds/USDeNavBeforeMaturityFeed.sol | 88 +++ .../USDeNavBeforeMaturityFeed.t.sol | 150 +++++ ... PendlePTsUSDe27Mar25MarketForkTest.t.sol} | 2 +- .../PendlePTsUSDe29May25MarketForkTest.t.sol | 92 +++ test/util/BaseFeedSwitchNavFork.t.sol | 556 ++++++++++++++++++ test/util/FeedSwitchNavSUSDe29May25.t.sol | 28 + 6 files changed, 915 insertions(+), 1 deletion(-) create mode 100644 src/feeds/USDeNavBeforeMaturityFeed.sol create mode 100644 test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol rename test/marketForkTests/{PendlePTUSDeMarketForkTest.t.sol => PendlePTsUSDe27Mar25MarketForkTest.t.sol} (97%) create mode 100644 test/marketForkTests/PendlePTsUSDe29May25MarketForkTest.t.sol create mode 100644 test/util/BaseFeedSwitchNavFork.t.sol create mode 100644 test/util/FeedSwitchNavSUSDe29May25.t.sol diff --git a/src/feeds/USDeNavBeforeMaturityFeed.sol b/src/feeds/USDeNavBeforeMaturityFeed.sol new file mode 100644 index 00000000..13ea7a2d --- /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 getDiscount(uint256 timeLeft) external view returns (uint256); + + function latestRoundData() + external + view + returns (uint80, int256, uint256, uint256, uint80); +} +/// @title USDeFeed +/// @notice A contract to get the USDe price using sUSDe Chainlink Wrapper feed and sUSDe/USDe rate +contract USDeNavBeforeMaturityFeed { + error DecimalsMismatch(); + + 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) + revert DecimalsMismatch(); + + 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 + * @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 discountPrice = (USDeUsdPrice * navDiscountedPrice) / 1e18; + + return (roundId, discountPrice, 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..28fa2b3f --- /dev/null +++ b/test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol @@ -0,0 +1,150 @@ +// 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"; + +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 USDeNavBeforeMaturityFeedTest is Test { + USDeNavBeforeMaturityFeed feed; + ChainlinkBasePriceFeed sUSDeWrappedFeed; + address sUSDeFeed = address(0xFF3BC18cCBd5999CE63E788A1c250a88626aD099); + IERC4626 sUSDe = IERC4626(0x9D39A5DE30e57443BfF2A8307A4256c8797A3497); + address gov = address(0x926dF14a23BE491164dCF93f4c468A50ef659D5B); + PendleSparkLinearDiscountOracleFactory navFactory = PendleSparkLinearDiscountOracleFactory(0xA9A924A4BB95509F77868E086154C25e934F6171); + 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 = navFactory.createWithPt(pendlePT, 0.2 ether); + 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); + } +} 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..7c76ae91 --- /dev/null +++ b/test/marketForkTests/PendlePTsUSDe29May25MarketForkTest.t.sol @@ -0,0 +1,92 @@ +// 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"; + +interface PendleSparkLinearDiscountOracleFactory { + function createWithPt(address pt, uint256 baseDiscountPerYear) external returns (address); +} + +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; + PendleSparkLinearDiscountOracleFactory navFactory = PendleSparkLinearDiscountOracleFactory(0xA9A924A4BB95509F77868E086154C25e934F6171); + uint256 baseDiscount = 0.2 ether; // 20% + FeedSwitch feedSwitch; + + function setUp() public { + //This will fail if there's no mainnet variable in foundry.toml + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url, 22018716); + + Market pendleMarket = new Market( + gov, + fedAddr, + pauseGuardian, + simpleERC20EscrowAddr, + IDolaBorrowingRights(address(dbrAddr)), + IERC20(address(pendlePT)), + IOracle(address(oracleAddr)), + 5000, + 5000, + 1000, + true + ); + + address feedAddr = _deployFeed(); + _advancedInit(address(pendleMarket), feedAddr, false); + } + + function _deployFeed() internal returns (address feed) { + sUSDeWrappedFeed = new ChainlinkBasePriceFeed( + gov, + sUSDeFeed, + address(0), + 24 hours + ); + navFeed = navFactory.createWithPt(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/util/BaseFeedSwitchNavFork.t.sol b/test/util/BaseFeedSwitchNavFork.t.sol new file mode 100644 index 00000000..3e8d8f90 --- /dev/null +++ b/test/util/BaseFeedSwitchNavFork.t.sol @@ -0,0 +1,556 @@ +// 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 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); +} + +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_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..483f3fba --- /dev/null +++ b/test/util/FeedSwitchNavSUSDe29May25.t.sol @@ -0,0 +1,28 @@ +// 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"; + +interface PendleSparkLinearDiscountOracleFactory { + function createWithPt(address pt, uint256 baseDiscountPerYear) external returns (address); +} + +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); + PendleSparkLinearDiscountOracleFactory navFactory = PendleSparkLinearDiscountOracleFactory(0xA9A924A4BB95509F77868E086154C25e934F6171); + function setUp() public { + string memory url = vm.rpcUrl("mainnet"); + vm.createSelectFork(url, 22018716); + address _navFeed = navFactory.createWithPt(_pendlePT, _baseDiscount); + _beforeMaturityFeed = address(new USDeNavBeforeMaturityFeed(sUSDeWrapper,sUSDe,_navFeed)); // USDeBeforeMaturityFeed: USDe/USD Feed using sUSDe Chainlink feed and sUSDe/USDe rate and NAV + + initialize(address(_beforeMaturityFeed), address(_afterMaturityFeed), _pendlePT , _baseDiscount, _navFeed); + } +} From d90491abcc3e55eda7e6197661db8819ea2183cf Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Wed, 12 Mar 2025 17:22:00 -0500 Subject: [PATCH 12/14] add maturity check and test --- src/feeds/USDeNavBeforeMaturityFeed.sol | 10 +++++----- test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol | 12 ++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/feeds/USDeNavBeforeMaturityFeed.sol b/src/feeds/USDeNavBeforeMaturityFeed.sol index 13ea7a2d..3e2ae6de 100644 --- a/src/feeds/USDeNavBeforeMaturityFeed.sol +++ b/src/feeds/USDeNavBeforeMaturityFeed.sol @@ -6,8 +6,7 @@ import {IERC4626} from "lib/openzeppelin-contracts/contracts/interfaces/IERC4626 interface INavFeed { function maturity() external view returns (uint256); - function getDiscount(uint256 timeLeft) external view returns (uint256); - + function decimals() external view returns (uint8); function latestRoundData() external view @@ -17,7 +16,8 @@ interface INavFeed { /// @notice A contract to get the USDe price using sUSDe Chainlink Wrapper feed and sUSDe/USDe rate contract USDeNavBeforeMaturityFeed { error DecimalsMismatch(); - + error MaturityPassed(); + IChainlinkBasePriceFeed public immutable sUSDeFeed; IERC4626 public immutable sUSDe; INavFeed public immutable navFeed; @@ -28,9 +28,9 @@ contract USDeNavBeforeMaturityFeed { sUSDeFeed = IChainlinkBasePriceFeed(_sUSDeFeed); sUSDe = IERC4626(_sUSDe); navFeed = INavFeed(_navFeed); - if (sUSDeFeed.decimals() != 18 || sUSDe.decimals() != 18) + 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" diff --git a/test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol b/test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol index 28fa2b3f..e78d8586 100644 --- a/test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol +++ b/test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol @@ -147,4 +147,16 @@ contract USDeNavBeforeMaturityFeedTest is Test { assertEq(updatedAt, 0); assertEq(answeredInRound, 0); } + + function test_maturity_passed() public { + uint256 maturity = INavFeed(address(feed.navFeed())).maturity(); + vm.warp(maturity); + address navFeed = navFactory.createWithPt(pendlePT, 0.2 ether); + vm.expectRevert(USDeNavBeforeMaturityFeed.MaturityPassed.selector); + feed = new USDeNavBeforeMaturityFeed( + address(sUSDeWrappedFeed), + address(sUSDe), + navFeed + ); + } } From 8c2c4ae8365f4ea56775cc29aa0c772038155752 Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Wed, 12 Mar 2025 17:26:25 -0500 Subject: [PATCH 13/14] update natspec --- src/feeds/USDeNavBeforeMaturityFeed.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/feeds/USDeNavBeforeMaturityFeed.sol b/src/feeds/USDeNavBeforeMaturityFeed.sol index 3e2ae6de..ee1a8680 100644 --- a/src/feeds/USDeNavBeforeMaturityFeed.sol +++ b/src/feeds/USDeNavBeforeMaturityFeed.sol @@ -12,12 +12,12 @@ interface INavFeed { view returns (uint80, int256, uint256, uint256, uint80); } -/// @title USDeFeed -/// @notice A contract to get the USDe price using sUSDe Chainlink Wrapper feed and sUSDe/USDe rate +/// @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; @@ -40,7 +40,7 @@ contract USDeNavBeforeMaturityFeed { /** * @return roundId The round ID of sUSDe Chainlink price feed - * @return USDeUsdPrice The latest USDe price in USD + * @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 @@ -64,9 +64,9 @@ contract USDeNavBeforeMaturityFeed { int256 USDeUsdPrice = (sUSDePrice * 1e18) / int256(sUSDeToUSDeRate); (,int256 navDiscountedPrice,,,)= navFeed.latestRoundData(); - int256 discountPrice = (USDeUsdPrice * navDiscountedPrice) / 1e18; + int256 usdeDiscountPrice = (USDeUsdPrice * navDiscountedPrice) / 1e18; - return (roundId, discountPrice, startedAt, updatedAt, answeredInRound); + return (roundId, usdeDiscountPrice, startedAt, updatedAt, answeredInRound); } /** From 04577329703e662f869d3b8aa865e2dc707f5760 Mon Sep 17 00:00:00 2001 From: 08xmt Date: Fri, 14 Mar 2025 11:32:48 +0100 Subject: [PATCH 14/14] Add generalized PTDiscountedNavFeed and PendlePTsUSDE29May25 mArket test --- src/feeds/PTDiscountedNAVFeed.sol | 93 ++++++++++ src/feeds/USDeNavBeforeMaturityFeed.sol | 88 ---------- .../USDeNavBeforeMaturityFeed.t.sol | 162 ------------------ .../USDePTDiscountedNAVFeed.t.sol | 152 ++++++++++++++++ .../PendlePTsUSDe29May25MarketForkTest.t.sol | 37 +--- test/util/FeedSwitchNavSUSDe29May25.t.sol | 28 --- 6 files changed, 252 insertions(+), 308 deletions(-) create mode 100644 src/feeds/PTDiscountedNAVFeed.sol delete mode 100644 src/feeds/USDeNavBeforeMaturityFeed.sol delete mode 100644 test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol create mode 100644 test/feedForkTests/USDePTDiscountedNAVFeed.t.sol delete mode 100644 test/util/FeedSwitchNavSUSDe29May25.t.sol diff --git a/src/feeds/PTDiscountedNAVFeed.sol b/src/feeds/PTDiscountedNAVFeed.sol new file mode 100644 index 00000000..d631b051 --- /dev/null +++ b/src/feeds/PTDiscountedNAVFeed.sol @@ -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; + } +} diff --git a/src/feeds/USDeNavBeforeMaturityFeed.sol b/src/feeds/USDeNavBeforeMaturityFeed.sol deleted file mode 100644 index ee1a8680..00000000 --- a/src/feeds/USDeNavBeforeMaturityFeed.sol +++ /dev/null @@ -1,88 +0,0 @@ -// 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 deleted file mode 100644 index e78d8586..00000000 --- a/test/feedForkTests/USDeNavBeforeMaturityFeed.t.sol +++ /dev/null @@ -1,162 +0,0 @@ -// 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"; - -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 USDeNavBeforeMaturityFeedTest is Test { - USDeNavBeforeMaturityFeed feed; - ChainlinkBasePriceFeed sUSDeWrappedFeed; - address sUSDeFeed = address(0xFF3BC18cCBd5999CE63E788A1c250a88626aD099); - IERC4626 sUSDe = IERC4626(0x9D39A5DE30e57443BfF2A8307A4256c8797A3497); - address gov = address(0x926dF14a23BE491164dCF93f4c468A50ef659D5B); - PendleSparkLinearDiscountOracleFactory navFactory = PendleSparkLinearDiscountOracleFactory(0xA9A924A4BB95509F77868E086154C25e934F6171); - 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 = navFactory.createWithPt(pendlePT, 0.2 ether); - 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 = navFactory.createWithPt(pendlePT, 0.2 ether); - vm.expectRevert(USDeNavBeforeMaturityFeed.MaturityPassed.selector); - feed = new USDeNavBeforeMaturityFeed( - address(sUSDeWrappedFeed), - address(sUSDe), - navFeed - ); - } -} diff --git a/test/feedForkTests/USDePTDiscountedNAVFeed.t.sol b/test/feedForkTests/USDePTDiscountedNAVFeed.t.sol new file mode 100644 index 00000000..bd47bfd9 --- /dev/null +++ b/test/feedForkTests/USDePTDiscountedNAVFeed.t.sol @@ -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 + ); + } +} diff --git a/test/marketForkTests/PendlePTsUSDe29May25MarketForkTest.t.sol b/test/marketForkTests/PendlePTsUSDe29May25MarketForkTest.t.sol index 7c76ae91..df9e50e7 100644 --- a/test/marketForkTests/PendlePTsUSDe29May25MarketForkTest.t.sol +++ b/test/marketForkTests/PendlePTsUSDe29May25MarketForkTest.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.13; import "forge-std/Test.sol"; import "./MarketBaseForkTest.sol"; -import {USDeNavBeforeMaturityFeed} from "src/feeds/USDeNavBeforeMaturityFeed.sol"; +import {PTDiscountedNAVFeed} from "src/feeds/PTDiscountedNAVFeed.sol"; import {ChainlinkBasePriceFeed} from "src/feeds/ChainlinkBasePriceFeed.sol"; import {FeedSwitch} from "src/util/FeedSwitch.sol"; @@ -13,19 +13,12 @@ interface PendleSparkLinearDiscountOracleFactory { 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; - PendleSparkLinearDiscountOracleFactory navFactory = PendleSparkLinearDiscountOracleFactory(0xA9A924A4BB95509F77868E086154C25e934F6171); + ChainlinkBasePriceFeed USDeWrappedFeed; uint256 baseDiscount = 0.2 ether; // 20% - FeedSwitch feedSwitch; function setUp() public { //This will fail if there's no mainnet variable in foundry.toml @@ -51,34 +44,18 @@ contract PendlePTsUSDe29May25MarketForkTest is MarketBaseForkTest { } function _deployFeed() internal returns (address feed) { - sUSDeWrappedFeed = new ChainlinkBasePriceFeed( - gov, - sUSDeFeed, - address(0), - 24 hours - ); - navFeed = navFactory.createWithPt(pendlePT, baseDiscount); - beforeMaturityFeed = new USDeNavBeforeMaturityFeed( - address(sUSDeWrappedFeed), - address(sUSDe), - address(navFeed) - ); - afterMaturityFeed = new ChainlinkBasePriceFeed( + USDeWrappedFeed = new ChainlinkBasePriceFeed( gov, USDeFeed, address(0), 24 hours ); - feedSwitch = new FeedSwitch( - address(navFeed), - address(beforeMaturityFeed), - address(afterMaturityFeed), - 18 hours, + feed = address(new PTDiscountedNAVFeed( + address(USDeWrappedFeed), pendlePT, - pauseGuardian - ); - return address(feedSwitch); + 0.2 ether + )); } // Override the function to use the PendlePTHolder to avoid error revert: stdStorage find(StdStorage): Slot(s) not found diff --git a/test/util/FeedSwitchNavSUSDe29May25.t.sol b/test/util/FeedSwitchNavSUSDe29May25.t.sol deleted file mode 100644 index 483f3fba..00000000 --- a/test/util/FeedSwitchNavSUSDe29May25.t.sol +++ /dev/null @@ -1,28 +0,0 @@ -// 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"; - -interface PendleSparkLinearDiscountOracleFactory { - function createWithPt(address pt, uint256 baseDiscountPerYear) external returns (address); -} - -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); - PendleSparkLinearDiscountOracleFactory navFactory = PendleSparkLinearDiscountOracleFactory(0xA9A924A4BB95509F77868E086154C25e934F6171); - function setUp() public { - string memory url = vm.rpcUrl("mainnet"); - vm.createSelectFork(url, 22018716); - address _navFeed = navFactory.createWithPt(_pendlePT, _baseDiscount); - _beforeMaturityFeed = address(new USDeNavBeforeMaturityFeed(sUSDeWrapper,sUSDe,_navFeed)); // USDeBeforeMaturityFeed: USDe/USD Feed using sUSDe Chainlink feed and sUSDe/USDe rate and NAV - - initialize(address(_beforeMaturityFeed), address(_afterMaturityFeed), _pendlePT , _baseDiscount, _navFeed); - } -}