@ubk-labs/ubk-oracle 0.1.9 → 0.2.0

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.
@@ -23,10 +23,13 @@ library UBKOracleConstants {
23
23
  uint256 public constant ORACLE_MIN_VAULT_RATE_WAD = 0.2e18; // 0.2x (20%)
24
24
  uint256 public constant ORACLE_MAX_VAULT_RATE_WAD = 3e18; // 3x (300%)
25
25
 
26
+ uint256 public constant ORACLE_MAX_VAULT_ASSETS_PER_SHARE = 1e36;
27
+
26
28
  // -----------------------------------------------------------------------
27
29
  // Oracle Staleness Periods
28
30
  // -----------------------------------------------------------------------
29
31
  uint256 public constant ORACLE_MIN_STALE_PERIOD = 1 hours;
32
+ uint256 public constant ORACLE_DEFAULT_STALE_PERIOD = 24 hours;
30
33
  uint256 public constant ORACLE_MAX_STALE_PERIOD = 48 hours;
31
34
 
32
35
  uint256 public constant ORACLE_DEFAULT_STALE_FALLBACK_MULTIPLIER = 2; //2x stale
@@ -14,11 +14,11 @@ import "../errors/UBKOracleErrors.sol";
14
14
  import "../constants/UBKOracleConstants.sol";
15
15
 
16
16
  /**
17
- * @title Oracle
18
- * @notice This contract is an implementation of the IUBKOracle interface.
17
+ * @title UBKOracle
18
+ * @notice This contract is the canonical implementation of the IUBKOracle interface.
19
19
  *
20
20
  * @dev
21
- * The oracle computes normalized 1e18 prices for all supported assets,
21
+ * The contract computes normalized 1e18 (WAD) prices for all supported assets,
22
22
  * combining manual overrides, ERC4626 vault conversions, and Chainlink feeds.
23
23
  *
24
24
  * === PRICE RESOLUTION ORDER ===
@@ -26,6 +26,12 @@ import "../constants/UBKOracleConstants.sol";
26
26
  * 2. ERC4626 vault-derived (convertToAssets * underlying price)
27
27
  * 3️. Chainlink feed (normalized to 1e18)
28
28
  *
29
+ * === DECIMAL INVARIANTS ===
30
+ * - Only explicitly registered tokens may be priced or converted.
31
+ * - Token decimals are validated once at registration and MUST lie within [6, 18].
32
+ * - Validated decimals are cached immutably and never queried from token contracts at runtime.
33
+ * - All internal price and value computations assume and rely on these enforced bounds.
34
+ * - ERC-4626 vaults are only supported when vault.decimals() == underlying.decimals(), preventing cross-decimal normalization errors in vault pricing.
29
35
  * === SAFETY FEATURES ===
30
36
  * - Recursion guard (nested ERC4626 depth ≤ 5)
31
37
  * - Chainlink stale-period enforcement
@@ -40,6 +46,7 @@ import "../constants/UBKOracleConstants.sol";
40
46
  * - UI / Subgraphs can use `isPriceFresh()` and `getPriceAge()` for safety checks.
41
47
  *
42
48
  */
49
+
43
50
  contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
44
51
  // -----------------------------------------------------------------------
45
52
  // Storage
@@ -80,6 +87,9 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
80
87
  /// @notice Mapping to track supported tokens.
81
88
  mapping(address => bool) public isSupported;
82
89
 
90
+ /// @notice Cache that tracks decimals for supported tokens.
91
+ mapping(address => uint8) internal tokenDecimals;
92
+
83
93
  // -----------------------------------------------------------------------
84
94
  // Constructor & Modifiers
85
95
  // -----------------------------------------------------------------------
@@ -106,6 +116,12 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
106
116
  _recursionDepth--;
107
117
  }
108
118
 
119
+ /// @notice Does not allow execution of the decorated function if token is unsupported.
120
+ modifier supportedTokenOnly(address token) {
121
+ if (!isSupported[token]) revert TokenNotSupported(token);
122
+ _;
123
+ }
124
+
109
125
  // -----------------------------------------------------------------------
110
126
  // Admin / Configuration
111
127
  // -----------------------------------------------------------------------
@@ -121,21 +137,11 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
121
137
  }
122
138
 
123
139
  /**
124
- * @notice Sets the maximum time (in seconds) a Chainlink feed value is valid.
140
+ * @notice External facing admin function that sets the maximum time (in seconds) a Chainlink feed value is valid.
125
141
  * @param period The new fallback staleness threshold. Must be 1.5x of stalePeriod of the same token.
126
- * @dev Must lie within [UBKOracleConstants.ORACLE_MIN_STALE_PERIOD, UBKOracleConstants.ORACLE_MAX_STALE_PERIOD].
127
142
  */
128
143
  function setStalePeriod(address token, uint256 period) external onlyOwner {
129
- if (
130
- period < UBKOracleConstants.ORACLE_MIN_STALE_PERIOD ||
131
- period > UBKOracleConstants.ORACLE_MAX_STALE_PERIOD
132
- ) revert InvalidStalePeriod(period);
133
- stalePeriod[token] = period;
134
- fallbackStalePeriod[token] =
135
- UBKOracleConstants.ORACLE_DEFAULT_STALE_FALLBACK_MULTIPLIER *
136
- period; //Minimum fallback period should be 2x stalePeriod[token].
137
- emit StalePeriodUpdated(token, stalePeriod[token]);
138
- emit FallbackStalePeriodUpdated(token, fallbackStalePeriod[token]);
144
+ _setStalePeriod(token, period);
139
145
  }
140
146
 
141
147
  /**
@@ -170,8 +176,11 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
170
176
  ) external onlyOwner {
171
177
  if (vault == address(0))
172
178
  revert ZeroAddress("UBKOracle::setVaultRateBounds", "vault");
173
- if (minRate == 0 || maxRate <= minRate || maxRate > 100e18)
174
- revert InvalidVaultBounds(vault, minRate, maxRate);
179
+ if (
180
+ minRate == 0 ||
181
+ maxRate <= minRate ||
182
+ maxRate > UBKOracleConstants.ORACLE_MAX_VAULT_RATE_WAD
183
+ ) revert InvalidVaultBounds(vault, minRate, maxRate);
175
184
 
176
185
  vaultRateBounds[vault] = VaultRateBounds(minRate, maxRate);
177
186
  emit VaultRateBoundsSet(vault, minRate, maxRate);
@@ -190,6 +199,10 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
190
199
  ) external onlyOwner whenNotPaused {
191
200
  if (token == address(0))
192
201
  revert ZeroAddress("UBKOracle::setManualPrice", "token");
202
+ if (!isSupported[token]) {
203
+ _addSupportedToken(token);
204
+ }
205
+
193
206
  if (
194
207
  price < UBKOracleConstants.ORACLE_MIN_ABSOLUTE_PRICE_WAD ||
195
208
  price > UBKOracleConstants.ORACLE_MAX_ABSOLUTE_PRICE_WAD
@@ -224,9 +237,9 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
224
237
  * @notice Disables manual pricing for a token.
225
238
  * @param token The token address.
226
239
  */
227
- function disableManualPrice(address token) external onlyOwner {
228
- if (token == address(0))
229
- revert ZeroAddress("UBKOracle::disableManualPrice", "token");
240
+ function disableManualPrice(
241
+ address token
242
+ ) external onlyOwner supportedTokenOnly(token) {
230
243
  isManual[token] = false;
231
244
  emit ManualModeEnabled(token, false);
232
245
  }
@@ -265,8 +278,7 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
265
278
  * @notice Registers an ERC4626 vault and its underlying asset.
266
279
  * @param vault ERC4626 vault token.
267
280
  * @param underlying Reference underlying asset used for pricing.
268
- * @dev Does not enforce .asset() == underlying for flexibility
269
- * (e.g., sUSDe → USDC pegging for depeg protection).
281
+ * @dev Stricly enforces == underlying for flexibility
270
282
  */
271
283
  function setERC4626Vault(
272
284
  address vault,
@@ -299,8 +311,6 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
299
311
  function fetchAndUpdatePrice(
300
312
  address token
301
313
  ) external whenNotPaused returns (uint256) {
302
- if (token == address(0))
303
- revert ZeroAddress("UBKOracle::fetchAndUpdatePrice", "token");
304
314
  return _fetchAndUpdatePrice(token);
305
315
  }
306
316
 
@@ -373,17 +383,28 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
373
383
  // -----------------------------------------------------------------------
374
384
 
375
385
  /**
376
- * @notice Converts a token amount into USD (18 decimals)
377
- * @param token Token address
378
- * @param amount Raw token amount (native decimals)
379
- * @return usdValue USD value (18 decimals)
386
+ * @notice Converts a supported token amount into USD (18 decimals).
387
+ * @dev
388
+ * Requirements:
389
+ * - `token` MUST be registered as supported by the oracle.
390
+ * - Token decimals are cached at registration and guaranteed to be within [6,18].
391
+ * - Reverts if the token is not supported or if the cached price is stale or unavailable.
392
+ *
393
+ * Behavior:
394
+ * - Returns 0 if `amount == 0` without reading oracle price.
395
+ * - Uses cached decimals and cached price for deterministic gas usage.
396
+ *
397
+ * @param token Supported token address.
398
+ * @param amount Raw token amount in native decimals.
399
+ * @return usdValue USD value with 18 decimals of precision.
380
400
  */
401
+
381
402
  function toUSD(
382
403
  address token,
383
404
  uint256 amount
384
405
  ) external view returns (uint256 usdValue) {
385
406
  if (amount == 0) return 0;
386
- uint8 decimals = IERC20Metadata(token).decimals();
407
+ uint8 decimals = tokenDecimals[token];
387
408
  uint256 normalized = (amount * UBKOracleConstants.WAD) /
388
409
  (10 ** decimals);
389
410
  uint256 price = _getPrice(token); // 18 decimals
@@ -391,17 +412,28 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
391
412
  }
392
413
 
393
414
  /**
394
- * @notice Converts a USD amount (18 decimals) into token units
395
- * @param token Token address
396
- * @param usdAmount USD value (18 decimals)
397
- * @return tokenAmount Equivalent in token units
415
+ * @notice Converts a USD amount (18 decimals) into units of a supported token.
416
+ * @dev
417
+ * Requirements:
418
+ * - `token` MUST be registered as supported by the oracle.
419
+ * - Token decimals are cached at registration and guaranteed to be within [6,18].
420
+ * - Reverts if the token is not supported or if the cached price is stale or unavailable.
421
+ *
422
+ * Behavior:
423
+ * - Returns 0 if `usdAmount == 0` without reading oracle price.
424
+ * - Uses cached decimals and cached price for deterministic gas usage.
425
+ *
426
+ * @param token Supported token address.
427
+ * @param usdAmount USD value with 18 decimals of precision.
428
+ * @return tokenAmount Equivalent amount in token native decimals.
398
429
  */
430
+
399
431
  function fromUSD(
400
432
  address token,
401
433
  uint256 usdAmount
402
434
  ) external view returns (uint256 tokenAmount) {
403
435
  if (usdAmount == 0) return 0;
404
- uint8 decimals = IERC20Metadata(token).decimals();
436
+ uint8 decimals = tokenDecimals[token];
405
437
  uint256 price = _getPrice(token); // 18 decimals
406
438
  uint256 normalized = (usdAmount * UBKOracleConstants.WAD) / price;
407
439
  tokenAmount = (normalized * (10 ** decimals)) / UBKOracleConstants.WAD;
@@ -412,14 +444,35 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
412
444
  // -----------------------------------------------------------------------
413
445
 
414
446
  /**
415
- * @notice Returns the cached price for a token (1e18 precision).
416
- * @dev Reverts if no valid price or if staleness > stalePeriod.
447
+ * @notice Returns the cached USD price for a token.
448
+ *
449
+ * @dev
450
+ * This function is the canonical read path for all pricing operations
451
+ * (including `toUSD` and `fromUSD`). It intentionally performs **no explicit
452
+ * supported-token check** in order to minimize gas usage on hot, view-only paths.
453
+ *
454
+ * Reverts in the following cases:
455
+ *
456
+ * 1. `NoFallbackPrice(token)`
457
+ * - The token has **never had a successfully resolved and cached price**, OR
458
+ * - The token is **unsupported** and therefore has no initialized pricing state.
459
+ *
460
+ * 2. `StalePrice(token, lastUpdated, now)`
461
+ * - A cached price exists, but its age exceeds `stalePeriod[token]`.
462
+ *
463
+ * Supported tokens are guaranteed to have:
464
+ * - non-zero addresses,
465
+ * - validated and cached decimals within [6, 18],
466
+ * - pricing state initialized only via explicit configuration paths
467
+ * (e.g. Chainlink feeds, ERC4626 vaults, or manual pricing).
468
+ *
469
+ * As a result, callers of `toUSD` and `fromUSD` can safely rely on this function
470
+ * for correctness while avoiding redundant SLOADs or branching.
471
+ *
417
472
  * @param token Asset token address.
418
- * @return price Cached price in 1e18 precision.
473
+ * @return price Cached token price in 1e18 precision.
419
474
  */
420
- function _getPrice(address token) internal view returns (uint256) {
421
- if (token == address(0))
422
- revert ZeroAddress("UBKOracle::getPrice", "token");
475
+ function _getPrice(address token) internal view returns (uint256 price) {
423
476
  LastValidPrice memory lv = lastValidPrice[token];
424
477
  if (lv.price == 0) revert NoFallbackPrice(token);
425
478
  if (!_isPriceFresh(token))
@@ -446,17 +499,20 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
446
499
  * @dev Derives price via convertToAssets() * underlying price.
447
500
  * Ensures vault rate lies within acceptable bounds.
448
501
  */
449
- function _getVaultPrice(
502
+ function _resolveVaultPrice(
450
503
  address vault,
451
504
  address underlying
452
505
  ) internal checkRecursion returns (uint256 price) {
453
- uint8 shareDecimals = IERC20Metadata(vault).decimals();
454
- uint8 underlyingDecimals = IERC20Metadata(underlying).decimals();
506
+ uint8 shareDecimals = tokenDecimals[vault];
507
+ uint8 underlyingDecimals = tokenDecimals[underlying];
455
508
 
456
509
  uint256 oneShare = 10 ** shareDecimals;
457
510
  uint256 assetsPerShare = IERC4626(vault).convertToAssets(oneShare);
458
- if (assetsPerShare == 0 || assetsPerShare > 1e36)
459
- revert InvalidVaultExchangeRate(vault, assetsPerShare);
511
+ if (
512
+ assetsPerShare == 0 ||
513
+ assetsPerShare >
514
+ UBKOracleConstants.ORACLE_MAX_VAULT_ASSETS_PER_SHARE
515
+ ) revert InvalidVaultExchangeRate(vault, assetsPerShare);
460
516
 
461
517
  uint256 scaledAssets = (assetsPerShare * UBKOracleConstants.WAD) /
462
518
  (10 ** underlyingDecimals);
@@ -541,11 +597,14 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
541
597
  * @return price Resolved fair price in 1e18 precision.
542
598
  * @dev May trigger state updates if underlying vaults are resolved.
543
599
  */
544
- function _resolvePrice(address token) internal returns (uint256 price) {
600
+ function _resolvePrice(
601
+ address token
602
+ ) internal supportedTokenOnly(token) returns (uint256 price) {
545
603
  if (isManual[token]) return manualPrices[token];
546
604
 
547
605
  address underlying = erc4626Underlying[token];
548
- if (underlying != address(0)) return _getVaultPrice(token, underlying);
606
+ if (underlying != address(0))
607
+ return _resolveVaultPrice(token, underlying);
549
608
 
550
609
  address feed = chainlinkFeeds[token];
551
610
  if (feed == address(0)) revert NoPriceFeed(token);
@@ -580,7 +639,7 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
580
639
  if (
581
640
  price < UBKOracleConstants.ORACLE_MIN_ABSOLUTE_PRICE_WAD ||
582
641
  price > UBKOracleConstants.ORACLE_MAX_ABSOLUTE_PRICE_WAD
583
- ) revert InvalidOraclePrice(token, address(0));
642
+ ) revert InvalidOraclePrice(token, price);
584
643
 
585
644
  lastValidPrice[token] = LastValidPrice(price, block.timestamp);
586
645
  emit LastValidPriceUpdated(token, price, block.timestamp);
@@ -598,9 +657,18 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
598
657
  * Emits a {TokenSupportAdded} event upon successful addition.
599
658
  */
600
659
  function _addSupportedToken(address token) internal {
660
+ if (token == address(0)) {
661
+ revert ZeroAddress("UBKOracle::_addSupportedToken", "token");
662
+ }
663
+
601
664
  if (!isSupported[token]) {
602
665
  supportedTokens.push(token);
603
666
  isSupported[token] = true;
667
+ tokenDecimals[token] = _validateTokenDecimals(token);
668
+ _setStalePeriod(
669
+ token,
670
+ UBKOracleConstants.ORACLE_DEFAULT_STALE_PERIOD
671
+ );
604
672
  emit TokenSupportAdded(token);
605
673
  }
606
674
  }
@@ -652,4 +720,22 @@ contract UBKOracle is IUBKOracle, UBKDecimalsBounded, Ownable {
652
720
  );
653
721
  }
654
722
  }
723
+
724
+ /**
725
+ * @notice Sets the maximum time (in seconds) a Chainlink feed value is valid.
726
+ * @param period The new fallback staleness threshold. Must be 1.5x of stalePeriod of the same token.
727
+ * @dev Must lie within [UBKOracleConstants.ORACLE_MIN_STALE_PERIOD, UBKOracleConstants.ORACLE_MAX_STALE_PERIOD].
728
+ */
729
+ function _setStalePeriod(address token, uint256 period) internal {
730
+ if (
731
+ period < UBKOracleConstants.ORACLE_MIN_STALE_PERIOD ||
732
+ period > UBKOracleConstants.ORACLE_MAX_STALE_PERIOD
733
+ ) revert InvalidStalePeriod(period);
734
+ stalePeriod[token] = period;
735
+ fallbackStalePeriod[token] =
736
+ UBKOracleConstants.ORACLE_DEFAULT_STALE_FALLBACK_MULTIPLIER *
737
+ period; //Minimum fallback period should be 2x stalePeriod[token].
738
+ emit StalePeriodUpdated(token, stalePeriod[token]);
739
+ emit FallbackStalePeriodUpdated(token, fallbackStalePeriod[token]);
740
+ }
655
741
  }
@@ -4,19 +4,24 @@ pragma solidity ^0.8.20;
4
4
  import "@ubk-labs/ubk-commons/contracts/errors/UBKErrors.sol";
5
5
 
6
6
  // ───────────── Errors ─────────────
7
+ error ERC4626DecimalsMismatch(
8
+ string functionName,
9
+ address vault,
10
+ address underlying
11
+ );
12
+ error InvalidERC4626Vault(address vault);
13
+ error InvalidFeedContract(address feed);
14
+ error InvalidFeedDecimals(address feed, uint8 decimals);
7
15
  error InvalidManualPrice(address token, uint256 price);
16
+ error InvalidOraclePrice(address token, uint256 price);
8
17
  error InvalidStalePeriod(uint256 period);
9
18
  error InvalidVaultBounds(address vault, uint256 minRate, uint256 maxRate);
10
- error InvalidFeedContract(address feed);
11
- error InvalidFeedDecimals(address feed, uint8 decimals);
12
- error InvalidERC4626Vault(address vault);
13
19
  error InvalidVaultExchangeRate(address vault, uint256 rate);
14
- error InvalidOraclePrice(address token, address feed);
20
+ error NoFallbackPrice(address token);
15
21
  error NoPriceFeed(address token);
16
- error StalePrice(address token, uint256 updatedAt, uint256 currentTime);
22
+ error OraclePaused(address oracle, uint256 timestamp);
23
+ error RecursiveResolution(address token);
17
24
  error StaleFallback(address token);
18
- error NoFallbackPrice(address token);
25
+ error StalePrice(address token, uint256 updatedAt, uint256 currentTime);
19
26
  error SuspiciousVaultRate(address vault, uint256 rate);
20
- error RecursiveResolution(address token);
21
- error OraclePaused(address oracle, uint256 timestamp);
22
- error ERC4626DecimalsMismatch(string functionName, address vault, address underlying);
27
+ error TokenNotSupported(address token);
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@ubk-labs/ubk-oracle",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "Oracle supporting ERC-20 and ERC-4626 assets for use in decentralized financial applications.",
5
5
  "scripts": {
6
6
  "build": "npx hardhat compile",
7
7
  "test": "npx hardhat test",
8
8
  "coverage": "hardhat coverage",
9
9
  "lint": "prettier --check .",
10
- "format": "prettier --write ."
10
+ "format": "prettier --write .",
11
+ "deploy:mainnet": "npx hardhat run scripts/deploy.js --network mainnet"
11
12
  },
12
13
  "keywords": [
13
14
  "DeFi",