Skip to content

Commit fc87703

Browse files
authored
feat(middleware): wsteth oracle with data streams (#898)
* feat: wsteth oracle with data streams * docs: wstETH * docs: ETH
1 parent a01bc4f commit fc87703

File tree

12 files changed

+310
-23
lines changed

12 files changed

+310
-23
lines changed

src/OracleMiddleware/WstEthOracleMiddleware.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { OracleMiddlewareWithPyth } from "./OracleMiddlewareWithPyth.sol";
1010

1111
/**
1212
* @title Middleware Implementation For WstETH Price
13-
* @notice This contract is used to get the price of wstETH from the eth price oracle.
13+
* @notice This contract is used to get the price of wstETH from the ETH price oracle.
1414
*/
1515
contract WstEthOracleMiddleware is OracleMiddlewareWithPyth {
1616
/// @notice The wstETH contract.
@@ -35,7 +35,7 @@ contract WstEthOracleMiddleware is OracleMiddlewareWithPyth {
3535

3636
/**
3737
* @inheritdoc IBaseOracleMiddleware
38-
* @notice Parses and validates `data`, returns the corresponding price data by applying eth/wstETH ratio.
38+
* @notice Parses and validates `data`, returns the corresponding price data by applying ETH/wstETH ratio.
3939
* @dev The data format is specific to the middleware and is simply forwarded from the user transaction's calldata.
4040
* Wsteth price is calculated as follows: `ethPrice x stEthPerToken / 1 ether`.
4141
* A fee amounting to exactly {validationCost} (with the same `data` and `action`) must be sent or the transaction
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity 0.8.26;
3+
4+
import { IWstETH } from "../interfaces/IWstETH.sol";
5+
import { IBaseOracleMiddleware } from "../interfaces/OracleMiddleware/IBaseOracleMiddleware.sol";
6+
import { PriceInfo } from "../interfaces/OracleMiddleware/IOracleMiddlewareTypes.sol";
7+
import { IUsdnProtocolTypes as Types } from "../interfaces/UsdnProtocol/IUsdnProtocolTypes.sol";
8+
import { CommonOracleMiddleware } from "./CommonOracleMiddleware.sol";
9+
import { OracleMiddlewareWithDataStreams } from "./OracleMiddlewareWithDataStreams.sol";
10+
11+
/**
12+
* @title Middleware Implementation For WstETH Price With Chainlink Data Streams
13+
* @notice This contract is used to get the price of wstETH from the ETH price or the wstETH price directly.
14+
*/
15+
contract WstEthOracleMiddlewareWithDataStreams is OracleMiddlewareWithDataStreams {
16+
/// @notice The wstETH contract.
17+
IWstETH internal immutable _wstETH;
18+
19+
/**
20+
* @param pythContract The address of the Pyth contract.
21+
* @param pythFeedId The ETH/USD Pyth price feed ID for the asset.
22+
* @param chainlinkPriceFeed The address of the Chainlink ETH/USD price feed.
23+
* @param wstETH The address of the wstETH contract.
24+
* @param chainlinkTimeElapsedLimit The duration after which a Chainlink price is considered stale.
25+
* @param chainlinkProxyVerifierAddress The address of the Chainlink proxy verifier contract.
26+
* @param chainlinkStreamId The supported Chainlink wstETH/USD data stream ID.
27+
*/
28+
constructor(
29+
address pythContract,
30+
bytes32 pythFeedId,
31+
address chainlinkPriceFeed,
32+
address wstETH,
33+
uint256 chainlinkTimeElapsedLimit,
34+
address chainlinkProxyVerifierAddress,
35+
bytes32 chainlinkStreamId
36+
)
37+
OracleMiddlewareWithDataStreams(
38+
pythContract,
39+
pythFeedId,
40+
chainlinkPriceFeed,
41+
chainlinkTimeElapsedLimit,
42+
chainlinkProxyVerifierAddress,
43+
chainlinkStreamId
44+
)
45+
{
46+
_wstETH = IWstETH(wstETH);
47+
}
48+
49+
/**
50+
* @inheritdoc IBaseOracleMiddleware
51+
* @notice Parses and validates `data`, returns the corresponding price data,
52+
* applying ETH/wstETH ratio if the price is in ETH.
53+
* @dev The data format is specific to the middleware and is simply forwarded from the user transaction's calldata.
54+
* If needed, the wstETH price is calculated as follows: `ethPrice x stEthPerToken / 1 ether`.
55+
* A fee amounting to exactly {validationCost} (with the same `data` and `action`) must be sent or the transaction
56+
* will revert.
57+
* @param actionId A unique identifier for the current action. This identifier can be used to link an `Initiate`
58+
* call with the corresponding `Validate` call.
59+
* @param targetTimestamp The target timestamp for validating the price data. For validation actions, this is the
60+
* timestamp of the initiation.
61+
* @param action Type of action for which the price is requested. The middleware may use this to alter the
62+
* validation of the price or the returned price.
63+
* @param data The data to be used to communicate with oracles, the format varies from middleware to middleware and
64+
* can be different depending on the action.
65+
* @return result_ The price and timestamp as {IOracleMiddlewareTypes.PriceInfo}.
66+
*/
67+
function parseAndValidatePrice(
68+
bytes32 actionId,
69+
uint128 targetTimestamp,
70+
Types.ProtocolAction action,
71+
bytes calldata data
72+
) public payable virtual override(IBaseOracleMiddleware, CommonOracleMiddleware) returns (PriceInfo memory) {
73+
PriceInfo memory oraclePrice = super.parseAndValidatePrice(actionId, targetTimestamp, action, data);
74+
75+
if (data.length > 32 && !_isPythData(data)) {
76+
// the price is already in wstETH/USD from the Chainlink data stream
77+
return oraclePrice;
78+
}
79+
80+
uint256 stEthPerToken = _wstETH.stEthPerToken();
81+
82+
return PriceInfo({
83+
price: oraclePrice.price * stEthPerToken / 1 ether,
84+
neutralPrice: oraclePrice.neutralPrice * stEthPerToken / 1 ether,
85+
timestamp: oraclePrice.timestamp
86+
});
87+
}
88+
}

src/OracleMiddleware/mock/MockWstEthOracleMiddleware.sol

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,15 @@ import { WstEthOracleMiddleware } from "../WstEthOracleMiddleware.sol";
99

1010
/**
1111
* @title Contract to apply and return a mocked wstETH price
12-
* @notice This contract is used to get the price of wsteth by setting up a price or forwarding it to wstethMiddleware
12+
* @notice This contract is used to get the price of wstETH by setting up a price or forwarding it to wstethMiddleware
1313
* @dev This aims at simulating price action. Do not use in production
1414
*/
1515
contract MockWstEthOracleMiddleware is WstEthOracleMiddleware {
1616
/// @notice Confidence interval percentage numerator
1717
uint16 internal _wstethMockedConfBps = 20; // default 0.2% conf
1818

1919
/**
20-
* @notice Wsteth mocked price
20+
* @notice wstETH mocked price
2121
* @dev This price will be used if greater than zero
2222
*/
2323
uint256 internal _wstethMockedPrice;
@@ -42,7 +42,7 @@ contract MockWstEthOracleMiddleware is WstEthOracleMiddleware {
4242
Types.ProtocolAction action,
4343
bytes calldata data
4444
) public payable override returns (PriceInfo memory price_) {
45-
// parse and validate from parent wsteth middleware
45+
// parse and validate from parent WstEth middleware
4646
// this aims to verify pyth price hermes signature in any case
4747
if (_verifySignature || _wstethMockedPrice == 0) {
4848
price_ = super.parseAndValidatePrice(actionId, targetTimestamp, action, data);
@@ -75,8 +75,8 @@ contract MockWstEthOracleMiddleware is WstEthOracleMiddleware {
7575
}
7676

7777
/**
78-
* @notice Set Wsteth mocked price
79-
* @dev If the new mocked wsteth is greater than zero this will validate this mocked price else this will validate
78+
* @notice Set WstEth mocked price
79+
* @dev If the new mocked WstEth is greater than zero this will validate this mocked price else this will validate
8080
* the parent middleware price
8181
* @param newWstethMockedPrice The mock price to set
8282
*/
@@ -94,12 +94,12 @@ contract MockWstEthOracleMiddleware is WstEthOracleMiddleware {
9494
_wstethMockedConfBps = newWstethMockedConfPct;
9595
}
9696

97-
/// @notice Get current wsteth mocked price
97+
/// @notice Get current WstEth mocked price
9898
function getWstethMockedPrice() external view returns (uint256) {
9999
return _wstethMockedPrice;
100100
}
101101

102-
/// @notice Get current wsteth mocked confidence interval
102+
/// @notice Get current WstEth mocked confidence interval
103103
function getWstethMockedConfBps() external view returns (uint64) {
104104
return _wstethMockedConfBps;
105105
}

src/interfaces/IWstETH.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface IWstETH is IERC20Metadata, IERC20Permit {
1212
* - `_stETHAmount` must be non-zero
1313
* - msg.sender must approve at least `_stETHAmount` stETH to this contract
1414
* - msg.sender must have at least `_stETHAmount` of stETH
15-
* User should first approve `_stETHAmount` to the WstETH contract
15+
* User should first approve `_stETHAmount` to the wstETH contract
1616
* @return Amount of wstETH user receives after wrap
1717
*/
1818
function wrap(uint256 _stETHAmount) external returns (uint256);

test/integration/Middlewares/Oracle/ParseAndValidatePrice.t.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ contract TestOracleMiddlewareParseAndValidatePriceRealData is OracleMiddlewareBa
9494

9595
/**
9696
* @custom:scenario Parse and validate price with chainlink on-chain
97-
* @custom:given The price feed is eth/usd for chainlink
97+
* @custom:given The price feed is ETH/USD for chainlink
9898
* @custom:when The protocol action is any targeted action
9999
* @custom:then The price retrieved by the oracle middleware is the same as the one from chainlink on-chain data
100100
*/
@@ -257,7 +257,7 @@ contract TestOracleMiddlewareParseAndValidatePriceRealData is OracleMiddlewareBa
257257

258258
/**
259259
* @custom:scenario Parse and validate price with chainlink on-chain
260-
* @custom:given The price feed is eth/usd for chainlink
260+
* @custom:given The price feed is ETH/USD for chainlink
261261
* @custom:when Protocol action is an `initiateDeposit`
262262
* @custom:then The price signature is well-decoded
263263
* @custom:and The price retrieved by the oracle middleware is the same as the one from the chainlink on-chain data

test/integration/Middlewares/WstethOracle/ParseAndValidatePrice.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ contract TestWstethMiddlewareParseAndValidatePriceRealData is WstethIntegrationF
281281
assertApproxEqAbs(middlewarePrice.timestamp, pythWstethTimestamp, 5, "Wrong similar timestamp");
282282

283283
// should obtain a short different price between the pyth wsteth price feed
284-
// and the pyth eth price feed adjusted with ratio.
284+
// and the pyth ETH price feed adjusted with ratio.
285285
// We are ok with a delta below the pyth wsteth confidence.
286286
assertApproxEqAbs(
287287
middlewarePrice.price,

test/integration/UsdnProtocol/RebalancerInitiateClosePosition.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ contract TestRebalancerInitiateClosePosition is
245245
protocol.setExpoImbalanceLimits(0, 0, 0, 0, 0, 0);
246246

247247
skip(1 hours);
248-
// put the eth price a bit higher to avoid liquidating existing position
248+
// put the ETH price a bit higher to avoid liquidating existing position
249249
wstEthPrice = _setOraclePrices(wstEthPrice * 15 / 10);
250250

251251
vm.startPrank(user);

test/unit/LiquidationRewardsManager/GetLiquidationRewardsWusdn.t.sol

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ contract TestLiquidationRewardsManagerWusdnGetLiquidationRewards is BaseFixture
1818
LiquidationRewardsManagerWusdn internal liquidationRewardsManager;
1919
ILiquidationRewardsManagerErrorsEventsTypes.RewardsParameters rewardsParameters;
2020

21-
uint256 internal constant CURRENT_PRICE = 1 ether / 1000; // 0.001 eth/wUsdn
21+
uint256 internal constant CURRENT_PRICE = 1 ether / 1000; // 0.001 ETH/WUSDN
2222

2323
Types.LiqTickInfo[] internal _singleLiquidatedTick;
2424
Types.LiqTickInfo[] internal _liquidatedTicksEmpty;
@@ -41,8 +41,8 @@ contract TestLiquidationRewardsManagerWusdnGetLiquidationRewards is BaseFixture
4141
totalPositions: 1,
4242
totalExpo: 10_000 ether,
4343
remainingCollateral: -200 ether,
44-
tickPrice: 1.02 ether / 1000, // 0.00102 eth/wUsdn
45-
priceWithoutPenalty: 1 ether / 1000 // 0.001 eth/wUsdn
44+
tickPrice: 1.02 ether / 1000, // 0.00102 ETH/wUsdn
45+
priceWithoutPenalty: 1 ether / 1000 // 0.001 ETH/wUsdn
4646
})
4747
);
4848
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity 0.8.26;
3+
4+
import { FixedPointMathLib } from "solady/src/utils/FixedPointMathLib.sol";
5+
6+
import { MOCK_PYTH_DATA } from "../../utils/Constants.sol";
7+
import { WstethOracleWithDataStreamsBaseFixture } from "../../utils/Fixtures.sol";
8+
9+
import { PriceInfo } from "../../../../../src/interfaces/OracleMiddleware/IOracleMiddlewareTypes.sol";
10+
import { IUsdnProtocolTypes as Types } from "../../../../../src/interfaces/UsdnProtocol/IUsdnProtocolTypes.sol";
11+
12+
/**
13+
* @custom:feature The `parseAndValidatePrice` function of the `WstethOracleWithDataStreams`
14+
* @custom:background A deployed `WstethOracleWithDataStreams` contract.
15+
*/
16+
contract TestWstethOracleParseAndValidatePrice is WstethOracleWithDataStreamsBaseFixture {
17+
uint256 internal oracleFee;
18+
19+
function setUp() public override {
20+
super.setUp();
21+
}
22+
23+
/**
24+
* @custom:scenario Tests the `parseAndValidatePrice` with a payload from a Chainlink data stream.
25+
* @custom:when The function is called.
26+
* @custom:then The returned price data must be equal to the data from the Chainlink data stream report.
27+
*/
28+
function test_parseAndValidatePriceWithDataStreams() public {
29+
oracleFee = oracleMiddleware.validationCost(payload, Types.ProtocolAction.InitiateOpenPosition);
30+
31+
PriceInfo memory price = oracleMiddleware.parseAndValidatePrice{ value: oracleFee }(
32+
"", 0, Types.ProtocolAction.InitiateOpenPosition, payload
33+
);
34+
35+
assertEq(price.price, uint192(report.price), "The returned price must be equal to the report price");
36+
assertEq(
37+
price.neutralPrice, uint192(report.price), "The returned neutral price must be equal to the report price"
38+
);
39+
assertEq(
40+
price.timestamp,
41+
uint128(report.observationsTimestamp),
42+
"The returned timestamp must be equal to the report observationsTimestamp"
43+
);
44+
}
45+
46+
/**
47+
* @custom:scenario Tests the `parseAndValidatePrice` with a price data from a Pyth price feed.
48+
* @custom:when The function is called.
49+
* @custom:then The returned price data must be equal to the adjusted data from the Pyth price feed.
50+
*/
51+
function test_parseAndValidatePriceWithPyth() public {
52+
oracleFee = oracleMiddleware.validationCost(MOCK_PYTH_DATA, Types.ProtocolAction.Liquidation);
53+
54+
PriceInfo memory price = oracleMiddleware.parseAndValidatePrice{ value: oracleFee }(
55+
"", 0, Types.ProtocolAction.Liquidation, MOCK_PYTH_DATA
56+
);
57+
uint256 adjustedWstethPythPrice = uint256(uint64(mockPyth.price()))
58+
* 10 ** (oracleMiddleware.getDecimals() - FixedPointMathLib.abs(mockPyth.expo())) * wsteth.stEthPerToken()
59+
/ 1 ether;
60+
61+
assertEq(price.price, adjustedWstethPythPrice, "The returned price must be equal to the adjusted Pyth price");
62+
assertEq(
63+
price.neutralPrice,
64+
adjustedWstethPythPrice,
65+
"The returned neutral price must be equal to the adjusted Pyth price"
66+
);
67+
assertEq(
68+
price.timestamp, mockPyth.lastPublishTime(), "The returned timestamp must be equal to the Pyth timestamp"
69+
);
70+
}
71+
72+
/**
73+
* @custom:scenario Tests the `parseAndValidatePrice` without data.
74+
* @custom:when The function is called.
75+
* @custom:then The returned price data must be equal to the adjusted data from the latest roundId of the Chainlink
76+
* data feeds.
77+
*/
78+
function test_parseAndValidatePriceWithDataFeedsEmptyData() public {
79+
oracleFee = oracleMiddleware.validationCost("", Types.ProtocolAction.InitiateOpenPosition);
80+
81+
(, int256 answer,, uint256 updatedAt,) = mockChainlinkOnChain.latestRoundData();
82+
PriceInfo memory price = oracleMiddleware.parseAndValidatePrice{ value: oracleFee }(
83+
"", 0, Types.ProtocolAction.InitiateOpenPosition, ""
84+
);
85+
uint256 adjustedWstethDataFeedsPrice = uint256(answer)
86+
* 10 ** (oracleMiddleware.getDecimals() - mockChainlinkOnChain.decimals()) * wsteth.stEthPerToken() / 1 ether;
87+
assertEq(
88+
price.price,
89+
adjustedWstethDataFeedsPrice,
90+
"The returned price must be equal to the adjusted latest roundId adjusted price"
91+
);
92+
assertEq(
93+
price.neutralPrice,
94+
adjustedWstethDataFeedsPrice,
95+
"The returned neutral price must be equal to the adjusted latest roundId adjusted price"
96+
);
97+
assertEq(price.timestamp, updatedAt, "The returned timestamp must be equal to the latest roundId timestamp");
98+
}
99+
100+
/**
101+
* @custom:scenario Tests the `parseAndValidatePrice` with a specified roundId data from a Chainlink data feed.
102+
* @custom:when The function is called.
103+
* @custom:then The returned price data must be equal to the adjusted data from the specified
104+
* roundId of the Chainlink data feeds.
105+
*/
106+
function test_parseAndValidatePriceWithDataFeedsRoundIdData() public {
107+
uint256 lowLatencyDelay = oracleMiddleware.getLowLatencyDelay();
108+
skip(lowLatencyDelay + 1);
109+
110+
uint80 roundId = 1;
111+
(,,, uint256 previousRoundIdTimestamp,) = mockChainlinkOnChain.getRoundData(roundId - 1);
112+
113+
mockChainlinkOnChain.setRoundTimestamp(
114+
roundId, previousRoundIdTimestamp + oracleMiddleware.getLowLatencyDelay() + 1
115+
);
116+
oracleFee = oracleMiddleware.validationCost(abi.encode(roundId), Types.ProtocolAction.ValidateOpenPosition);
117+
118+
(, int256 answer,, uint256 updatedAt,) = mockChainlinkOnChain.getRoundData(roundId);
119+
PriceInfo memory price = oracleMiddleware.parseAndValidatePrice{ value: oracleFee }(
120+
"", uint128(previousRoundIdTimestamp), Types.ProtocolAction.ValidateOpenPosition, abi.encode(roundId)
121+
);
122+
uint256 adjustedWstethDataFeedsPrice = uint256(answer)
123+
* 10 ** (oracleMiddleware.getDecimals() - mockChainlinkOnChain.decimals()) * wsteth.stEthPerToken() / 1 ether;
124+
125+
assertEq(
126+
price.price,
127+
adjustedWstethDataFeedsPrice,
128+
"The returned price must be equal to the specified roundId adjusted price"
129+
);
130+
assertEq(
131+
price.neutralPrice,
132+
adjustedWstethDataFeedsPrice,
133+
"The returned neutral price must be equal to the specified roundId adjusted price"
134+
);
135+
assertEq(price.timestamp, updatedAt, "The returned timestamp must be equal to specified roundId timestamp");
136+
}
137+
}

test/unit/Middlewares/utils/Constants.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ uint48 constant REDSTONE_ETH_TIMESTAMP = 1_717_684_100;
1818
bytes constant REDSTONE_ETH_DATA =
1919
hex"00000000000000004554480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000595cd5e4d5018fedf35ba00000002000000196c710f6ee933d08f86e99def2c6c64684808705691289aef63503f54bf8b9202d21cde463aeef0830cea7795a03cbebd92d51a7eb13134129fc82e3cac26e2e1b4554480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000595cd4a962018fedf35ba0000000200000015bd816c1b59f67af5990b834c572b1e403a6a7ffcbc78e13510a0b7de750e02a4d0c641de3797cc2225fd6064f7445d0d77808ec620bd96f84e298bea049ac2f1c4554480000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000595cd5e4d5018fedf35ba000000020000001ab05ff923cef4ea56a1b77af4ea5f2700cab64628b44aaf460b7d9950dff4c1e74da926233820b52b78644fce28a97dc7b88c25bbe1dbd173ff69e8e3c1dcef01b0003000000000002ed57011e0000";
2020

21-
uint256 constant STREAM_ETH_PRICE = 2000 ether;
21+
uint256 constant STREAM_WSTETH_PRICE = 2300 ether;
2222
bytes32 constant EMPTY_STREAM_V3 = 0x0003000000000000000000000000000000000000000000000000000000000000;
2323
bytes32 constant EMPTY_STREAM_V4 = 0x0004000000000000000000000000000000000000000000000000000000000000;

0 commit comments

Comments
 (0)