Skip to content

Commit 45358d8

Browse files
authored
Feat: sdeUSD market (#110)
* add sdeUSD market test * add sdeUSD ale * add ERC4626Feed
1 parent 1c80adb commit 45358d8

File tree

7 files changed

+1573
-0
lines changed

7 files changed

+1573
-0
lines changed

src/feeds/ERC4626Feed.sol

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.4;
3+
4+
import {IChainlinkCurveFeed} from "src/interfaces/IChainlinkCurveFeed.sol";
5+
import {IERC4626} from "lib/openzeppelin-contracts/contracts/interfaces/IERC4626.sol";
6+
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
7+
import {FixedPointMathLib} from "solmate/utils/FixedPointMathLib.sol";
8+
9+
/// @title ERC4626Feed
10+
/// @notice This contract is a generalized contract for an ERC4626 vault which has a feed in a Normalized Asset to USD price
11+
/// @dev It will convert the normalized asset to USD price to the Asset to USD price using the vault's rate
12+
/// @dev This is contract is meant to be used in combination with ChainlinkCurveFeed or ChainlinkCurve2CoinsFeed contracts.
13+
/// @dev Underlying asset decimals must be 18 or lower.
14+
15+
contract ERC4626Feed {
16+
using FixedPointMathLib for uint256;
17+
error DecimalsMismatch();
18+
19+
// ChainlinkCurve feed for the normalized asset to USD price
20+
IChainlinkCurveFeed public immutable feed;
21+
// ERC4626 vault asset
22+
IERC4626 public immutable vault;
23+
// Asset scale
24+
uint256 public immutable assetScale;
25+
// Scaling factor
26+
uint256 public constant SCALE = 1e18;
27+
// Description of the feed
28+
string public description;
29+
30+
constructor(address _vault, address _feed) {
31+
feed = IChainlinkCurveFeed(_feed);
32+
vault = IERC4626(_vault);
33+
assetScale = 10 ** IERC20Metadata(vault.asset()).decimals();
34+
35+
if (
36+
feed.decimals() != 18 ||
37+
vault.decimals() != 18 ||
38+
assetScale > SCALE
39+
) revert DecimalsMismatch();
40+
41+
description = string(
42+
abi.encodePacked(
43+
feed.description(),
44+
" using ",
45+
vault.symbol(),
46+
" vault rate"
47+
)
48+
);
49+
}
50+
51+
/**
52+
* @return roundId The round ID from the feed
53+
* @return assetToUsdPrice The latest asset price in USD
54+
* @return startedAt The timestamp when the latest round of feed started
55+
* @return updatedAt The timestamp when the latest round of feed was updated
56+
* @return answeredInRound The round ID in which the answer was computed
57+
*/
58+
function latestRoundData()
59+
public
60+
view
61+
returns (uint80, int256, uint256, uint256, uint80)
62+
{
63+
(
64+
uint80 roundId,
65+
int256 normalizedAssetToUsdPrice,
66+
uint256 startedAt,
67+
uint256 updatedAt,
68+
uint80 answeredInRound
69+
) = feed.latestRoundData();
70+
71+
uint256 assetToUnderlyingRate;
72+
73+
try vault.previewRedeem(SCALE) returns (uint256 rate) {
74+
assetToUnderlyingRate = rate.divWadDown(assetScale);
75+
} catch {
76+
assetToUnderlyingRate = vault.convertToAssets(SCALE).divWadDown(
77+
assetScale
78+
);
79+
}
80+
81+
// Multiply Normalized Asset/USD price by asset/underlying rate to get Asset/USD price
82+
int256 assetToUsdPrice = int256(
83+
(uint256(normalizedAssetToUsdPrice) * assetToUnderlyingRate) / SCALE
84+
);
85+
86+
return (
87+
roundId,
88+
assetToUsdPrice,
89+
startedAt,
90+
updatedAt,
91+
answeredInRound
92+
);
93+
}
94+
95+
/**
96+
@notice Retrieves the latest asset price
97+
@return price The latest asset price
98+
*/
99+
function latestAnswer() external view returns (int256) {
100+
(, int256 price, , , ) = latestRoundData();
101+
return price;
102+
}
103+
104+
/**
105+
* @notice Retrieves number of decimals for the price feed
106+
* @return decimals The number of decimals for the price feed
107+
*/
108+
function decimals() public pure returns (uint8) {
109+
return 18;
110+
}
111+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
pragma solidity ^0.8.13;
2+
3+
import {IChainlinkBasePriceFeed} from "src/interfaces/IChainlinkFeed.sol";
4+
import {ICurvePool} from "src/interfaces/ICurvePool.sol";
5+
6+
interface IChainlinkCurveFeed {
7+
function decimals() external view returns (uint8 decimals);
8+
9+
function latestRoundData()
10+
external
11+
view
12+
returns (
13+
uint80 roundId,
14+
int256 crvUsdPrice,
15+
uint256 startedAt,
16+
uint256 updatedAt,
17+
uint80 answeredInRound
18+
);
19+
20+
function latestAnswer() external view returns (int256 price);
21+
22+
function description() external view returns (string memory description);
23+
24+
function assetToUsd() external view returns (IChainlinkBasePriceFeed feed);
25+
26+
function curvePool() external view returns (ICurvePool pool);
27+
28+
function targetIndex() external view returns (uint256 index);
29+
30+
function assetOrTargetK() external view returns (uint256 k);
31+
}

src/interfaces/ICurvePool.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,6 @@ interface ICurvePool {
7575
function symbol() external view returns (string memory);
7676

7777
function lp_token() external view returns (address);
78+
79+
function decimals() external view returns (uint256);
7880
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.19;
3+
4+
import "forge-std/Test.sol";
5+
import "src/feeds/ERC4626Feed.sol";
6+
import "src/interfaces/IChainlinkFeed.sol";
7+
import {ChainlinkCurveFeed, ICurvePool} from "src/feeds/ChainlinkCurveFeed.sol";
8+
import "forge-std/console.sol";
9+
import {ERC20 as ERC20Mock} from "test/mocks/ERC20.sol";
10+
import {ERC4626, ERC20} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
11+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
12+
13+
contract Mock4626 is ERC4626 {
14+
constructor(IERC20 _asset) ERC20("MOCK", "MOCK") ERC4626(_asset) {}
15+
16+
function _decimalsOffset() internal view override returns (uint8) {
17+
return 12;
18+
}
19+
}
20+
21+
contract SdeUSDFeedForkTest is Test {
22+
ChainlinkCurveFeed curveFeed;
23+
ERC4626Feed feed;
24+
address curvePool = address(0x82202CAEC5E6d85014eADC68D4912F3C90093e7C);
25+
uint256 k = 0;
26+
uint256 targetIndex = 1;
27+
address sdeUSD = address(0x5C5b196aBE0d54485975D1Ec29617D42D9198326);
28+
address dolaFeed = address(0x6255981e2a1EBeA600aFC506185590eD383517be);
29+
30+
ERC20Mock mock6Decimals;
31+
Mock4626 mock6Vault;
32+
ERC4626Feed feed6Decimals;
33+
34+
function setUp() public {
35+
string memory url = vm.rpcUrl("mainnet");
36+
vm.createSelectFork(url);
37+
curveFeed = new ChainlinkCurveFeed(dolaFeed, curvePool, k, targetIndex);
38+
feed = new ERC4626Feed(sdeUSD, address(curveFeed));
39+
}
40+
41+
function test_6Decimals_underlying_asset_Feed_returns_18() public {
42+
mock6Decimals = new ERC20Mock("Mock6", "M6", 6);
43+
mock6Vault = new Mock4626(IERC20(address(mock6Decimals)));
44+
assertEq(mock6Vault.decimals(), 18);
45+
46+
feed6Decimals = new ERC4626Feed(
47+
address(mock6Vault),
48+
address(curveFeed)
49+
);
50+
uint256 amount = 10000e6;
51+
assertEq(feed6Decimals.decimals(), 18);
52+
assertEq(feed6Decimals.assetScale(), 1e6);
53+
54+
mock6Decimals.mint(address(this), amount);
55+
mock6Decimals.approve(address(mock6Vault), amount);
56+
mock6Vault.deposit(amount, address(this));
57+
58+
assertEq(mock6Vault.convertToAssets(1e18), 1e6);
59+
assertEq(mock6Vault.previewRedeem(1e18), 1e6);
60+
assertEq(mock6Vault.convertToShares(1e6), 1e18);
61+
assertEq(mock6Vault.previewDeposit(1e6), 1e18);
62+
63+
uint256 sdeUSDNormalizedToDola = ICurvePool(curvePool).price_oracle(
64+
curveFeed.assetOrTargetK()
65+
);
66+
67+
int256 dolaToUsdPrice = curveFeed.assetToUsd().latestAnswer();
68+
int256 sdeUSDNormalizedToUsdPrice = int256(
69+
(sdeUSDNormalizedToDola * uint(dolaToUsdPrice)) / 1e18
70+
);
71+
72+
uint256 sdeUSDToDeUSDRate = mock6Vault.previewRedeem(1e18);
73+
74+
uint256 price = (uint(sdeUSDNormalizedToUsdPrice) * sdeUSDToDeUSDRate) /
75+
1e6;
76+
assertEq(feed6Decimals.latestAnswer(), int256(price));
77+
}
78+
79+
function test_decimals() public {
80+
assertEq(feed.decimals(), 18);
81+
assertEq(feed.assetScale(), 1e18);
82+
}
83+
84+
function test_description() public {
85+
assertEq(feed.description(), "sdeUSD / USD using sdeUSD vault rate");
86+
}
87+
88+
function test_latestRoundData() public {
89+
(
90+
uint80 roundId,
91+
int256 sdeUSDToUsdPrice,
92+
uint256 startedAt,
93+
uint256 updatedAt,
94+
uint80 answeredInRound
95+
) = feed.latestRoundData();
96+
assertEq(feed.latestAnswer(), _calculateSdeUSDPrice());
97+
(
98+
uint80 roundId2,
99+
,
100+
uint256 startedAt2,
101+
uint256 updatedAt2,
102+
uint80 answeredInRound2
103+
) = IChainlinkBasePriceFeed(dolaFeed).latestRoundData();
104+
// Data are
105+
assertEq(roundId, roundId2);
106+
assertEq(sdeUSDToUsdPrice, _calculateSdeUSDPrice());
107+
assertEq(startedAt, startedAt2);
108+
assertEq(updatedAt, updatedAt2);
109+
assertEq(answeredInRound, answeredInRound2);
110+
}
111+
112+
function test_sdeUSD_upward_depeg() public {
113+
int256 answer = feed.latestAnswer();
114+
assertEq(feed.latestAnswer(), _calculateSdeUSDPrice());
115+
uint256 mockRate = 2e18;
116+
_mockVaultRate(sdeUSD, mockRate);
117+
assertEq(feed.latestAnswer(), _calculateSdeUSDPrice());
118+
assertGt(feed.latestAnswer(), answer);
119+
}
120+
121+
function test_sdeUSD_downward_depeg() public {
122+
int256 answer = feed.latestAnswer();
123+
assertEq(feed.latestAnswer(), _calculateSdeUSDPrice());
124+
uint256 mockRate = 0.5e18;
125+
_mockVaultRate(sdeUSD, mockRate);
126+
assertEq(feed.latestAnswer(), _calculateSdeUSDPrice());
127+
assertLt(feed.latestAnswer(), answer);
128+
}
129+
130+
function test_previewRedeemRevert_useConvertToAssets() public {
131+
// Mock preview redeem rate
132+
_mockVaultRate(sdeUSD, 2e18);
133+
// Answer is equal to preview redeem rate but not equal to convert to assets
134+
assertEq(feed.latestAnswer(), _calculateSdeUSDPrice());
135+
assertGt(feed.latestAnswer(), _calculateSdeUSDPriceConvertToAssets());
136+
// Mock preview redeem revert to use convert to assets
137+
_mockPreviewRevert(sdeUSD);
138+
assertEq(feed.latestAnswer(), _calculateSdeUSDPriceConvertToAssets());
139+
}
140+
141+
function _calculateSdeUSDPrice() internal view returns (int256) {
142+
uint256 sdeUSDNormalizedToDola = ICurvePool(curvePool).price_oracle(
143+
curveFeed.assetOrTargetK()
144+
);
145+
146+
int256 dolaToUsdPrice = curveFeed.assetToUsd().latestAnswer();
147+
int256 sdeUSDNormalizedToUsdPrice = int256(
148+
(sdeUSDNormalizedToDola * uint(dolaToUsdPrice)) / 1e18
149+
);
150+
151+
uint256 sdeUSDToDeUSDRate = IERC4626(sdeUSD).previewRedeem(1e18);
152+
return
153+
(sdeUSDNormalizedToUsdPrice * int(sdeUSDToDeUSDRate)) /
154+
int256(1e18);
155+
}
156+
157+
function _calculateSdeUSDPriceConvertToAssets()
158+
internal
159+
view
160+
returns (int256)
161+
{
162+
uint256 sdeUSDNormalizedToDola = ICurvePool(curvePool).price_oracle(
163+
curveFeed.assetOrTargetK()
164+
);
165+
166+
int256 dolaToUsdPrice = curveFeed.assetToUsd().latestAnswer();
167+
int256 sdeUSDNormalizedToUsdPrice = int256(
168+
(sdeUSDNormalizedToDola * uint(dolaToUsdPrice)) / 1e18
169+
);
170+
171+
uint256 sdeUSDToDeUSDRate = IERC4626(sdeUSD).convertToAssets(1e18);
172+
return
173+
(sdeUSDNormalizedToUsdPrice * int(sdeUSDToDeUSDRate)) /
174+
int256(1e18);
175+
}
176+
177+
function _mockVaultRate(address vault, uint256 mockRate) internal {
178+
vm.mockCall(
179+
vault,
180+
abi.encodeWithSelector(IERC4626.previewRedeem.selector, 1e18),
181+
abi.encode(mockRate)
182+
);
183+
}
184+
185+
function _mockPreviewRevert(address vault) internal {
186+
vm.mockCallRevert(
187+
vault,
188+
abi.encodeWithSelector(IERC4626.previewRedeem.selector, 1e18),
189+
"mock revert"
190+
);
191+
}
192+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.13;
3+
4+
import "forge-std/Test.sol";
5+
import "./MarketBaseForkTest.sol";
6+
import "src/feeds/ERC4626Feed.sol";
7+
import "src/Market.sol";
8+
import {ChainlinkCurveFeed} from "src/feeds/ChainlinkCurveFeed.sol";
9+
10+
contract SdeUSDMarketForkTest is MarketBaseForkTest {
11+
address curvePool = address(0x82202CAEC5E6d85014eADC68D4912F3C90093e7C);
12+
uint256 k = 0;
13+
uint256 targetIndex = 1;
14+
address sdeUSD = address(0x5C5b196aBE0d54485975D1Ec29617D42D9198326);
15+
address dolaFeed = address(0x6255981e2a1EBeA600aFC506185590eD383517be);
16+
17+
function setUp() public virtual {
18+
//This will fail if there's no mainnet variable in foundry.toml
19+
string memory url = vm.rpcUrl("mainnet");
20+
vm.createSelectFork(url, 21880783);
21+
address curveFeed = address(
22+
new ChainlinkCurveFeed(dolaFeed, curvePool, k, targetIndex)
23+
);
24+
address feedAddr = address(new ERC4626Feed(sdeUSD, curveFeed));
25+
26+
address marketAddr = address(
27+
new Market(
28+
gov,
29+
lender,
30+
pauseGuardian,
31+
simpleERC20EscrowAddr,
32+
IDolaBorrowingRights(dbrAddr),
33+
IERC20(sdeUSD),
34+
IOracle(oracleAddr),
35+
5000,
36+
1000,
37+
1000,
38+
false
39+
)
40+
);
41+
_advancedInit(marketAddr, feedAddr, false);
42+
}
43+
}

0 commit comments

Comments
 (0)