@ubk-labs/ubk-oracle 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +34 -0
- package/artifacts/contracts/constants/Constants.sol/Constants.dbg.json +4 -0
- package/artifacts/contracts/constants/Constants.sol/Constants.json +141 -0
- package/artifacts/contracts/core/UBKOracle.sol/UBKOracle.dbg.json +4 -0
- package/artifacts/contracts/core/UBKOracle.sol/UBKOracle.json +1028 -0
- package/artifacts/contracts/mocks/MockAggregatorV3.sol/MockAggregatorV3.dbg.json +4 -0
- package/artifacts/contracts/mocks/MockAggregatorV3.sol/MockAggregatorV3.json +177 -0
- package/artifacts/contracts/mocks/MockERC20.sol/MockERC20.dbg.json +4 -0
- package/artifacts/contracts/mocks/MockERC20.sol/MockERC20.json +381 -0
- package/artifacts/contracts/mocks/MockERC4626.sol/Mock4626.dbg.json +4 -0
- package/artifacts/contracts/mocks/MockERC4626.sol/Mock4626.json +500 -0
- package/artifacts/contracts/mocks/MockIERC4626.sol/MockIERC4626.dbg.json +4 -0
- package/artifacts/contracts/mocks/MockIERC4626.sol/MockIERC4626.json +99 -0
- package/artifacts/interfaces/IUBKOracle.sol/IUBKOracle.dbg.json +4 -0
- package/artifacts/interfaces/IUBKOracle.sol/IUBKOracle.json +264 -0
- package/contracts/constants/Constants.sol +34 -0
- package/contracts/core/UBKOracle.sol +560 -0
- package/contracts/errors/Errors.sol +17 -0
- package/contracts/mocks/MockAggregatorV3.sol +56 -0
- package/contracts/mocks/MockERC20.sol +28 -0
- package/contracts/mocks/MockERC4626.sol +74 -0
- package/contracts/mocks/MockIERC4626.sol +13 -0
- package/interfaces/IUBKOracle.sol +54 -0
- package/package.json +41 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.20;
|
|
3
|
+
|
|
4
|
+
import "@openzeppelin/contracts/access/Ownable.sol";
|
|
5
|
+
import "@openzeppelin/contracts/utils/math/Math.sol";
|
|
6
|
+
import "@openzeppelin/contracts/interfaces/IERC4626.sol";
|
|
7
|
+
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
|
|
8
|
+
|
|
9
|
+
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
|
|
10
|
+
|
|
11
|
+
import "../../interfaces/IUBKOracle.sol";
|
|
12
|
+
import "../errors/Errors.sol";
|
|
13
|
+
import "../constants/Constants.sol";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @title Oracle
|
|
17
|
+
* @notice This contract is an implementation of the IOracle interface.
|
|
18
|
+
*
|
|
19
|
+
* @dev
|
|
20
|
+
* The oracle computes normalized 1e18 prices for all supported assets,
|
|
21
|
+
* combining manual overrides, ERC4626 vault conversions, and Chainlink feeds.
|
|
22
|
+
*
|
|
23
|
+
* === PRICE RESOLUTION ORDER ===
|
|
24
|
+
* 1️⃣ Manual override (±10% bound from last valid)
|
|
25
|
+
* 2️⃣ ERC4626 vault-derived (convertToAssets * underlying price)
|
|
26
|
+
* 3️⃣ Chainlink feed (normalized to 1e18)
|
|
27
|
+
*
|
|
28
|
+
* === SAFETY FEATURES ===
|
|
29
|
+
* - Recursion guard (nested ERC4626 depth ≤ 5)
|
|
30
|
+
* - Chainlink stale-period enforcement
|
|
31
|
+
* - Vault rate sanity bounds (min/max rate per vault)
|
|
32
|
+
* - Manual mode ±10% limit if last valid < stalePeriod
|
|
33
|
+
* - Circuit breaker (paused mode)
|
|
34
|
+
* - Fallback to last valid price (if feed fails but within fallback window)
|
|
35
|
+
*
|
|
36
|
+
* === DESIGN PHILOSOPHY ===
|
|
37
|
+
* - `getPrice()` returns cached prices only (for gas efficiency).
|
|
38
|
+
* - `fetchAndUpdatePrice()` pulls fresh on-chain data (keeper or contract call).
|
|
39
|
+
* - UI / Subgraphs can use `isPriceFresh()` and `getPriceAge()` for safety checks.
|
|
40
|
+
*
|
|
41
|
+
*/
|
|
42
|
+
contract UBKOracle is IUBKOracle, Ownable {
|
|
43
|
+
// -----------------------------------------------------------------------
|
|
44
|
+
// Storage
|
|
45
|
+
// -----------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/// @notice Chainlink feed address per token.
|
|
48
|
+
mapping(address => address) public chainlinkFeeds;
|
|
49
|
+
|
|
50
|
+
/// @notice ERC4626 vault underlying token mapping.
|
|
51
|
+
mapping(address => address) public erc4626Underlying;
|
|
52
|
+
|
|
53
|
+
/// @notice Manual price overrides (scaled to 1e18).
|
|
54
|
+
mapping(address => uint256) public manualPrices;
|
|
55
|
+
|
|
56
|
+
/// @notice Manual mode flags.
|
|
57
|
+
mapping(address => bool) public isManual;
|
|
58
|
+
|
|
59
|
+
/// @notice Cache for last valid price lookup.
|
|
60
|
+
mapping(address => LastValidPrice) public lastValidPrice;
|
|
61
|
+
|
|
62
|
+
/// @notice Mapping to track vault rate bounds.
|
|
63
|
+
mapping(address => VaultRateBounds) public vaultRateBounds;
|
|
64
|
+
|
|
65
|
+
/// @notice Maximum staleness period for Chainlink feeds (seconds).
|
|
66
|
+
uint256 public stalePeriod = Constants.ORACLE_DEFAULT_STALE_PERIOD;
|
|
67
|
+
|
|
68
|
+
/// @notice Staleness tolerance for fallback prices (seconds).
|
|
69
|
+
uint256 public fallbackStalePeriod =
|
|
70
|
+
Constants.ORACLE_DEFAULT_STALE_PERIOD * 2;
|
|
71
|
+
|
|
72
|
+
OracleMode public mode = OracleMode.NORMAL;
|
|
73
|
+
|
|
74
|
+
/// @notice Recursion tracking for nested ERC4626 vaults.
|
|
75
|
+
uint256 private _recursionDepth;
|
|
76
|
+
|
|
77
|
+
/// @notice Array to track supported tokens.
|
|
78
|
+
address[] public supportedTokens;
|
|
79
|
+
|
|
80
|
+
/// @notice Mapping to track supported tokens.
|
|
81
|
+
mapping(address => bool) public isSupported;
|
|
82
|
+
|
|
83
|
+
// -----------------------------------------------------------------------
|
|
84
|
+
// Constructor & Modifiers
|
|
85
|
+
// -----------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @notice Deploys the Oracle contract.
|
|
89
|
+
* @param _owner The address to assign as the owner (governance or deployer).
|
|
90
|
+
*/
|
|
91
|
+
constructor(address _owner) Ownable(_owner) {
|
|
92
|
+
if (_owner == address(0))
|
|
93
|
+
revert ZeroAddress("Oracle:constructor", "owner");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// @notice Ensures oracle is not paused.
|
|
97
|
+
modifier whenNotPaused() {
|
|
98
|
+
if (mode == OracleMode.PAUSED)
|
|
99
|
+
revert OraclePaused(address(this), block.timestamp);
|
|
100
|
+
_;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// @notice Prevents infinite recursion when resolving nested ERC4626 vaults.
|
|
104
|
+
modifier checkRecursion() {
|
|
105
|
+
if (_recursionDepth >= Constants.MAX_RECURSION_DEPTH)
|
|
106
|
+
revert RecursiveResolution(address(0));
|
|
107
|
+
_recursionDepth++;
|
|
108
|
+
_;
|
|
109
|
+
_recursionDepth--;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// -----------------------------------------------------------------------
|
|
113
|
+
// Admin / Configuration
|
|
114
|
+
// -----------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @notice Switches oracle operating mode (NORMAL ↔ PAUSED).
|
|
118
|
+
* @dev Paused mode halts all fetchAndUpdatePrice() calls.
|
|
119
|
+
*/
|
|
120
|
+
function setOracleMode(OracleMode newMode) external onlyOwner {
|
|
121
|
+
OracleMode oldMode = mode;
|
|
122
|
+
mode = newMode;
|
|
123
|
+
emit OracleModeChanged(oldMode, newMode);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @notice Sets the maximum time (in seconds) a Chainlink feed value is valid.
|
|
128
|
+
* @param period The new staleness threshold.
|
|
129
|
+
* @dev Must lie within [Constants.ORACLE_MIN_STALE_PERIOD, Constants.ORACLE_MAX_STALE_PERIOD].
|
|
130
|
+
*/
|
|
131
|
+
function setStalePeriod(uint256 period) external onlyOwner {
|
|
132
|
+
if (
|
|
133
|
+
period < Constants.ORACLE_MIN_STALE_PERIOD ||
|
|
134
|
+
period > Constants.ORACLE_MAX_STALE_PERIOD
|
|
135
|
+
) revert InvalidStalePeriod(period);
|
|
136
|
+
stalePeriod = period;
|
|
137
|
+
emit StalePeriodUpdated(period);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @notice Sets the fallback staleness tolerance for lastValidPrice().
|
|
142
|
+
* @param period Maximum allowed seconds for fallback validity.
|
|
143
|
+
* @dev Must be ≥ stalePeriod to remain meaningful.
|
|
144
|
+
*/
|
|
145
|
+
function setFallbackStalePeriod(uint256 period) external onlyOwner {
|
|
146
|
+
if (period < stalePeriod) revert InvalidStalePeriod(period);
|
|
147
|
+
fallbackStalePeriod = period;
|
|
148
|
+
emit FallbackStalePeriodUpdated(period);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @notice Configures acceptable min/max conversion rates for a specific ERC4626 vault.
|
|
153
|
+
* @param vault ERC4626 vault token address.
|
|
154
|
+
* @param minRate Minimum acceptable rate (e.g., 0.2e18 = 0.2x).
|
|
155
|
+
* @param maxRate Maximum acceptable rate (e.g., 3e18 = 3x).
|
|
156
|
+
* @dev Prevents mispriced vaults or flash-manipulated convertToAssets().
|
|
157
|
+
*/
|
|
158
|
+
function setVaultRateBounds(
|
|
159
|
+
address vault,
|
|
160
|
+
uint256 minRate,
|
|
161
|
+
uint256 maxRate
|
|
162
|
+
) external onlyOwner {
|
|
163
|
+
if (vault == address(0))
|
|
164
|
+
revert ZeroAddress("Oracle:setVaultRateBounds", "vault");
|
|
165
|
+
if (minRate == 0 || maxRate <= minRate || maxRate > 100e18)
|
|
166
|
+
revert InvalidVaultBounds(vault, minRate, maxRate);
|
|
167
|
+
|
|
168
|
+
vaultRateBounds[vault] = VaultRateBounds(minRate, maxRate);
|
|
169
|
+
emit VaultRateBoundsSet(vault, minRate, maxRate);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @notice Manually sets a price for a token, bounded by ±10% of last valid.
|
|
174
|
+
* @param token Token address.
|
|
175
|
+
* @param price Manual price in 1e18 precision.
|
|
176
|
+
* @dev Enforces bounds if a valid recent price (< stalePeriod) exists.
|
|
177
|
+
* Enables manual mode until disabled.
|
|
178
|
+
*/
|
|
179
|
+
function setManualPrice(
|
|
180
|
+
address token,
|
|
181
|
+
uint256 price
|
|
182
|
+
) external onlyOwner whenNotPaused {
|
|
183
|
+
if (token == address(0))
|
|
184
|
+
revert ZeroAddress("Oracle:setManualPrice", "token");
|
|
185
|
+
if (
|
|
186
|
+
price < Constants.ORACLE_MIN_ABSOLUTE_PRICE_WAD ||
|
|
187
|
+
price > Constants.ORACLE_MAX_ABSOLUTE_PRICE_WAD
|
|
188
|
+
) revert InvalidManualPrice(token, price);
|
|
189
|
+
|
|
190
|
+
LastValidPrice memory lv = lastValidPrice[token];
|
|
191
|
+
if (lv.price > 0 && block.timestamp - lv.timestamp <= stalePeriod) {
|
|
192
|
+
uint256 lowerBound = (lv.price *
|
|
193
|
+
(Constants.WAD - Constants.ORACLE_MANUAL_PRICE_MAX_DELTA_WAD)) /
|
|
194
|
+
Constants.WAD;
|
|
195
|
+
uint256 upperBound = (lv.price *
|
|
196
|
+
(Constants.WAD + Constants.ORACLE_MANUAL_PRICE_MAX_DELTA_WAD)) /
|
|
197
|
+
Constants.WAD;
|
|
198
|
+
if (price < lowerBound || price > upperBound)
|
|
199
|
+
revert InvalidManualPrice(token, price);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
manualPrices[token] = price;
|
|
203
|
+
isManual[token] = true;
|
|
204
|
+
lastValidPrice[token] = LastValidPrice(price, block.timestamp);
|
|
205
|
+
|
|
206
|
+
emit ManualPriceSet(token, price);
|
|
207
|
+
emit ManualModeEnabled(token, true);
|
|
208
|
+
emit LastValidPriceUpdated(token, price, block.timestamp);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* @notice Disables manual pricing for a token.
|
|
213
|
+
* @param token The token address.
|
|
214
|
+
*/
|
|
215
|
+
function disableManualPrice(address token) external onlyOwner {
|
|
216
|
+
if (token == address(0))
|
|
217
|
+
revert ZeroAddress("Oracle:disableManualPrice", "token");
|
|
218
|
+
isManual[token] = false;
|
|
219
|
+
emit ManualModeEnabled(token, false);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @notice Registers a Chainlink feed and validates its response.
|
|
224
|
+
* @param token Asset token address.
|
|
225
|
+
* @param feed Chainlink AggregatorV3 feed address.
|
|
226
|
+
* @dev Ensures decimals ≤ 18 and feed returns a nonzero updatedAt value.
|
|
227
|
+
*/
|
|
228
|
+
function setChainlinkFeed(address token, address feed) external onlyOwner {
|
|
229
|
+
if (token == address(0) || feed == address(0))
|
|
230
|
+
revert ZeroAddress("Oracle:setChainlinkFeed", "input");
|
|
231
|
+
if (feed.code.length == 0) revert InvalidFeedContract(feed);
|
|
232
|
+
|
|
233
|
+
AggregatorV3Interface agg = AggregatorV3Interface(feed);
|
|
234
|
+
uint8 decimals = agg.decimals();
|
|
235
|
+
if (decimals > 18) revert InvalidFeedDecimals(feed, decimals);
|
|
236
|
+
|
|
237
|
+
try agg.latestRoundData() returns (
|
|
238
|
+
uint80,
|
|
239
|
+
int256 answer,
|
|
240
|
+
uint256,
|
|
241
|
+
uint256 updatedAt,
|
|
242
|
+
uint80
|
|
243
|
+
) {
|
|
244
|
+
if (answer <= 0 || updatedAt == 0) revert InvalidFeedContract(feed);
|
|
245
|
+
} catch {
|
|
246
|
+
revert InvalidFeedContract(feed);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
chainlinkFeeds[token] = feed;
|
|
250
|
+
isManual[token] = false;
|
|
251
|
+
_addSupportedToken(token);
|
|
252
|
+
emit ChainlinkFeedSet(token, feed);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @notice Registers an ERC4626 vault and its underlying asset.
|
|
257
|
+
* @param vault ERC4626 vault token.
|
|
258
|
+
* @param underlying Reference underlying asset used for pricing.
|
|
259
|
+
* @dev Does not enforce .asset() == underlying for flexibility
|
|
260
|
+
* (e.g., sUSDe → USDC pegging for depeg protection).
|
|
261
|
+
*/
|
|
262
|
+
function setERC4626Vault(
|
|
263
|
+
address vault,
|
|
264
|
+
address underlying
|
|
265
|
+
) external onlyOwner {
|
|
266
|
+
if (vault == address(0) || underlying == address(0))
|
|
267
|
+
revert ZeroAddress("Oracle:setERC4626Vault", "input");
|
|
268
|
+
try IERC4626(vault).asset() returns (address) {} catch {
|
|
269
|
+
revert InvalidERC4626Vault(vault);
|
|
270
|
+
}
|
|
271
|
+
erc4626Underlying[vault] = underlying;
|
|
272
|
+
_addSupportedToken(vault);
|
|
273
|
+
emit ERC4626Registered(vault, underlying);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// -----------------------------------------------------------------------
|
|
277
|
+
// External / Public API
|
|
278
|
+
// -----------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Public wrapper for internal _getPrice() method.
|
|
282
|
+
*/
|
|
283
|
+
function getPrice(address token) external view returns (uint256 price) {
|
|
284
|
+
return _getPrice(token);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @notice Fetches, resolves, and caches the latest token price.
|
|
289
|
+
* @param token Asset token address.
|
|
290
|
+
* @return price Fresh price in 1e18 precision.
|
|
291
|
+
* @dev Pulls live data from Chainlink or ERC4626 vaults.
|
|
292
|
+
* Should be called by protocol keepers or critical functions.
|
|
293
|
+
*/
|
|
294
|
+
function fetchAndUpdatePrice(
|
|
295
|
+
address token
|
|
296
|
+
) external whenNotPaused returns (uint256) {
|
|
297
|
+
if (token == address(0))
|
|
298
|
+
revert ZeroAddress("Oracle:fetchAndUpdatePrice", "token");
|
|
299
|
+
return _fetchAndUpdatePrice(token);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* @notice Returns age of last cached price in seconds.
|
|
304
|
+
* @param token Token address.
|
|
305
|
+
* @return age Time since lastValidPrice update.
|
|
306
|
+
*/
|
|
307
|
+
function getPriceAge(address token) external view returns (uint256 age) {
|
|
308
|
+
LastValidPrice memory lv = lastValidPrice[token];
|
|
309
|
+
if (lv.timestamp == 0) return type(uint256).max;
|
|
310
|
+
return block.timestamp - lv.timestamp;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @notice Checks if cached price is within freshness threshold.
|
|
315
|
+
* @param token Token address.
|
|
316
|
+
* @return isFresh True if price updated ≤ stalePeriod ago.
|
|
317
|
+
*/
|
|
318
|
+
function isPriceFresh(address token) external view returns (bool isFresh) {
|
|
319
|
+
LastValidPrice memory lv = lastValidPrice[token];
|
|
320
|
+
return (lv.timestamp != 0 &&
|
|
321
|
+
block.timestamp - lv.timestamp <= stalePeriod);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @notice Returns the list of all supported token addresses.
|
|
326
|
+
* @dev The returned array is stored in contract state and may grow over time
|
|
327
|
+
* as new tokens are registered via admin configuration functions.
|
|
328
|
+
* @return tokens An array of all currently supported token addresses.
|
|
329
|
+
*/
|
|
330
|
+
function getSupportedTokens()
|
|
331
|
+
external
|
|
332
|
+
view
|
|
333
|
+
returns (address[] memory tokens)
|
|
334
|
+
{
|
|
335
|
+
return supportedTokens;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// -----------------------------------------------------------------------
|
|
339
|
+
// Public decimals helpers for converting assets with any amount of
|
|
340
|
+
// decimals to 1e18 standardized USD format.
|
|
341
|
+
// -----------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* @notice Converts a token amount into USD (18 decimals)
|
|
345
|
+
* @param token Token address
|
|
346
|
+
* @param amount Raw token amount (native decimals)
|
|
347
|
+
* @return usdValue USD value (18 decimals)
|
|
348
|
+
*/
|
|
349
|
+
function toUSD(
|
|
350
|
+
address token,
|
|
351
|
+
uint256 amount
|
|
352
|
+
) external view returns (uint256 usdValue) {
|
|
353
|
+
if (amount == 0) return 0;
|
|
354
|
+
uint8 decimals = IERC20Metadata(token).decimals();
|
|
355
|
+
uint256 normalized = (amount * Constants.WAD) / (10 ** decimals);
|
|
356
|
+
uint256 price = _getPrice(token); // 18 decimals
|
|
357
|
+
usdValue = (normalized * price) / Constants.WAD;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @notice Converts a USD amount (18 decimals) into token units
|
|
362
|
+
* @param token Token address
|
|
363
|
+
* @param usdAmount USD value (18 decimals)
|
|
364
|
+
* @return tokenAmount Equivalent in token units
|
|
365
|
+
*/
|
|
366
|
+
function fromUSD(
|
|
367
|
+
address token,
|
|
368
|
+
uint256 usdAmount
|
|
369
|
+
) external view returns (uint256 tokenAmount) {
|
|
370
|
+
if (usdAmount == 0) return 0;
|
|
371
|
+
uint8 decimals = IERC20Metadata(token).decimals();
|
|
372
|
+
uint256 price = _getPrice(token); // 18 decimals
|
|
373
|
+
uint256 normalized = (usdAmount * Constants.WAD) / price;
|
|
374
|
+
tokenAmount = (normalized * (10 ** decimals)) / Constants.WAD;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// -----------------------------------------------------------------------
|
|
378
|
+
// Internal Helpers
|
|
379
|
+
// -----------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @notice Returns the cached price for a token (1e18 precision).
|
|
383
|
+
* @dev Reverts if no valid price or if staleness > stalePeriod.
|
|
384
|
+
* @param token Asset token address.
|
|
385
|
+
* @return price Cached price in 1e18 precision.
|
|
386
|
+
*/
|
|
387
|
+
function _getPrice(address token) internal view returns (uint256) {
|
|
388
|
+
if (token == address(0)) revert ZeroAddress("Oracle:getPrice", "token");
|
|
389
|
+
LastValidPrice memory lv = lastValidPrice[token];
|
|
390
|
+
if (lv.price == 0) revert NoFallbackPrice(token);
|
|
391
|
+
if (!this.isPriceFresh(token))
|
|
392
|
+
revert StalePrice(token, lv.timestamp, block.timestamp);
|
|
393
|
+
return lv.price;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* @notice Resolves the fair price of an ERC4626 vault share.
|
|
397
|
+
* @param vault ERC4626 vault token address.
|
|
398
|
+
* @param underlying Underlying asset used for valuation.
|
|
399
|
+
* @return price Vault share price (1e18 precision).
|
|
400
|
+
* @dev Derives price via convertToAssets() * underlying price.
|
|
401
|
+
* Ensures vault rate lies within acceptable bounds.
|
|
402
|
+
*/
|
|
403
|
+
function _getVaultPrice(
|
|
404
|
+
address vault,
|
|
405
|
+
address underlying
|
|
406
|
+
) internal checkRecursion returns (uint256 price) {
|
|
407
|
+
uint8 shareDecimals = IERC20Metadata(vault).decimals();
|
|
408
|
+
uint8 underlyingDecimals = IERC20Metadata(underlying).decimals();
|
|
409
|
+
|
|
410
|
+
uint256 oneShare = 10 ** shareDecimals;
|
|
411
|
+
uint256 assetsPerShare = IERC4626(vault).convertToAssets(oneShare);
|
|
412
|
+
if (assetsPerShare == 0 || assetsPerShare > 1e36)
|
|
413
|
+
revert InvalidVaultExchangeRate(vault, assetsPerShare);
|
|
414
|
+
|
|
415
|
+
uint256 scaledAssets = (assetsPerShare * Constants.WAD) /
|
|
416
|
+
(10 ** underlyingDecimals);
|
|
417
|
+
uint256 scaledShare = (oneShare * Constants.WAD) /
|
|
418
|
+
(10 ** shareDecimals);
|
|
419
|
+
uint256 rate = (scaledAssets * Constants.WAD) / scaledShare;
|
|
420
|
+
|
|
421
|
+
VaultRateBounds memory bounds = vaultRateBounds[vault];
|
|
422
|
+
uint256 minRate = bounds.minRate == 0
|
|
423
|
+
? Constants.ORACLE_MIN_VAULT_RATE_WAD
|
|
424
|
+
: bounds.minRate;
|
|
425
|
+
uint256 maxRate = bounds.maxRate == 0
|
|
426
|
+
? Constants.ORACLE_MAX_VAULT_RATE_WAD
|
|
427
|
+
: bounds.maxRate;
|
|
428
|
+
if (rate > maxRate || rate < minRate)
|
|
429
|
+
revert SuspiciousVaultRate(vault, rate);
|
|
430
|
+
|
|
431
|
+
uint256 underlyingPrice = resolvePrice(underlying);
|
|
432
|
+
return (underlyingPrice * rate) / Constants.WAD;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* @notice Fetches and validates the latest Chainlink feed price.
|
|
437
|
+
* @param feed The address of the Chainlink AggregatorV3 feed.
|
|
438
|
+
* @return price The normalized price (1e18 precision).
|
|
439
|
+
* @return valid Boolean indicating whether the feed result is valid.
|
|
440
|
+
*
|
|
441
|
+
* @dev
|
|
442
|
+
* - Fetches `latestRoundData()` from the feed.
|
|
443
|
+
* - Checks for nonzero `answer` and `updatedAt` values.
|
|
444
|
+
* - Rejects stale prices older than `stalePeriod`.
|
|
445
|
+
* - Normalizes feed decimals to 18.
|
|
446
|
+
* - Ensures price lies within absolute oracle bounds.
|
|
447
|
+
*
|
|
448
|
+
* If any condition fails or the feed call reverts, returns `(0, false)`.
|
|
449
|
+
*/
|
|
450
|
+
function _getChainlinkPrice(
|
|
451
|
+
address feed
|
|
452
|
+
) internal view returns (uint256 price, bool valid) {
|
|
453
|
+
try AggregatorV3Interface(feed).latestRoundData() returns (
|
|
454
|
+
uint80,
|
|
455
|
+
int256 answer,
|
|
456
|
+
uint256,
|
|
457
|
+
uint256 updatedAt,
|
|
458
|
+
uint80
|
|
459
|
+
) {
|
|
460
|
+
if (
|
|
461
|
+
answer <= 0 ||
|
|
462
|
+
updatedAt == 0 ||
|
|
463
|
+
block.timestamp - updatedAt > stalePeriod
|
|
464
|
+
) return (0, false);
|
|
465
|
+
|
|
466
|
+
uint8 feedDecimals = AggregatorV3Interface(feed).decimals();
|
|
467
|
+
uint256 raw = uint256(answer);
|
|
468
|
+
|
|
469
|
+
uint256 clPrice;
|
|
470
|
+
if (feedDecimals == 18) {
|
|
471
|
+
clPrice = raw;
|
|
472
|
+
} else if (feedDecimals < 18) {
|
|
473
|
+
// multiply and scale up safely to 18 decimals
|
|
474
|
+
clPrice = Math.mulDiv(raw, 10 ** (18 - feedDecimals), 1);
|
|
475
|
+
} else {
|
|
476
|
+
// scale down safely to 18 decimals
|
|
477
|
+
clPrice = Math.mulDiv(raw, 1, 10 ** (feedDecimals - 18));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (
|
|
481
|
+
clPrice < Constants.ORACLE_MIN_ABSOLUTE_PRICE_WAD ||
|
|
482
|
+
clPrice > Constants.ORACLE_MAX_ABSOLUTE_PRICE_WAD
|
|
483
|
+
) return (0, false);
|
|
484
|
+
|
|
485
|
+
return (clPrice, true);
|
|
486
|
+
} catch {
|
|
487
|
+
return (0, false);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* @notice Resolves a token's price using manual, ERC4626, or Chainlink sources.
|
|
493
|
+
* @param token Token to resolve.
|
|
494
|
+
* @return price Resolved fair price in 1e18 precision.
|
|
495
|
+
* @dev May trigger state updates if underlying vaults are resolved.
|
|
496
|
+
*/
|
|
497
|
+
function resolvePrice(address token) public returns (uint256 price) {
|
|
498
|
+
if (isManual[token]) return manualPrices[token];
|
|
499
|
+
|
|
500
|
+
address underlying = erc4626Underlying[token];
|
|
501
|
+
if (underlying != address(0)) return _getVaultPrice(token, underlying);
|
|
502
|
+
|
|
503
|
+
address feed = chainlinkFeeds[token];
|
|
504
|
+
if (feed == address(0)) revert NoPriceFeed(token);
|
|
505
|
+
|
|
506
|
+
(uint256 clPrice, bool valid) = _getChainlinkPrice(feed);
|
|
507
|
+
if (valid) return clPrice;
|
|
508
|
+
|
|
509
|
+
LastValidPrice memory lv = lastValidPrice[token];
|
|
510
|
+
if (lv.price == 0) revert NoFallbackPrice(token);
|
|
511
|
+
if (block.timestamp - lv.timestamp > fallbackStalePeriod)
|
|
512
|
+
revert StaleFallback(token);
|
|
513
|
+
|
|
514
|
+
emit OracleFallbackUsed(
|
|
515
|
+
token,
|
|
516
|
+
lv.price,
|
|
517
|
+
block.timestamp,
|
|
518
|
+
"Chainlink failure"
|
|
519
|
+
);
|
|
520
|
+
return lv.price;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* @notice Fetches, validates, and stores the latest token price.
|
|
525
|
+
* @param token Token address.
|
|
526
|
+
* @return price Resolved and persisted price (1e18 precision).
|
|
527
|
+
* @dev Updates lastValidPrice mapping and emits event.
|
|
528
|
+
*/
|
|
529
|
+
function _fetchAndUpdatePrice(
|
|
530
|
+
address token
|
|
531
|
+
) internal returns (uint256 price) {
|
|
532
|
+
price = resolvePrice(token);
|
|
533
|
+
if (
|
|
534
|
+
price < Constants.ORACLE_MIN_ABSOLUTE_PRICE_WAD ||
|
|
535
|
+
price > Constants.ORACLE_MAX_ABSOLUTE_PRICE_WAD
|
|
536
|
+
) revert InvalidOraclePrice(token, address(0));
|
|
537
|
+
|
|
538
|
+
lastValidPrice[token] = LastValidPrice(price, block.timestamp);
|
|
539
|
+
emit LastValidPriceUpdated(token, price, block.timestamp);
|
|
540
|
+
return price;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* @notice Adds a token to the supported tokens list if not already present.
|
|
545
|
+
* @dev
|
|
546
|
+
* - Internal helper to register newly configured assets.
|
|
547
|
+
* - Ensures uniqueness via `isSupported` mapping.
|
|
548
|
+
* - Called from setup functions such as `setChainlinkFeed` or `setERC4626Vault`.
|
|
549
|
+
* @param token The address of the token to add.
|
|
550
|
+
*
|
|
551
|
+
* Emits a {TokenSupportAdded} event upon successful addition.
|
|
552
|
+
*/
|
|
553
|
+
function _addSupportedToken(address token) internal {
|
|
554
|
+
if (!isSupported[token]) {
|
|
555
|
+
supportedTokens.push(token);
|
|
556
|
+
isSupported[token] = true;
|
|
557
|
+
emit TokenSupportAdded(token);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// ───────────── Errors ─────────────
|
|
2
|
+
error ZeroAddress(string functionName, string field);
|
|
3
|
+
error InvalidManualPrice(address token, uint256 price);
|
|
4
|
+
error InvalidStalePeriod(uint256 period);
|
|
5
|
+
error InvalidVaultBounds(address vault, uint256 minRate, uint256 maxRate);
|
|
6
|
+
error InvalidFeedContract(address feed);
|
|
7
|
+
error InvalidFeedDecimals(address feed, uint8 decimals);
|
|
8
|
+
error InvalidERC4626Vault(address vault);
|
|
9
|
+
error InvalidVaultExchangeRate(address vault, uint256 rate);
|
|
10
|
+
error InvalidOraclePrice(address token, address feed);
|
|
11
|
+
error NoPriceFeed(address token);
|
|
12
|
+
error StalePrice(address token, uint256 updatedAt, uint256 currentTime);
|
|
13
|
+
error StaleFallback(address token);
|
|
14
|
+
error NoFallbackPrice(address token);
|
|
15
|
+
error SuspiciousVaultRate(address vault, uint256 rate);
|
|
16
|
+
error RecursiveResolution(address token);
|
|
17
|
+
error OraclePaused(address oracle, uint256 timestamp);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.20;
|
|
3
|
+
|
|
4
|
+
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
|
|
5
|
+
|
|
6
|
+
contract MockAggregatorV3 is AggregatorV3Interface {
|
|
7
|
+
int256 private answer;
|
|
8
|
+
uint8 private decimals_;
|
|
9
|
+
uint256 private updatedAt_;
|
|
10
|
+
|
|
11
|
+
constructor(int256 _initialAnswer, uint8 _decimals) {
|
|
12
|
+
answer = _initialAnswer;
|
|
13
|
+
decimals_ = _decimals;
|
|
14
|
+
updatedAt_ = block.timestamp;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function decimals() external view override returns (uint8) {
|
|
18
|
+
return decimals_;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function description() external pure override returns (string memory) {
|
|
22
|
+
return "Mock Chainlink Aggregator";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function version() external pure override returns (uint256) {
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function latestRoundData()
|
|
30
|
+
external
|
|
31
|
+
view
|
|
32
|
+
override
|
|
33
|
+
returns (uint80 roundId, int256 answer_, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
|
|
34
|
+
{
|
|
35
|
+
return (0, answer, updatedAt_, updatedAt_, 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getRoundData(uint80) external view override returns (uint80, int256, uint256, uint256, uint80) {
|
|
39
|
+
return (0, answer, updatedAt_, updatedAt_, 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// @notice manually update the price
|
|
43
|
+
function updateAnswer(int256 _newAnswer) external {
|
|
44
|
+
answer = _newAnswer;
|
|
45
|
+
updatedAt_ = block.timestamp;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// @notice manually set a custom updatedAt for staleness testing
|
|
49
|
+
function setUpdatedAt(uint256 _timestamp) external {
|
|
50
|
+
updatedAt_ = _timestamp;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getUpdatedAt() external view returns (uint256) {
|
|
54
|
+
return updatedAt_;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// MockERC20 for testing
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
pragma solidity ^0.8.20;
|
|
4
|
+
|
|
5
|
+
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
6
|
+
|
|
7
|
+
contract MockERC20 is ERC20 {
|
|
8
|
+
uint8 private immutable _decimals;
|
|
9
|
+
|
|
10
|
+
constructor(string memory name_, string memory symbol_, uint8 decimals_, uint256 initialSupply)
|
|
11
|
+
ERC20(name_, symbol_)
|
|
12
|
+
{
|
|
13
|
+
_decimals = decimals_;
|
|
14
|
+
_mint(msg.sender, initialSupply);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function decimals() public view override returns (uint8) {
|
|
18
|
+
return _decimals;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mint(address to, uint256 amount) external {
|
|
22
|
+
_mint(to, amount);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function burn(address from, uint256 amount) external {
|
|
26
|
+
_burn(from, amount);
|
|
27
|
+
}
|
|
28
|
+
}
|