From 05db48a7fd949b3904e6b54023a5e616212af880 Mon Sep 17 00:00:00 2001 From: cryptoharry <110295612+cryptoharry@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:23:37 +0400 Subject: [PATCH 1/3] Keep FiRM pricing available during oracle update outages --- .github/workflows/dual-feed-tests.yml | 21 +++ docs/dual-feed-price-feeds.md | 140 +++++++++++++++ src/feeds/DualFeedPriceFeed.sol | 159 +++++++++++++++++ src/feeds/RedstoneStoredPriceFeed.sol | 50 ++++++ test/DualFeedComposition.t.sol | 70 ++++++++ test/DualFeedMarket.t.sol | 116 +++++++++++++ test/DualFeedPriceFeed.t.sol | 241 ++++++++++++++++++++++++++ test/RedstoneStoredPriceFeed.t.sol | 96 ++++++++++ test/mocks/DualFeedMocks.sol | 130 ++++++++++++++ 9 files changed, 1023 insertions(+) create mode 100644 .github/workflows/dual-feed-tests.yml create mode 100644 docs/dual-feed-price-feeds.md create mode 100644 src/feeds/DualFeedPriceFeed.sol create mode 100644 src/feeds/RedstoneStoredPriceFeed.sol create mode 100644 test/DualFeedComposition.t.sol create mode 100644 test/DualFeedMarket.t.sol create mode 100644 test/DualFeedPriceFeed.t.sol create mode 100644 test/RedstoneStoredPriceFeed.t.sol create mode 100644 test/mocks/DualFeedMocks.sol diff --git a/.github/workflows/dual-feed-tests.yml b/.github/workflows/dual-feed-tests.yml new file mode 100644 index 00000000..fad1c9ba --- /dev/null +++ b/.github/workflows/dual-feed-tests.yml @@ -0,0 +1,21 @@ +name: Dual-feed tests + +on: + pull_request: + branches: [dev] + push: + branches: [dev] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: foundry-rs/foundry-toolchain@v1 + - name: Run deterministic oracle and market tests + run: forge test --match-contract '^(DualFeed|RedstoneStored|BorrowControllerTest$|OracleTest$|MarketTest$)' --evm-version shanghai -vv diff --git a/docs/dual-feed-price-feeds.md b/docs/dual-feed-price-feeds.md new file mode 100644 index 00000000..ac3810bc --- /dev/null +++ b/docs/dual-feed-price-feeds.md @@ -0,0 +1,140 @@ +# Dual-source USD feeds + +FiRM's current ETH/USD and BTC/USD `ChainlinkBasePriceFeed` instances select +RedStone when Chainlink is stale. RedStone's current read interface reverts when +its last publication reaches 30 hours. If Chainlink remains stale, that revert +propagates through WETH, INV and WBTC pricing and prevents liquidations. + +This change provides replacement components. It does not deploy contracts or +change any live market, oracle registration or controller threshold. + +## Components and selection + +`RedstoneStoredPriceFeed` reads `getLastUpdateDetailsUnsafe(bytes32)` on the +existing RedStone multi-feed adapter. Updates continue to be validated by +RedStone before storage; the replacement removes only the read-time expiry. +It rejects missing/zero values, values exceeding `int256`, missing timestamps, +future publication times, and signed data more than one minute ahead of its +publication. That forward tolerance matches the currently deployed adapter. +Its eight-decimal output is specific to the ETH/USD and BTC/USD records. + +The reported timestamp is the earlier of the signed data timestamp (milliseconds +converted to seconds) and the publication timestamp. A delayed publication +cannot make an old observation appear fresh. Age alone does not cause a revert. + +`DualFeedPriceFeed` accepts a Chainlink primary and a Chainlink-compatible +fallback, and normalizes both to 18 decimals. Both use the same configurable +freshness target: + +1. Use a valid, fresh Chainlink quote, including at the heartbeat boundary. +2. Otherwise use a valid, fresh fallback quote. +3. Otherwise use the newer valid stale quote, preferring Chainlink on a tie. +4. Revert with `NoValidPrice()` if neither source is usable. + +Each result retains the selected quote's metadata and timestamp. The selector +never marks old data fresh or replaces its timestamp with zero. Nonpositive +prices, zero/future update timestamps, malformed responses, normalization +overflow, and prices rounding down to zero are not candidates. Primary +validation preserves the existing strict Chainlink `minAnswer`/`maxAnswer` +checks. Failure to read the primary or its bounds invalidates only that source; +a failed fallback does not prevent using a valid stale primary. Consequently, +the primary must support Chainlink's aggregator and bounds interface. + +A healthy primary is read without calling the fallback. There is no local +last-good-price cache. If both providers become unreadable or invalid, this +implementation deliberately does not invent a valuation. + +## Mainnet wiring + +The following configuration was checked at Ethereum block 26,234,376 on +20 October 2026. Recheck sources, proxy implementations and parameters before +deployment. RedStone's adapter is upgradeable; monitor changes to its interface, +decimals, timestamp semantics, data IDs and update validation. + +| Component | Address / identifier | +| --- | --- | +| Chainlink ETH/USD primary | `0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419` | +| Chainlink BTC/USD primary | `0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c` | +| RedStone multi-feed adapter | `0xd72a6ba4a87ddb33e801b3f1c7750b2d0911fc6c` | +| ETH data ID | `bytes32("ETH")`, not a hash | +| BTC data ID | `bytes32("BTC")`, not a hash | +| Existing WBTC/BTC normalized component | `0x435419607191f1c1c6809e01bb5c38311957c6a8` | +| INV/WETH Curve pool | `0xdcd90d866ff9636e5a04768825d05d27b3fb19ec` | +| FiRM pessimistic oracle | `0xabe146cf570fd27ddd985895ce9b138a7110cce8` | + +Deployment and governance preparation: + +1. Deploy an ETH stored-price adapter and a BTC stored-price adapter, using the + data IDs above. Verify the stored records are eight-decimal USD quotes. +2. Deploy ETH/USD and BTC/USD selectors using the respective Chainlink proxies + and stored-price adapters. Set the intended owner and freshness target + explicitly. The replaced USD components currently use 3,660 seconds. +3. Deploy a new `DynamicFeeCurveFeed` for INV with the new ETH/USD selector, + the existing pool, INV token and governance. Preserve the existing `maxFee` + and governance configuration after rechecking them. +4. Deploy a new `ChainlinkBridgeAssetFeed` for WBTC using the existing normalized + WBTC/BTC component and the new BTC/USD selector. Preserve the existing ratio + direction (`bridgeAssetDenominator`). +5. Prepare `Oracle.setFeed` updates: WETH to the ETH/USD selector; INV to its new + Curve feed; WBTC to its new bridge feed. Preserve collateral token decimals + (18, 18 and 8 respectively). Those are token decimals, not feed decimals. +6. Test the complete proposed registrations against existing positions and + pessimistic-oracle cache state before governance execution. + +The existing combining feeds store their component addresses immutably, so +changing the source file cannot update those deployed contracts. Replacement +feed deployments and oracle registration updates are required. Market contracts, +escrows, debts and borrower positions do not need migration for these updates. + +## Borrowing and recovery + +The controller continues to enforce borrowing freshness using the selected +timestamp. At the checked block, INV's borrowing threshold was 3,660 seconds; +WETH and WBTC used 86,460 seconds. The selector's switching target and the +controller's borrowing threshold are separate settings. This PR does not change +them onchain. A WBTC review must consider the different update schedules of its +WBTC/BTC and BTC/USD legs; the composite still reports the older leg's timestamp. + +An old quote keeps pricing callable but does not guarantee accurate or profitable +liquidations. Freshness alone does not establish accuracy either. A fallback is +a second trusted source, not a quorum or independent agreement check. Neither +source protects against every shared market or chain failure. Prolonged outages +still require monitoring and an incident response. + +Recovery restores Chainlink priority, but an already-recorded pessimistic daily +low is not cleared by switching feeds. Its existing lookback still applies. + +## Tests + +Run the deterministic feed and core market suites without an RPC endpoint: + +```sh +forge test --match-contract '^(DualFeed|RedstoneStored|BorrowControllerTest$|OracleTest$|MarketTest$)' --evm-version shanghai +``` + +The dedicated CI job uses Solidity 0.8.20 and the Shanghai EVM target. The +repository-wide compiler configuration is unchanged. The existing full workflow +also runs fork suites that require the `RPC_MAINNET` repository secret; those +suites fail at setup if the RPC URL is missing. + +Tests cover source failures and malformed responses, primary bounds, decimal +normalization, freshness boundaries, newest-stale selection and metadata, +ownership, RedStone expiry and observation age, INV's Curve/fee composition, +WBTC's older-component timestamp, controller borrowing rejection, settlement +through the real local Market/Oracle/DBR/escrow contracts, and recovery with an +existing daily-low cache. Upstream price publishers and the Curve pool are mocks +in these local tests. These are not mainnet transaction-fork tests or a completed +deployment/governance simulation. + +Validation completed for this change: 42 new tests (including 256 fuzz cases) +and 95 existing Market, Oracle and BorrowController tests passed with Solidity +0.8.20 and the Shanghai EVM target. + +Ten read-only Multicall3 control/replacement scenarios also passed at Ethereum +block 26,234,376, using injected replacement code and hypothetical source quotes +with native RedStone, Curve, combining-feed and controller contracts. These +checks covered both-stale selection, either provider being unreadable, and both +being unreadable. They were state overrides, not a live outage or a complete +deployment/governance simulation. + +Tracking: https://maestro.inverse.finance/boards/2/tasks/419 diff --git a/src/feeds/DualFeedPriceFeed.sol b/src/feeds/DualFeedPriceFeed.sol new file mode 100644 index 00000000..dbed69d6 --- /dev/null +++ b/src/feeds/DualFeedPriceFeed.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IChainlinkFeed} from "src/interfaces/IChainlinkFeed.sol"; + +interface IDualFeedAggregator { + function minAnswer() external view returns (int192); + function maxAnswer() external view returns (int192); +} + +/// @notice Chainlink-primary price feed with a Chainlink-compatible fallback. +/// @dev Both sources use the same freshness target. Age alone never invalidates +/// a quote: if neither is fresh, the newer valid quote is returned with its age. +contract DualFeedPriceFeed { + error NoValidPrice(); + + struct RoundData { + uint80 roundId; + int256 answer; + uint256 startedAt; + uint256 updatedAt; + uint80 answeredInRound; + bool valid; + } + + IChainlinkFeed public immutable assetToUsd; + IChainlinkFeed public immutable assetToUsdFallback; + uint8 public immutable assetToUsdDecimals; + uint8 public immutable assetToUsdFallbackDecimals; + uint8 public constant decimals = 18; + string public description; + address public owner; + address public pendingOwner; + uint256 public assetToUsdHeartbeat; + + event NewHeartbeat(uint256 heartbeat); + event NewPendingOwner(address pendingOwner); + event NewOwner(address owner); + + modifier onlyOwner() { + require(msg.sender == owner, "Only owner"); + _; + } + + constructor(address _owner, address _assetToUsd, address _fallback, uint256 _heartbeat) { + require(_owner != address(0), "Invalid owner"); + require(_assetToUsd != address(0) && _fallback != address(0), "Invalid source"); + require(_assetToUsd != _fallback, "Identical sources"); + require(_heartbeat > 0, "Invalid heartbeat"); + owner = _owner; + assetToUsd = IChainlinkFeed(_assetToUsd); + assetToUsdFallback = IChainlinkFeed(_fallback); + assetToUsdDecimals = assetToUsd.decimals(); + assetToUsdFallbackDecimals = assetToUsdFallback.decimals(); + require(assetToUsdDecimals <= 36 && assetToUsdFallbackDecimals <= 36, "Unsupported decimals"); + assetToUsdHeartbeat = _heartbeat; + description = assetToUsd.description(); + } + + function latestRoundData() public view returns (uint80, int256, uint256, uint256, uint80) { + RoundData memory primary = _read(assetToUsd, assetToUsdDecimals, true); + if (primary.valid && _isFresh(primary.updatedAt)) return _result(primary); + + RoundData memory secondary = _read(assetToUsdFallback, assetToUsdFallbackDecimals, false); + if (secondary.valid && _isFresh(secondary.updatedAt)) return _result(secondary); + + // Prefer Chainlink on a tie. Neither zero/future timestamps nor failed + // reads are candidates, even when the other source is also unavailable. + if (primary.valid && (!secondary.valid || primary.updatedAt >= secondary.updatedAt)) { + return _result(primary); + } + if (secondary.valid) return _result(secondary); + revert NoValidPrice(); + } + + function latestAnswer() external view returns (int256) { + (, int256 answer,,,) = latestRoundData(); + return answer; + } + + function hasFallback() external pure returns (bool) { + return true; + } + + function setHeartbeat(uint256 heartbeat) external onlyOwner { + require(heartbeat > 0, "Invalid heartbeat"); + assetToUsdHeartbeat = heartbeat; + emit NewHeartbeat(heartbeat); + } + + function setPendingOwner(address newOwner) external onlyOwner { + pendingOwner = newOwner; + emit NewPendingOwner(newOwner); + } + + function acceptOwner() external { + require(msg.sender == pendingOwner, "Only pending owner"); + owner = pendingOwner; + pendingOwner = address(0); + emit NewOwner(owner); + } + + function _isFresh(uint256 updatedAt) internal view returns (bool) { + // _read has already excluded future timestamps. + return block.timestamp - updatedAt <= assetToUsdHeartbeat; + } + + function _read(IChainlinkFeed feed, uint8 feedDecimals, bool checkBounds) + internal + view + returns (RoundData memory data) + { + (bool success, bytes memory result) = + address(feed).staticcall(abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector)); + if (!success || result.length < 160) return data; + // Decode round IDs as full words first: malformed uint80 return values + // must invalidate only this source, rather than revert during decoding. + (uint256 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint256 answeredInRound) = + abi.decode(result, (uint256, int256, uint256, uint256, uint256)); + if (roundId > type(uint80).max || answeredInRound > type(uint80).max) return data; + if (answer <= 0 || updatedAt == 0 || updatedAt > block.timestamp) return data; + if (checkBounds && !_withinPrimaryBounds(answer)) return data; + int256 normalized = _normalize(answer, feedDecimals); + if (normalized == 0) return data; + data = RoundData(uint80(roundId), normalized, startedAt, updatedAt, uint80(answeredInRound), true); + } + + /// @dev Preserve ChainlinkBasePriceFeed's strict primary bounds. A failure + /// to read the aggregator or either bound makes only the primary unusable. + function _withinPrimaryBounds(int256 answer) internal view returns (bool) { + (bool success, uint256 aggregatorWord) = _readWord(address(assetToUsd), IChainlinkFeed.aggregator.selector); + if (!success || aggregatorWord == 0 || aggregatorWord > type(uint160).max) return false; + address aggregator = address(uint160(aggregatorWord)); + (bool minSuccess, uint256 minWord) = _readWord(aggregator, IDualFeedAggregator.minAnswer.selector); + (bool maxSuccess, uint256 maxWord) = _readWord(aggregator, IDualFeedAggregator.maxAnswer.selector); + int256 minimum = int256(minWord); + int256 maximum = int256(maxWord); + return minSuccess && maxSuccess && minimum >= type(int192).min && minimum <= type(int192).max + && maximum >= type(int192).min && maximum <= type(int192).max && minimum < maximum && answer > minimum + && answer < maximum; + } + + function _readWord(address target, bytes4 selector) internal view returns (bool, uint256) { + (bool success, bytes memory result) = target.staticcall(abi.encodeWithSelector(selector)); + if (!success || result.length < 32) return (false, 0); + return (true, abi.decode(result, (uint256))); + } + + function _normalize(int256 answer, uint8 feedDecimals) internal pure returns (int256) { + if (feedDecimals > 18) return answer / int256(10 ** (feedDecimals - 18)); + uint256 scale = 10 ** (18 - feedDecimals); + if (uint256(answer) > uint256(type(int256).max) / scale) return 0; + return answer * int256(scale); + } + + function _result(RoundData memory data) internal pure returns (uint80, int256, uint256, uint256, uint80) { + return (data.roundId, data.answer, data.startedAt, data.updatedAt, data.answeredInRound); + } +} diff --git a/src/feeds/RedstoneStoredPriceFeed.sol b/src/feeds/RedstoneStoredPriceFeed.sol new file mode 100644 index 00000000..7530f1b0 --- /dev/null +++ b/src/feeds/RedstoneStoredPriceFeed.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +interface IRedstoneStoredAdapter { + function getLastUpdateDetailsUnsafe(bytes32 dataFeedId) + external + view + returns (uint256 dataTimestampMilliseconds, uint256 publicationTimestamp, uint256 value); +} + +/// @notice Reads the last stored eight-decimal RedStone ETH/USD or BTC/USD quote. +/// @dev Removes the provider's read-time expiry only. Signed updates are still +/// validated by RedStone before storage. Consumers must enforce their own age policy. +contract RedstoneStoredPriceFeed { + error InvalidStoredPrice(); + + IRedstoneStoredAdapter public immutable adapter; + bytes32 public immutable dataFeedId; + uint8 public constant decimals = 8; + // Matches the current adapter's allowed forward clock skew at publication. + uint256 public constant MAX_DATA_TIMESTAMP_AHEAD = 1 minutes; + string public description; + + constructor(address _adapter, bytes32 _dataFeedId, string memory _description) { + require(_adapter != address(0) && _dataFeedId != bytes32(0), "Invalid source"); + adapter = IRedstoneStoredAdapter(_adapter); + dataFeedId = _dataFeedId; + description = _description; + } + + function latestRoundData() public view returns (uint80, int256, uint256, uint256, uint80) { + (uint256 dataMilliseconds, uint256 publishedAt, uint256 value) = adapter.getLastUpdateDetailsUnsafe(dataFeedId); + uint256 observedAt = dataMilliseconds / 1000; + if ( + value == 0 || value > uint256(type(int256).max) || observedAt == 0 || publishedAt == 0 + || publishedAt > block.timestamp + || (observedAt > publishedAt && observedAt - publishedAt > MAX_DATA_TIMESTAMP_AHEAD) + ) revert InvalidStoredPrice(); + + // Publication must never make an older observation appear fresh. When + // signed data is slightly ahead of the block clock, use publication time. + uint256 updatedAt = observedAt < publishedAt ? observedAt : publishedAt; + return (1, int256(value), updatedAt, updatedAt, 1); + } + + function latestAnswer() external view returns (int256) { + (, int256 answer,,,) = latestRoundData(); + return answer; + } +} diff --git a/test/DualFeedComposition.t.sol b/test/DualFeedComposition.t.sol new file mode 100644 index 00000000..b32b78ca --- /dev/null +++ b/test/DualFeedComposition.t.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {DualFeedPriceFeed} from "src/feeds/DualFeedPriceFeed.sol"; +import {RedstoneStoredPriceFeed} from "src/feeds/RedstoneStoredPriceFeed.sol"; +import {DynamicFeeCurveFeed} from "src/feeds/DynamicFeeCurveFeed.sol"; +import {ChainlinkBridgeAssetFeed} from "src/feeds/ChainlinkBridgeAssetFeed.sol"; +import { + DualFeedSourceMock, + RedstoneAdapterMock, + DualFeedCurvePoolMock, + DualFeedInvTokenMock +} from "test/mocks/DualFeedMocks.sol"; + +contract DualFeedCompositionTest is Test { + DualFeedSourceMock primary; + RedstoneAdapterMock adapter; + DualFeedPriceFeed usd; + + function setUp() public { + vm.warp(1000 days); + primary = new DualFeedSourceMock(8); + primary.setRound(2000e8, block.timestamp); + adapter = new RedstoneAdapterMock(); + adapter.set(block.timestamp * 1000, block.timestamp, 1900e8); + RedstoneStoredPriceFeed stored = new RedstoneStoredPriceFeed(address(adapter), bytes32("ETH"), "ETH / USD"); + usd = new DualFeedPriceFeed(address(this), address(primary), address(stored), 3660); + } + + function test_invCompositionPreservesCurveFeeAndSelectedAgeAfterExpiry() public { + DualFeedInvTokenMock inv = new DualFeedInvTokenMock(); + DualFeedCurvePoolMock pool = new DualFeedCurvePoolMock(address(inv)); + DynamicFeeCurveFeed feed = new DynamicFeeCurveFeed(address(usd), address(pool), address(inv), address(this)); + (, int256 price,, uint256 timestamp,) = feed.latestRoundData(); + assertEq(price, 9.8e18); // 2000 USD/ETH * 0.005 ETH/INV * 98% + assertEq(timestamp, block.timestamp); + + vm.warp(block.timestamp + 1 minutes); + adapter.set(block.timestamp * 1000, block.timestamp, 1900e8); + uint256 fallbackTimestamp = block.timestamp; + vm.warp(block.timestamp + 30 hours); + vm.expectRevert("Redstone expired"); + adapter.getLastUpdateDetails(bytes32("ETH")); + (, price,, timestamp,) = feed.latestRoundData(); + assertEq(price, 9.31e18); + assertEq(timestamp, fallbackTimestamp); + assertEq(feed.latestAnswer(), price); + assertEq(feed.maxFee(), 2e8); + } + + function test_wbtcCompositionRetainsOlderConversionLegTimestamp() public { + primary.setRound(50000e8, block.timestamp); + DualFeedSourceMock ratio = new DualFeedSourceMock(18); + ratio.setRound(0.999e18, block.timestamp - 2 hours); + ChainlinkBridgeAssetFeed feed = new ChainlinkBridgeAssetFeed(address(ratio), address(usd), true); + (, int256 price,, uint256 timestamp,) = feed.latestRoundData(); + assertEq(price, 49950e18); + assertEq(timestamp, block.timestamp - 2 hours); + + uint256 usdTimestamp = block.timestamp; + vm.warp(block.timestamp + 40 hours); + ratio.setRound(0.999e18, block.timestamp); + adapter.set((usdTimestamp - 1) * 1000, usdTimestamp - 1, 49000e8); + (, price,, timestamp,) = feed.latestRoundData(); + assertEq(price, 49950e18); // Chainlink's stale USD quote is newer. + assertEq(timestamp, usdTimestamp); // The fresh ratio cannot refresh USD data. + assertEq(feed.decimals(), 18); + } +} diff --git a/test/DualFeedMarket.t.sol b/test/DualFeedMarket.t.sol new file mode 100644 index 00000000..0afb542d --- /dev/null +++ b/test/DualFeedMarket.t.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import "test/FiRMBaseTest.sol"; +import {DualFeedPriceFeed} from "src/feeds/DualFeedPriceFeed.sol"; +import {RedstoneStoredPriceFeed} from "src/feeds/RedstoneStoredPriceFeed.sol"; +import {ChainlinkBasePriceFeed} from "src/feeds/ChainlinkBasePriceFeed.sol"; +import {DualFeedSourceMock, RedstoneAdapterMock, ExpiringRedstoneFeedMock} from "test/mocks/DualFeedMocks.sol"; + +contract DualFeedMarketTest is FiRMBaseTest { + DualFeedSourceMock primary; + RedstoneAdapterMock adapter; + DualFeedPriceFeed dual; + ChainlinkBasePriceFeed legacy; + uint256 fallbackTimestamp; + + function setUp() public { + initialize( + replenishmentPriceBps, + collateralFactorBps, + replenishmentIncentiveBps, + liquidationBonusBps, + callOnDepositCallback + ); + primary = new DualFeedSourceMock(8); + adapter = new RedstoneAdapterMock(); + RedstoneStoredPriceFeed stored = new RedstoneStoredPriceFeed(address(adapter), bytes32("ETH"), "ETH / USD"); + dual = new DualFeedPriceFeed(gov, address(primary), address(stored), 3660); + ExpiringRedstoneFeedMock expiring = new ExpiringRedstoneFeedMock(adapter); + legacy = new ChainlinkBasePriceFeed(gov, address(primary), address(expiring), 3660); + vm.startPrank(gov); + oracle.setFeed(address(WETH), IChainlinkFeed(address(dual)), 18); + borrowController.setStalenessThreshold(address(market), 3660); + vm.stopPrank(); + + vm.warp(block.timestamp + 1 days); + primary.setRound(2000e8, block.timestamp); + adapter.set(block.timestamp * 1000, block.timestamp, 2000e8); + gibDOLA(address(market), 10000 ether); + gibWeth(user, 1 ether); + gibDBR(user, 10000 ether); + vm.startPrank(user, user); + WETH.approve(address(market), 1 ether); + market.deposit(user, 1 ether); + market.borrow(1000 ether); + vm.stopPrank(); + } + + function test_staleBorrowingIsDeniedWhileLiquidationSettlesAtRedstoneExpiry() public { + _outage(); + assertTrue(borrowController.isPriceStale(address(market))); + vm.prank(user, user); + vm.expectRevert("Denied by borrow controller"); + market.borrow(1 ether); + + uint256 repaid = 250 ether; + gibDOLA(user2, repaid); + vm.prank(user2); + DOLA.approve(address(market), repaid); + + // Same debt, escrow, oracle cache and providers: the old component fails. + vm.prank(gov); + oracle.setFeed(address(WETH), IChainlinkFeed(address(legacy)), 18); + vm.prank(user2); + vm.expectRevert("Redstone expired"); + market.liquidate(user, repaid); + + vm.prank(gov); + oracle.setFeed(address(WETH), IChainlinkFeed(address(dual)), 18); + uint256 escrowBefore = WETH.balanceOf(address(market.escrows(user))); + uint256 marketDolaBefore = DOLA.balanceOf(address(market)); + vm.prank(user2); + market.liquidate(user, repaid); + + uint256 expectedCollateral = repaid * 1 ether / 900e18; + expectedCollateral += expectedCollateral * liquidationBonusBps / 10000; + assertEq(market.debts(user), 750 ether); + assertEq(WETH.balanceOf(user2), expectedCollateral); + assertEq(WETH.balanceOf(address(market.escrows(user))), escrowBefore - expectedCollateral); + assertEq(DOLA.balanceOf(address(market)), marketDolaBefore + repaid); + (,,, uint256 timestamp,) = dual.latestRoundData(); + assertEq(timestamp, fallbackTimestamp); + } + + function test_recoveryRestoresFreshnessWithoutErasingPessimisticLow() public { + _outage(); + assertEq(oracle.getPrice(address(WETH), collateralFactorBps), 900e18); + uint256 day = block.timestamp / 1 days; + primary.setRound(2000e8, block.timestamp); + assertFalse(borrowController.isPriceStale(address(market))); + assertEq(dual.latestAnswer(), 2000e18); + assertEq(oracle.dailyLows(address(WETH), day), 900e18); + assertEq(oracle.viewPrice(address(WETH), collateralFactorBps), 900e18 * 10000 / collateralFactorBps); + + vm.warp(block.timestamp + 2 days); + primary.setRound(2000e8, block.timestamp); + assertEq(oracle.viewPrice(address(WETH), collateralFactorBps), 2000e18); + } + + function test_freshFallbackAllowsBorrowingDuringPrimaryReadFailure() public { + primary.setFailRead(true); + assertFalse(borrowController.isPriceStale(address(market))); + vm.prank(user, user); + market.borrow(100 ether); + assertEq(market.debts(user), 1100 ether); + } + + function _outage() internal { + vm.warp(block.timestamp + 1 minutes); + primary.setRound(1000e8, block.timestamp); + vm.warp(block.timestamp + 4 minutes); + fallbackTimestamp = block.timestamp; + adapter.set(block.timestamp * 1000, block.timestamp, 900e8); + vm.warp(block.timestamp + 30 hours); + } +} diff --git a/test/DualFeedPriceFeed.t.sol b/test/DualFeedPriceFeed.t.sol new file mode 100644 index 00000000..9cba3ddd --- /dev/null +++ b/test/DualFeedPriceFeed.t.sol @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {DualFeedPriceFeed} from "src/feeds/DualFeedPriceFeed.sol"; +import {DualFeedSourceMock} from "test/mocks/DualFeedMocks.sol"; + +contract DualFeedPriceFeedTest is Test { + uint256 constant HEARTBEAT = 3660; + DualFeedSourceMock primary; + DualFeedSourceMock secondary; + DualFeedPriceFeed feed; + + function setUp() public { + vm.warp(1000 days); + primary = new DualFeedSourceMock(8); + secondary = new DualFeedSourceMock(8); + primary.setRound(2000e8, block.timestamp); + secondary.setRound(1900e8, block.timestamp); + secondary.setRoundId(9); + feed = new DualFeedPriceFeed(address(this), address(primary), address(secondary), HEARTBEAT); + } + + function test_freshPrimaryHasPriorityEvenWithNewerFallback() public { + primary.setRound(2000e8, block.timestamp - 10); + _assertQuote(2000e18, block.timestamp - 10, 7); + } + + function test_freshPrimaryDoesNotDependOnFallbackRead() public { + secondary.setFailRead(true); + _assertQuote(2000e18, block.timestamp, 7); + } + + function test_primaryFreshAtHeartbeatThenFallsBack() public { + primary.setRound(2000e8, block.timestamp - HEARTBEAT); + _assertQuote(2000e18, block.timestamp - HEARTBEAT, 7); + primary.setRound(2000e8, block.timestamp - HEARTBEAT - 1); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_bothStaleUsesNewerPrimary() public { + primary.setRound(2000e8, block.timestamp - 2 hours); + secondary.setRound(1900e8, block.timestamp - 20 hours); + _assertQuote(2000e18, block.timestamp - 2 hours, 7); + } + + function test_bothStaleUsesNewerFallback() public { + primary.setRound(2000e8, block.timestamp - 50 hours); + secondary.setRound(1900e8, block.timestamp - 40 hours); + _assertQuote(1900e18, block.timestamp - 40 hours, 9); + } + + function test_equalStaleTimestampsPreferPrimary() public { + primary.setRound(2000e8, block.timestamp - 2 hours); + secondary.setRound(1900e8, block.timestamp - 2 hours); + _assertQuote(2000e18, block.timestamp - 2 hours, 7); + } + + function test_primaryRevertUsesFallback() public { + primary.setFailRead(true); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_primaryRevertCanUseStaleFallback() public { + primary.setFailRead(true); + secondary.setRound(1900e8, block.timestamp - 90 days); + _assertQuote(1900e18, block.timestamp - 90 days, 9); + } + + function test_fallbackRevertRetainsStalePrimary() public { + primary.setRound(2000e8, block.timestamp - 40 hours); + secondary.setFailRead(true); + _assertQuote(2000e18, block.timestamp - 40 hours, 7); + } + + function test_primaryRecoveryRestoresPriority() public { + primary.setFailRead(true); + _assertQuote(1900e18, block.timestamp, 9); + primary.setFailRead(false); + primary.setRound(2100e8, block.timestamp); + _assertQuote(2100e18, block.timestamp, 7); + } + + function test_zeroNegativeZeroTimestampAndFuturePrimaryAreRejected() public { + primary.setRound(0, block.timestamp); + _assertQuote(1900e18, block.timestamp, 9); + primary.setRound(-1, block.timestamp); + _assertQuote(1900e18, block.timestamp, 9); + primary.setRound(2000e8, 0); + _assertQuote(1900e18, block.timestamp, 9); + primary.setRound(2000e8, block.timestamp + 1); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_invalidFallbackDoesNotReplaceStalePrimary() public { + primary.setRound(2000e8, block.timestamp - 2 hours); + secondary.setRound(0, block.timestamp); + _assertQuote(2000e18, block.timestamp - 2 hours, 7); + secondary.setRound(-1, block.timestamp); + _assertQuote(2000e18, block.timestamp - 2 hours, 7); + secondary.setRound(1900e8, 0); + _assertQuote(2000e18, block.timestamp - 2 hours, 7); + secondary.setRound(1900e8, block.timestamp + 1); + _assertQuote(2000e18, block.timestamp - 2 hours, 7); + } + + function test_primaryBoundsAreStrict() public { + primary.setBounds(2000e8, 3000e8); + _assertQuote(1900e18, block.timestamp, 9); + primary.setBounds(1, 2000e8); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_primaryBoundsFailuresDoNotDisableFallback() public { + primary.setFailures(true, false, false); + _assertQuote(1900e18, block.timestamp, 9); + primary.setFailures(false, true, false); + _assertQuote(1900e18, block.timestamp, 9); + primary.setFailures(false, false, true); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_malformedRoundReturnDoesNotDisableFallback() public { + vm.mockCall(address(primary), abi.encodeWithSignature("latestRoundData()"), hex"01"); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_malformedFallbackRetainsStalePrimary() public { + primary.setRound(2000e8, block.timestamp - 40 hours); + vm.mockCall(address(secondary), abi.encodeWithSignature("latestRoundData()"), hex"01"); + _assertQuote(2000e18, block.timestamp - 40 hours, 7); + } + + function test_malformedRoundIdDoesNotDisableFallback() public { + vm.mockCall( + address(primary), + abi.encodeWithSignature("latestRoundData()"), + abi.encode(uint256(type(uint80).max) + 1, int256(2000e8), block.timestamp, block.timestamp, uint256(7)) + ); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_malformedAggregatorDoesNotDisableFallback() public { + vm.mockCall(address(primary), abi.encodeWithSignature("aggregator()"), abi.encode(type(uint256).max)); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_malformedBoundsDoNotDisableFallback() public { + vm.mockCall(address(primary), abi.encodeWithSignature("minAnswer()"), hex"01"); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_overflowingFallbackQuoteDoesNotDisablePrimary() public { + primary.setRound(2000e8, block.timestamp - 2 hours); + secondary.setRound(type(int256).max, block.timestamp); + _assertQuote(2000e18, block.timestamp - 2 hours, 7); + } + + function test_normalizesSourcesWithDifferentDecimals() public { + DualFeedSourceMock other = new DualFeedSourceMock(20); + other.setRound(1900e20, block.timestamp); + DualFeedPriceFeed mixed = new DualFeedPriceFeed(address(this), address(primary), address(other), HEARTBEAT); + primary.setFailRead(true); + (, int256 answer,, uint256 timestamp,) = mixed.latestRoundData(); + assertEq(answer, 1900e18); + assertEq(timestamp, block.timestamp); + } + + function test_normalizesEighteenDecimalPrimaryWithoutRescaling() public { + DualFeedSourceMock other = new DualFeedSourceMock(18); + other.setRound(2000e18, block.timestamp); + DualFeedPriceFeed mixed = new DualFeedPriceFeed(address(this), address(other), address(secondary), HEARTBEAT); + assertEq(mixed.latestAnswer(), 2000e18); + } + + function test_downscalingToZeroInvalidatesQuote() public { + DualFeedSourceMock other = new DualFeedSourceMock(20); + other.setRound(1, block.timestamp); + DualFeedPriceFeed mixed = new DualFeedPriceFeed(address(this), address(primary), address(other), HEARTBEAT); + primary.setRound(2000e8, block.timestamp - 2 hours); + assertEq(mixed.latestAnswer(), 2000e18); + } + + function test_bothUnreadableRevertWithoutInventingPrice() public { + primary.setFailRead(true); + secondary.setFailRead(true); + vm.expectRevert(DualFeedPriceFeed.NoValidPrice.selector); + feed.latestRoundData(); + } + + function test_bothInvalidRevertWithoutInventingPrice() public { + primary.setRound(0, block.timestamp); + secondary.setRound(0, block.timestamp); + vm.expectRevert(DualFeedPriceFeed.NoValidPrice.selector); + feed.latestRoundData(); + } + + function test_ownerControlsHeartbeatAndOwnership() public { + vm.prank(address(1)); + vm.expectRevert("Only owner"); + feed.setHeartbeat(100); + vm.expectRevert("Invalid heartbeat"); + feed.setHeartbeat(0); + feed.setHeartbeat(100); + assertEq(feed.assetToUsdHeartbeat(), 100); + feed.setPendingOwner(address(1)); + vm.expectRevert("Only pending owner"); + feed.acceptOwner(); + vm.prank(address(1)); + feed.acceptOwner(); + assertEq(feed.owner(), address(1)); + assertEq(feed.pendingOwner(), address(0)); + } + + function test_constructorRejectsUnsupportedDecimals() public { + DualFeedSourceMock other = new DualFeedSourceMock(37); + vm.expectRevert("Unsupported decimals"); + new DualFeedPriceFeed(address(this), address(primary), address(other), HEARTBEAT); + } + + function testFuzz_bothStalePreserveNewestQuoteAndTimestamp(uint256 ageA, uint256 ageB) public { + ageA = bound(ageA, HEARTBEAT + 1, 365 days); + ageB = bound(ageB, HEARTBEAT + 1, 365 days); + primary.setRound(2000e8, block.timestamp - ageA); + secondary.setRound(1900e8, block.timestamp - ageB); + if (ageA <= ageB) _assertQuote(2000e18, block.timestamp - ageA, 7); + else _assertQuote(1900e18, block.timestamp - ageB, 9); + } + + function _assertQuote(int256 expected, uint256 timestamp, uint80 id) internal view { + (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = + feed.latestRoundData(); + assertEq(answer, expected); + assertEq(updatedAt, timestamp); + assertEq(startedAt, timestamp); + assertEq(roundId, id); + assertEq(answeredInRound, id); + assertEq(feed.latestAnswer(), answer); + assertEq(feed.decimals(), 18); + } +} diff --git a/test/RedstoneStoredPriceFeed.t.sol b/test/RedstoneStoredPriceFeed.t.sol new file mode 100644 index 00000000..b33e42d1 --- /dev/null +++ b/test/RedstoneStoredPriceFeed.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {RedstoneStoredPriceFeed} from "src/feeds/RedstoneStoredPriceFeed.sol"; +import {RedstoneAdapterMock} from "test/mocks/DualFeedMocks.sol"; + +contract RedstoneStoredPriceFeedTest is Test { + RedstoneAdapterMock adapter; + RedstoneStoredPriceFeed feed; + + function setUp() public { + vm.warp(1000 days); + adapter = new RedstoneAdapterMock(); + adapter.set(block.timestamp * 1000, block.timestamp, 2000e8); + feed = new RedstoneStoredPriceFeed(address(adapter), bytes32("ETH"), "ETH / USD (stored RedStone)"); + } + + function test_retainsStoredPriceBeforeAtAndAfterThirtyHours() public { + uint256 timestamp = block.timestamp; + vm.warp(timestamp + 30 hours - 1); + adapter.getLastUpdateDetails(bytes32("ETH")); + _assertQuote(2000e8, timestamp); + vm.warp(timestamp + 30 hours); + vm.expectRevert("Redstone expired"); + adapter.getLastUpdateDetails(bytes32("ETH")); + _assertQuote(2000e8, timestamp); + vm.warp(timestamp + 30 hours + 1); + _assertQuote(2000e8, timestamp); + vm.warp(timestamp + 90 days); + _assertQuote(2000e8, timestamp); + } + + function test_publicationCannotRefreshOldObservation() public { + adapter.set((block.timestamp - 2 hours) * 1000 + 999, block.timestamp, 2000e8); + _assertQuote(2000e8, block.timestamp - 2 hours); + } + + function test_allowedForwardClockSkewUsesPublicationTime() public { + adapter.set((block.timestamp + 60) * 1000, block.timestamp, 2000e8); + _assertQuote(2000e8, block.timestamp); + } + + function test_excessiveForwardClockSkewIsRejected() public { + adapter.set((block.timestamp + 61) * 1000, block.timestamp, 2000e8); + _expectInvalid(); + } + + function test_futurePublicationIsRejected() public { + adapter.set(block.timestamp * 1000, block.timestamp + 1, 2000e8); + _expectInvalid(); + } + + function test_zeroObservationAndPublicationAreRejected() public { + adapter.set(0, block.timestamp, 2000e8); + _expectInvalid(); + adapter.set(block.timestamp * 1000, 0, 2000e8); + _expectInvalid(); + } + + function test_zeroAndOverflowingValuesAreRejected() public { + adapter.set(block.timestamp * 1000, block.timestamp, 0); + _expectInvalid(); + adapter.set(block.timestamp * 1000, block.timestamp, uint256(type(int256).max) + 1); + _expectInvalid(); + } + + function test_readFailurePropagatesForSelectorToHandle() public { + adapter.setFailRead(true); + vm.expectRevert("Adapter failed"); + feed.latestRoundData(); + } + + function test_usesConfiguredDataFeedId() public { + RedstoneStoredPriceFeed btc = new RedstoneStoredPriceFeed(address(adapter), bytes32("BTC"), "BTC / USD"); + vm.expectCall(address(adapter), abi.encodeWithSignature("getLastUpdateDetailsUnsafe(bytes32)", bytes32("BTC"))); + btc.latestRoundData(); + } + + function _expectInvalid() internal { + vm.expectRevert(RedstoneStoredPriceFeed.InvalidStoredPrice.selector); + feed.latestRoundData(); + } + + function _assertQuote(int256 value, uint256 timestamp) internal view { + (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = + feed.latestRoundData(); + assertEq(roundId, 1); + assertEq(answeredInRound, 1); + assertEq(answer, value); + assertEq(startedAt, timestamp); + assertEq(updatedAt, timestamp); + assertEq(feed.latestAnswer(), answer); + assertEq(feed.decimals(), 8); + } +} diff --git a/test/mocks/DualFeedMocks.sol b/test/mocks/DualFeedMocks.sol new file mode 100644 index 00000000..f1e788b4 --- /dev/null +++ b/test/mocks/DualFeedMocks.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +contract DualFeedSourceMock { + uint8 public immutable decimals; + string public description = "ETH / USD"; + int256 public answer; + uint256 public updatedAt; + uint80 public roundId = 7; + bool public failRead; + bool public failAggregator; + bool public failMin; + bool public failMax; + int192 internal minimum = 1; + int192 internal maximum = type(int192).max; + + constructor(uint8 _decimals) { + decimals = _decimals; + } + + function setRound(int256 _answer, uint256 _updatedAt) external { + answer = _answer; + updatedAt = _updatedAt; + } + + function setRoundId(uint80 id) external { + roundId = id; + } + + function setFailRead(bool fail) external { + failRead = fail; + } + + function setBounds(int192 low, int192 high) external { + minimum = low; + maximum = high; + } + + function setFailures(bool aggregator_, bool min_, bool max_) external { + failAggregator = aggregator_; + failMin = min_; + failMax = max_; + } + + function aggregator() external view returns (address) { + require(!failAggregator, "Aggregator failed"); + return address(this); + } + + function minAnswer() external view returns (int192) { + require(!failMin, "Minimum failed"); + return minimum; + } + + function maxAnswer() external view returns (int192) { + require(!failMax, "Maximum failed"); + return maximum; + } + + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { + require(!failRead, "Feed failed"); + return (roundId, answer, updatedAt, updatedAt, roundId); + } +} + +contract RedstoneAdapterMock { + uint256 public dataMilliseconds; + uint256 public publishedAt; + uint256 public value; + bool public failRead; + + function set(uint256 data_, uint256 published_, uint256 value_) external { + dataMilliseconds = data_; + publishedAt = published_; + value = value_; + } + + function setFailRead(bool fail) external { + failRead = fail; + } + + function getLastUpdateDetailsUnsafe(bytes32) external view returns (uint256, uint256, uint256) { + require(!failRead, "Adapter failed"); + return (dataMilliseconds, publishedAt, value); + } + + function getLastUpdateDetails(bytes32) external view returns (uint256, uint256, uint256) { + require(!failRead, "Adapter failed"); + require(value > 0 && publishedAt + 30 hours > block.timestamp, "Redstone expired"); + return (dataMilliseconds, publishedAt, value); + } +} + +contract ExpiringRedstoneFeedMock { + uint8 public constant decimals = 8; + RedstoneAdapterMock public immutable adapter; + + constructor(RedstoneAdapterMock adapter_) { + adapter = adapter_; + } + + function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { + (, uint256 timestamp, uint256 value) = adapter.getLastUpdateDetails(bytes32("ETH")); + return (1, int256(value), timestamp, timestamp, 1); + } +} + +contract DualFeedCurvePoolMock { + address public immutable asset; + uint256 public price = 0.005 ether; + uint256 public fee = 2e8; + + constructor(address asset_) { + asset = asset_; + } + + function coins(uint256 index) external view returns (address) { + return index == 1 ? asset : address(0x1234); + } + + function price_oracle() external view returns (uint256) { + return price; + } +} + +contract DualFeedInvTokenMock { + function symbol() external pure returns (string memory) { + return "INV"; + } +} From 91eda1f1658062f45c0fea9e90047a8cf83faa08 Mon Sep 17 00:00:00 2001 From: cryptoharry <110295612+cryptoharry@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:53:58 +0400 Subject: [PATCH 2/3] Reuse base feed logic and generalize stored-price adapter --- docs/dual-feed-price-feeds.md | 196 +++++++++++--------------- src/feeds/ChainlinkBasePriceFeed.sol | 3 +- src/feeds/DualFeedPriceFeed.sol | 153 +++++--------------- src/feeds/RedstoneStoredPriceFeed.sol | 19 ++- test/DualFeedComposition.t.sol | 23 +-- test/DualFeedMarket.t.sol | 7 +- test/DualFeedPriceFeed.t.sol | 22 +++ test/RedstoneStoredPriceFeed.t.sol | 73 ++++++++-- test/mocks/DualFeedMocks.sol | 15 +- 9 files changed, 236 insertions(+), 275 deletions(-) diff --git a/docs/dual-feed-price-feeds.md b/docs/dual-feed-price-feeds.md index ac3810bc..bbdd40c7 100644 --- a/docs/dual-feed-price-feeds.md +++ b/docs/dual-feed-price-feeds.md @@ -1,108 +1,85 @@ -# Dual-source USD feeds +# Dual-source price feeds -FiRM's current ETH/USD and BTC/USD `ChainlinkBasePriceFeed` instances select -RedStone when Chainlink is stale. RedStone's current read interface reverts when -its last publication reaches 30 hours. If Chainlink remains stale, that revert -propagates through WETH, INV and WBTC pricing and prevents liquidations. +These components combine a Chainlink primary with an optional Chainlink-compatible +fallback. Both sources must quote the same asset pair. Valid stored quotes remain +available during update outages, with their original timestamps, so consumers can +apply their own freshness policy. -This change provides replacement components. It does not deploy contracts or -change any live market, oracle registration or controller threshold. +## DualFeedPriceFeed -## Components and selection +`DualFeedPriceFeed` inherits `ChainlinkBasePriceFeed`, reusing its source metadata, +ownership, heartbeat management, 18-decimal normalization and strict primary +aggregator bounds. The base contract only marks `latestRoundData` and +`setHeartbeat` as virtual; its own pricing and administration behaviour is +unchanged. -`RedstoneStoredPriceFeed` reads `getLastUpdateDetailsUnsafe(bytes32)` on the -existing RedStone multi-feed adapter. Updates continue to be validated by -RedStone before storage; the replacement removes only the read-time expiry. -It rejects missing/zero values, values exceeding `int256`, missing timestamps, -future publication times, and signed data more than one minute ahead of its -publication. That forward tolerance matches the currently deployed adapter. -Its eight-decimal output is specific to the ETH/USD and BTC/USD records. +Constructor: `(owner, primary, fallback, heartbeat)`. -The reported timestamp is the earlier of the signed data timestamp (milliseconds -converted to seconds) and the publication timestamp. A delayed publication -cannot make an old observation appear fresh. Age alone does not cause a revert. +- `primary` must support the existing Chainlink aggregator/bounds interface. +- `fallback` accepts a Chainlink-compatible quote source, or the zero address. +- `heartbeat` is a positive freshness target in seconds, shared by both sources. + The owner can update it through the existing heartbeat setter. +- Source decimals are read at deployment and must be between 0 and 36. -`DualFeedPriceFeed` accepts a Chainlink primary and a Chainlink-compatible -fallback, and normalizes both to 18 decimals. Both use the same configurable -freshness target: +Selection: -1. Use a valid, fresh Chainlink quote, including at the heartbeat boundary. -2. Otherwise use a valid, fresh fallback quote. -3. Otherwise use the newer valid stale quote, preferring Chainlink on a tie. +1. Return a valid, fresh primary quote, including at the heartbeat boundary. +2. Otherwise return a valid, fresh fallback quote. +3. Otherwise return the newer valid stale quote, preferring the primary on a tie. 4. Revert with `NoValidPrice()` if neither source is usable. -Each result retains the selected quote's metadata and timestamp. The selector -never marks old data fresh or replaces its timestamp with zero. Nonpositive -prices, zero/future update timestamps, malformed responses, normalization -overflow, and prices rounding down to zero are not candidates. Primary -validation preserves the existing strict Chainlink `minAnswer`/`maxAnswer` -checks. Failure to read the primary or its bounds invalidates only that source; -a failed fallback does not prevent using a valid stale primary. Consequently, -the primary must support Chainlink's aggregator and bounds interface. +Returned round metadata and timestamps belong to the selected source. Nonpositive +prices, zero/future update timestamps, failed/malformed reads, strict primary +bounds failures, normalization overflow and prices rounding to zero invalidate +only the affected source. An external `readSource` boundary lets `try/catch` +contain decoding and arithmetic failures while reusing the base feed's typed +calls and helpers. A healthy primary does not read the fallback. -A healthy primary is read without calling the fallback. There is no local -last-good-price cache. If both providers become unreadable or invalid, this -implementation deliberately does not invent a valuation. +There is no maintained local price cache. Both unreadable or invalid sources +still cause a revert. A valid single-source configuration retains stale +`updatedAt` instead of replacing it with zero. -## Mainnet wiring +## RedstoneStoredPriceFeed -The following configuration was checked at Ethereum block 26,234,376 on -20 October 2026. Recheck sources, proxy implementations and parameters before -deployment. RedStone's adapter is upgradeable; monitor changes to its interface, -decimals, timestamp semantics, data IDs and update validation. +This small adapter reads `getLastUpdateDetailsUnsafe(bytes32)` from an existing +RedStone multi-feed adapter. RedStone continues to verify signed updates before +storage. This reader adds no signature verification or update mechanism. -| Component | Address / identifier | +Constructor: `(adapter, dataFeedId, decimals, maxDataTimestampAhead, description)`. + +| Parameter | Meaning | | --- | --- | -| Chainlink ETH/USD primary | `0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419` | -| Chainlink BTC/USD primary | `0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c` | -| RedStone multi-feed adapter | `0xd72a6ba4a87ddb33e801b3f1c7750b2d0911fc6c` | -| ETH data ID | `bytes32("ETH")`, not a hash | -| BTC data ID | `bytes32("BTC")`, not a hash | -| Existing WBTC/BTC normalized component | `0x435419607191f1c1c6809e01bb5c38311957c6a8` | -| INV/WETH Curve pool | `0xdcd90d866ff9636e5a04768825d05d27b3fb19ec` | -| FiRM pessimistic oracle | `0xabe146cf570fd27ddd985895ce9b138a7110cce8` | - -Deployment and governance preparation: - -1. Deploy an ETH stored-price adapter and a BTC stored-price adapter, using the - data IDs above. Verify the stored records are eight-decimal USD quotes. -2. Deploy ETH/USD and BTC/USD selectors using the respective Chainlink proxies - and stored-price adapters. Set the intended owner and freshness target - explicitly. The replaced USD components currently use 3,660 seconds. -3. Deploy a new `DynamicFeeCurveFeed` for INV with the new ETH/USD selector, - the existing pool, INV token and governance. Preserve the existing `maxFee` - and governance configuration after rechecking them. -4. Deploy a new `ChainlinkBridgeAssetFeed` for WBTC using the existing normalized - WBTC/BTC component and the new BTC/USD selector. Preserve the existing ratio - direction (`bridgeAssetDenominator`). -5. Prepare `Oracle.setFeed` updates: WETH to the ETH/USD selector; INV to its new - Curve feed; WBTC to its new bridge feed. Preserve collateral token decimals - (18, 18 and 8 respectively). Those are token decimals, not feed decimals. -6. Test the complete proposed registrations against existing positions and - pessimistic-oracle cache state before governance execution. - -The existing combining feeds store their component addresses immutably, so -changing the source file cannot update those deployed contracts. Replacement -feed deployments and oracle registration updates are required. Market contracts, -escrows, debts and borrower positions do not need migration for these updates. - -## Borrowing and recovery - -The controller continues to enforce borrowing freshness using the selected -timestamp. At the checked block, INV's borrowing threshold was 3,660 seconds; -WETH and WBTC used 86,460 seconds. The selector's switching target and the -controller's borrowing threshold are separate settings. This PR does not change -them onchain. A WBTC review must consider the different update schedules of its -WBTC/BTC and BTC/USD legs; the composite still reports the older leg's timestamp. - -An old quote keeps pricing callable but does not guarantee accurate or profitable -liquidations. Freshness alone does not establish accuracy either. A fallback is -a second trusted source, not a quorum or independent agreement check. Neither -source protects against every shared market or chain failure. Prolonged outages -still require monitoring and an incident response. - -Recovery restores Chainlink priority, but an already-recorded pessimistic daily -low is not cleared by switching feeds. Its existing lookback still applies. +| `adapter` | Deployed provider exposing the stored-details getter | +| `dataFeedId` | Exact `bytes32` identifier used by that provider | +| `decimals` | Decimal places of the stored value; no conversion occurs here | +| `maxDataTimestampAhead` | Allowed observation time ahead of publication, in seconds; zero allows no forward skew | +| `description` | Description of the configured asset pair | + +The getter must return observation time in milliseconds, publication time in +seconds, and a positive stored value fitting `int256`. Missing timestamps and +future publication times are rejected. Forward observation skew is limited by +the constructor setting. `updatedAt` is the earlier of observation and +publication, so publishing an older observation cannot make it appear fresh. + +The adapter has no read-time age expiry. It does not need the provider's expiry +value: the stored getter bypasses that check regardless of its configured +length. Switching freshness belongs to the selector's heartbeat; borrowing or +other action-specific freshness belongs to the consuming protocol. Adding an +expiry here would prevent retaining stale quotes during an extended outage. + +## Integration + +Configure source addresses, pair direction, feed identifiers, decimals and +clock tolerance for each deployment. Verify these against the exact provider +implementation, including its update validation. Provider upgrades can change +these assumptions. + +The normalized selector can be used directly or as an input to existing +combining feeds. Immutable component addresses require new combining-feed +instances before changing an oracle registration. Review ownership, consumer +freshness thresholds and cached-price behaviour before adoption. A stale quote +preserves availability, not current market accuracy; restoring a fresh source +does not necessarily clear an existing pessimistic price cache. ## Tests @@ -112,29 +89,14 @@ Run the deterministic feed and core market suites without an RPC endpoint: forge test --match-contract '^(DualFeed|RedstoneStored|BorrowControllerTest$|OracleTest$|MarketTest$)' --evm-version shanghai ``` -The dedicated CI job uses Solidity 0.8.20 and the Shanghai EVM target. The -repository-wide compiler configuration is unchanged. The existing full workflow -also runs fork suites that require the `RPC_MAINNET` repository secret; those -suites fail at setup if the RPC URL is missing. - -Tests cover source failures and malformed responses, primary bounds, decimal -normalization, freshness boundaries, newest-stale selection and metadata, -ownership, RedStone expiry and observation age, INV's Curve/fee composition, -WBTC's older-component timestamp, controller borrowing rejection, settlement -through the real local Market/Oracle/DBR/escrow contracts, and recovery with an -existing daily-low cache. Upstream price publishers and the Curve pool are mocks -in these local tests. These are not mainnet transaction-fork tests or a completed -deployment/governance simulation. - -Validation completed for this change: 42 new tests (including 256 fuzz cases) -and 95 existing Market, Oracle and BorrowController tests passed with Solidity -0.8.20 and the Shanghai EVM target. - -Ten read-only Multicall3 control/replacement scenarios also passed at Ethereum -block 26,234,376, using injected replacement code and hypothetical source quotes -with native RedStone, Curve, combining-feed and controller contracts. These -checks covered both-stale selection, either provider being unreadable, and both -being unreadable. They were state overrides, not a live outage or a complete -deployment/governance simulation. - -Tracking: https://maestro.inverse.finance/boards/2/tasks/419 +Tests cover primary/fallback priority, freshness boundaries, newest-stale +selection, metadata, provider/decoding failures, bounds, normalization, +ownership, configurable provider expiries, stored-value decimals and clock +skew. Integration tests cover existing Curve/bridge compositions, borrowing +rejection, liquidation settlement and recovery with a pessimistic daily low. +The local price publishers and Curve pool are mocks. Deployment and governance +changes still require a full fork simulation of the intended registrations. + +The dedicated workflow uses Solidity 0.8.20 and Shanghai without RPC credentials. +The repository-wide compiler configuration is unchanged. The existing full fork +suite requires the `RPC_MAINNET` repository secret. diff --git a/src/feeds/ChainlinkBasePriceFeed.sol b/src/feeds/ChainlinkBasePriceFeed.sol index bc7696b3..9c048f31 100644 --- a/src/feeds/ChainlinkBasePriceFeed.sol +++ b/src/feeds/ChainlinkBasePriceFeed.sol @@ -63,6 +63,7 @@ contract ChainlinkBasePriceFeed { function latestRoundData() public view + virtual returns ( uint80 roundId, int256 usdPrice, @@ -131,7 +132,7 @@ contract ChainlinkBasePriceFeed { return address(assetToUsdFallback) != address(0); } - function setHeartbeat(uint256 newHeartbeat) public onlyOwner { + function setHeartbeat(uint256 newHeartbeat) public virtual onlyOwner { assetToUsdHeartbeat = newHeartbeat; } diff --git a/src/feeds/DualFeedPriceFeed.sol b/src/feeds/DualFeedPriceFeed.sol index dbed69d6..386a0e1d 100644 --- a/src/feeds/DualFeedPriceFeed.sol +++ b/src/feeds/DualFeedPriceFeed.sol @@ -1,17 +1,12 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; +import {ChainlinkBasePriceFeed} from "src/feeds/ChainlinkBasePriceFeed.sol"; import {IChainlinkFeed} from "src/interfaces/IChainlinkFeed.sol"; -interface IDualFeedAggregator { - function minAnswer() external view returns (int192); - function maxAnswer() external view returns (int192); -} - -/// @notice Chainlink-primary price feed with a Chainlink-compatible fallback. -/// @dev Both sources use the same freshness target. Age alone never invalidates -/// a quote: if neither is fresh, the newer valid quote is returned with its age. -contract DualFeedPriceFeed { +/// @notice Chainlink-primary feed that retains the newest valid quote during update outages. +/// @dev Both sources must quote the same pair. Timestamps retain the selected quote's age. +contract DualFeedPriceFeed is ChainlinkBasePriceFeed { error NoValidPrice(); struct RoundData { @@ -20,139 +15,57 @@ contract DualFeedPriceFeed { uint256 startedAt; uint256 updatedAt; uint80 answeredInRound; - bool valid; - } - - IChainlinkFeed public immutable assetToUsd; - IChainlinkFeed public immutable assetToUsdFallback; - uint8 public immutable assetToUsdDecimals; - uint8 public immutable assetToUsdFallbackDecimals; - uint8 public constant decimals = 18; - string public description; - address public owner; - address public pendingOwner; - uint256 public assetToUsdHeartbeat; - - event NewHeartbeat(uint256 heartbeat); - event NewPendingOwner(address pendingOwner); - event NewOwner(address owner); - - modifier onlyOwner() { - require(msg.sender == owner, "Only owner"); - _; } - constructor(address _owner, address _assetToUsd, address _fallback, uint256 _heartbeat) { + constructor(address _owner, address _primary, address _fallback, uint256 _heartbeat) + ChainlinkBasePriceFeed(_owner, _primary, _fallback, _heartbeat) + { require(_owner != address(0), "Invalid owner"); - require(_assetToUsd != address(0) && _fallback != address(0), "Invalid source"); - require(_assetToUsd != _fallback, "Identical sources"); + require(_primary != _fallback, "Identical sources"); require(_heartbeat > 0, "Invalid heartbeat"); - owner = _owner; - assetToUsd = IChainlinkFeed(_assetToUsd); - assetToUsdFallback = IChainlinkFeed(_fallback); - assetToUsdDecimals = assetToUsd.decimals(); - assetToUsdFallbackDecimals = assetToUsdFallback.decimals(); require(assetToUsdDecimals <= 36 && assetToUsdFallbackDecimals <= 36, "Unsupported decimals"); - assetToUsdHeartbeat = _heartbeat; - description = assetToUsd.description(); } - function latestRoundData() public view returns (uint80, int256, uint256, uint256, uint80) { - RoundData memory primary = _read(assetToUsd, assetToUsdDecimals, true); - if (primary.valid && _isFresh(primary.updatedAt)) return _result(primary); + function latestRoundData() public view override returns (uint80, int256, uint256, uint256, uint80) { + RoundData memory primary = _read(false); + if (primary.updatedAt != 0 && _isFresh(primary.updatedAt)) return _result(primary); - RoundData memory secondary = _read(assetToUsdFallback, assetToUsdFallbackDecimals, false); - if (secondary.valid && _isFresh(secondary.updatedAt)) return _result(secondary); + RoundData memory secondary = _read(true); + if (secondary.updatedAt != 0 && _isFresh(secondary.updatedAt)) return _result(secondary); - // Prefer Chainlink on a tie. Neither zero/future timestamps nor failed - // reads are candidates, even when the other source is also unavailable. - if (primary.valid && (!secondary.valid || primary.updatedAt >= secondary.updatedAt)) { - return _result(primary); - } - if (secondary.valid) return _result(secondary); + // A failed read has updatedAt == 0. Equal valid timestamps prefer the primary. + if (primary.updatedAt != 0 && primary.updatedAt >= secondary.updatedAt) return _result(primary); + if (secondary.updatedAt != 0) return _result(secondary); revert NoValidPrice(); } - function latestAnswer() external view returns (int256) { - (, int256 answer,,,) = latestRoundData(); - return answer; - } - - function hasFallback() external pure returns (bool) { - return true; - } - - function setHeartbeat(uint256 heartbeat) external onlyOwner { - require(heartbeat > 0, "Invalid heartbeat"); - assetToUsdHeartbeat = heartbeat; - emit NewHeartbeat(heartbeat); + function setHeartbeat(uint256 newHeartbeat) public override { + require(newHeartbeat > 0, "Invalid heartbeat"); + super.setHeartbeat(newHeartbeat); } - function setPendingOwner(address newOwner) external onlyOwner { - pendingOwner = newOwner; - emit NewPendingOwner(newOwner); + /// @dev External boundary lets _read catch decoding, bounds and normalization failures + /// as well as provider reverts, while reusing the base feed's typed calls and helpers. + function readSource(bool useFallback) external view returns (RoundData memory data) { + IChainlinkFeed feed = useFallback ? assetToUsdFallback : assetToUsd; + (data.roundId, data.answer, data.startedAt, data.updatedAt, data.answeredInRound) = feed.latestRoundData(); + if (data.answer <= 0 || data.updatedAt == 0 || data.updatedAt > block.timestamp) revert NoValidPrice(); + if (!useFallback && isPriceOutOfBounds(data.answer, feed)) revert NoValidPrice(); + data.answer = normalizePrice(data.answer, useFallback ? assetToUsdFallbackDecimals : assetToUsdDecimals); + if (data.answer <= 0) revert NoValidPrice(); } - function acceptOwner() external { - require(msg.sender == pendingOwner, "Only pending owner"); - owner = pendingOwner; - pendingOwner = address(0); - emit NewOwner(owner); + function _read(bool useFallback) internal view returns (RoundData memory data) { + if (useFallback && !hasFallback()) return data; + try this.readSource(useFallback) returns (RoundData memory result) { + return result; + } catch {} } function _isFresh(uint256 updatedAt) internal view returns (bool) { - // _read has already excluded future timestamps. return block.timestamp - updatedAt <= assetToUsdHeartbeat; } - function _read(IChainlinkFeed feed, uint8 feedDecimals, bool checkBounds) - internal - view - returns (RoundData memory data) - { - (bool success, bytes memory result) = - address(feed).staticcall(abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector)); - if (!success || result.length < 160) return data; - // Decode round IDs as full words first: malformed uint80 return values - // must invalidate only this source, rather than revert during decoding. - (uint256 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint256 answeredInRound) = - abi.decode(result, (uint256, int256, uint256, uint256, uint256)); - if (roundId > type(uint80).max || answeredInRound > type(uint80).max) return data; - if (answer <= 0 || updatedAt == 0 || updatedAt > block.timestamp) return data; - if (checkBounds && !_withinPrimaryBounds(answer)) return data; - int256 normalized = _normalize(answer, feedDecimals); - if (normalized == 0) return data; - data = RoundData(uint80(roundId), normalized, startedAt, updatedAt, uint80(answeredInRound), true); - } - - /// @dev Preserve ChainlinkBasePriceFeed's strict primary bounds. A failure - /// to read the aggregator or either bound makes only the primary unusable. - function _withinPrimaryBounds(int256 answer) internal view returns (bool) { - (bool success, uint256 aggregatorWord) = _readWord(address(assetToUsd), IChainlinkFeed.aggregator.selector); - if (!success || aggregatorWord == 0 || aggregatorWord > type(uint160).max) return false; - address aggregator = address(uint160(aggregatorWord)); - (bool minSuccess, uint256 minWord) = _readWord(aggregator, IDualFeedAggregator.minAnswer.selector); - (bool maxSuccess, uint256 maxWord) = _readWord(aggregator, IDualFeedAggregator.maxAnswer.selector); - int256 minimum = int256(minWord); - int256 maximum = int256(maxWord); - return minSuccess && maxSuccess && minimum >= type(int192).min && minimum <= type(int192).max - && maximum >= type(int192).min && maximum <= type(int192).max && minimum < maximum && answer > minimum - && answer < maximum; - } - - function _readWord(address target, bytes4 selector) internal view returns (bool, uint256) { - (bool success, bytes memory result) = target.staticcall(abi.encodeWithSelector(selector)); - if (!success || result.length < 32) return (false, 0); - return (true, abi.decode(result, (uint256))); - } - - function _normalize(int256 answer, uint8 feedDecimals) internal pure returns (int256) { - if (feedDecimals > 18) return answer / int256(10 ** (feedDecimals - 18)); - uint256 scale = 10 ** (18 - feedDecimals); - if (uint256(answer) > uint256(type(int256).max) / scale) return 0; - return answer * int256(scale); - } - function _result(RoundData memory data) internal pure returns (uint80, int256, uint256, uint256, uint80) { return (data.roundId, data.answer, data.startedAt, data.updatedAt, data.answeredInRound); } diff --git a/src/feeds/RedstoneStoredPriceFeed.sol b/src/feeds/RedstoneStoredPriceFeed.sol index 7530f1b0..a319aa7d 100644 --- a/src/feeds/RedstoneStoredPriceFeed.sol +++ b/src/feeds/RedstoneStoredPriceFeed.sol @@ -8,7 +8,7 @@ interface IRedstoneStoredAdapter { returns (uint256 dataTimestampMilliseconds, uint256 publicationTimestamp, uint256 value); } -/// @notice Reads the last stored eight-decimal RedStone ETH/USD or BTC/USD quote. +/// @notice Reads a configured RedStone feed's last stored quote without read-time expiry. /// @dev Removes the provider's read-time expiry only. Signed updates are still /// validated by RedStone before storage. Consumers must enforce their own age policy. contract RedstoneStoredPriceFeed { @@ -16,15 +16,22 @@ contract RedstoneStoredPriceFeed { IRedstoneStoredAdapter public immutable adapter; bytes32 public immutable dataFeedId; - uint8 public constant decimals = 8; - // Matches the current adapter's allowed forward clock skew at publication. - uint256 public constant MAX_DATA_TIMESTAMP_AHEAD = 1 minutes; + uint8 public immutable decimals; + uint256 public immutable maxDataTimestampAhead; string public description; - constructor(address _adapter, bytes32 _dataFeedId, string memory _description) { + constructor( + address _adapter, + bytes32 _dataFeedId, + uint8 _decimals, + uint256 _maxDataTimestampAhead, + string memory _description + ) { require(_adapter != address(0) && _dataFeedId != bytes32(0), "Invalid source"); adapter = IRedstoneStoredAdapter(_adapter); dataFeedId = _dataFeedId; + decimals = _decimals; + maxDataTimestampAhead = _maxDataTimestampAhead; description = _description; } @@ -34,7 +41,7 @@ contract RedstoneStoredPriceFeed { if ( value == 0 || value > uint256(type(int256).max) || observedAt == 0 || publishedAt == 0 || publishedAt > block.timestamp - || (observedAt > publishedAt && observedAt - publishedAt > MAX_DATA_TIMESTAMP_AHEAD) + || (observedAt > publishedAt && observedAt - publishedAt > maxDataTimestampAhead) ) revert InvalidStoredPrice(); // Publication must never make an older observation appear fresh. When diff --git a/test/DualFeedComposition.t.sol b/test/DualFeedComposition.t.sol index b32b78ca..fb05b442 100644 --- a/test/DualFeedComposition.t.sol +++ b/test/DualFeedComposition.t.sol @@ -10,7 +10,7 @@ import { DualFeedSourceMock, RedstoneAdapterMock, DualFeedCurvePoolMock, - DualFeedInvTokenMock + DualFeedTokenMock } from "test/mocks/DualFeedMocks.sol"; contract DualFeedCompositionTest is Test { @@ -22,26 +22,27 @@ contract DualFeedCompositionTest is Test { vm.warp(1000 days); primary = new DualFeedSourceMock(8); primary.setRound(2000e8, block.timestamp); - adapter = new RedstoneAdapterMock(); + adapter = new RedstoneAdapterMock(30 hours); adapter.set(block.timestamp * 1000, block.timestamp, 1900e8); - RedstoneStoredPriceFeed stored = new RedstoneStoredPriceFeed(address(adapter), bytes32("ETH"), "ETH / USD"); + RedstoneStoredPriceFeed stored = + new RedstoneStoredPriceFeed(address(adapter), bytes32("ASSET"), 8, 1 minutes, "ASSET / QUOTE"); usd = new DualFeedPriceFeed(address(this), address(primary), address(stored), 3660); } - function test_invCompositionPreservesCurveFeeAndSelectedAgeAfterExpiry() public { - DualFeedInvTokenMock inv = new DualFeedInvTokenMock(); - DualFeedCurvePoolMock pool = new DualFeedCurvePoolMock(address(inv)); - DynamicFeeCurveFeed feed = new DynamicFeeCurveFeed(address(usd), address(pool), address(inv), address(this)); + function test_curveCompositionPreservesCurveFeeAndSelectedAgeAfterExpiry() public { + DualFeedTokenMock asset = new DualFeedTokenMock(); + DualFeedCurvePoolMock pool = new DualFeedCurvePoolMock(address(asset)); + DynamicFeeCurveFeed feed = new DynamicFeeCurveFeed(address(usd), address(pool), address(asset), address(this)); (, int256 price,, uint256 timestamp,) = feed.latestRoundData(); - assertEq(price, 9.8e18); // 2000 USD/ETH * 0.005 ETH/INV * 98% + assertEq(price, 9.8e18); // 2000 QUOTE/BRIDGE * 0.005 BRIDGE/ASSET * 98% assertEq(timestamp, block.timestamp); vm.warp(block.timestamp + 1 minutes); adapter.set(block.timestamp * 1000, block.timestamp, 1900e8); uint256 fallbackTimestamp = block.timestamp; - vm.warp(block.timestamp + 30 hours); + vm.warp(block.timestamp + adapter.maxDataStaleness()); vm.expectRevert("Redstone expired"); - adapter.getLastUpdateDetails(bytes32("ETH")); + adapter.getLastUpdateDetails(bytes32("ASSET")); (, price,, timestamp,) = feed.latestRoundData(); assertEq(price, 9.31e18); assertEq(timestamp, fallbackTimestamp); @@ -49,7 +50,7 @@ contract DualFeedCompositionTest is Test { assertEq(feed.maxFee(), 2e8); } - function test_wbtcCompositionRetainsOlderConversionLegTimestamp() public { + function test_bridgeCompositionRetainsOlderConversionLegTimestamp() public { primary.setRound(50000e8, block.timestamp); DualFeedSourceMock ratio = new DualFeedSourceMock(18); ratio.setRound(0.999e18, block.timestamp - 2 hours); diff --git a/test/DualFeedMarket.t.sol b/test/DualFeedMarket.t.sol index 0afb542d..47ab5486 100644 --- a/test/DualFeedMarket.t.sol +++ b/test/DualFeedMarket.t.sol @@ -23,8 +23,9 @@ contract DualFeedMarketTest is FiRMBaseTest { callOnDepositCallback ); primary = new DualFeedSourceMock(8); - adapter = new RedstoneAdapterMock(); - RedstoneStoredPriceFeed stored = new RedstoneStoredPriceFeed(address(adapter), bytes32("ETH"), "ETH / USD"); + adapter = new RedstoneAdapterMock(30 hours); + RedstoneStoredPriceFeed stored = + new RedstoneStoredPriceFeed(address(adapter), bytes32("ASSET"), 8, 1 minutes, "ASSET / QUOTE"); dual = new DualFeedPriceFeed(gov, address(primary), address(stored), 3660); ExpiringRedstoneFeedMock expiring = new ExpiringRedstoneFeedMock(adapter); legacy = new ChainlinkBasePriceFeed(gov, address(primary), address(expiring), 3660); @@ -111,6 +112,6 @@ contract DualFeedMarketTest is FiRMBaseTest { vm.warp(block.timestamp + 4 minutes); fallbackTimestamp = block.timestamp; adapter.set(block.timestamp * 1000, block.timestamp, 900e8); - vm.warp(block.timestamp + 30 hours); + vm.warp(block.timestamp + adapter.maxDataStaleness()); } } diff --git a/test/DualFeedPriceFeed.t.sol b/test/DualFeedPriceFeed.t.sol index 9cba3ddd..0555e260 100644 --- a/test/DualFeedPriceFeed.t.sol +++ b/test/DualFeedPriceFeed.t.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {DualFeedPriceFeed} from "src/feeds/DualFeedPriceFeed.sol"; +import {ChainlinkBasePriceFeed} from "src/feeds/ChainlinkBasePriceFeed.sol"; import {DualFeedSourceMock} from "test/mocks/DualFeedMocks.sol"; contract DualFeedPriceFeedTest is Test { @@ -150,6 +151,27 @@ contract DualFeedPriceFeedTest is Test { _assertQuote(1900e18, block.timestamp, 9); } + function test_malformedSignedBoundDoesNotDisableFallback() public { + vm.mockCall(address(primary), abi.encodeWithSignature("minAnswer()"), abi.encode(uint256(1) << 191)); + _assertQuote(1900e18, block.timestamp, 9); + } + + function test_singleSourceRetainsAgeWithoutChangingLegacyBase() public { + DualFeedPriceFeed single = new DualFeedPriceFeed(address(this), address(primary), address(0), HEARTBEAT); + ChainlinkBasePriceFeed legacy = + new ChainlinkBasePriceFeed(address(this), address(primary), address(0), HEARTBEAT); + primary.setRound(2000e8, block.timestamp - 40 hours); + (, int256 answer,, uint256 timestamp,) = single.latestRoundData(); + assertEq(answer, 2000e18); + assertEq(timestamp, block.timestamp - 40 hours); + assertFalse(single.hasFallback()); + (,,, uint256 legacyTimestamp,) = legacy.latestRoundData(); + assertEq(legacyTimestamp, 0); + primary.setFailRead(true); + vm.expectRevert(DualFeedPriceFeed.NoValidPrice.selector); + single.latestRoundData(); + } + function test_overflowingFallbackQuoteDoesNotDisablePrimary() public { primary.setRound(2000e8, block.timestamp - 2 hours); secondary.setRound(type(int256).max, block.timestamp); diff --git a/test/RedstoneStoredPriceFeed.t.sol b/test/RedstoneStoredPriceFeed.t.sol index b33e42d1..3d52c328 100644 --- a/test/RedstoneStoredPriceFeed.t.sol +++ b/test/RedstoneStoredPriceFeed.t.sol @@ -3,7 +3,8 @@ pragma solidity ^0.8.20; import {Test} from "forge-std/Test.sol"; import {RedstoneStoredPriceFeed} from "src/feeds/RedstoneStoredPriceFeed.sol"; -import {RedstoneAdapterMock} from "test/mocks/DualFeedMocks.sol"; +import {DualFeedPriceFeed} from "src/feeds/DualFeedPriceFeed.sol"; +import {RedstoneAdapterMock, DualFeedSourceMock} from "test/mocks/DualFeedMocks.sol"; contract RedstoneStoredPriceFeedTest is Test { RedstoneAdapterMock adapter; @@ -11,21 +12,22 @@ contract RedstoneStoredPriceFeedTest is Test { function setUp() public { vm.warp(1000 days); - adapter = new RedstoneAdapterMock(); + adapter = new RedstoneAdapterMock(30 hours); adapter.set(block.timestamp * 1000, block.timestamp, 2000e8); - feed = new RedstoneStoredPriceFeed(address(adapter), bytes32("ETH"), "ETH / USD (stored RedStone)"); + feed = new RedstoneStoredPriceFeed(address(adapter), bytes32("ASSET"), 8, 1 minutes, "ASSET / QUOTE"); } - function test_retainsStoredPriceBeforeAtAndAfterThirtyHours() public { + function test_retainsStoredPriceBeforeAtAndAfterProviderExpiry() public { uint256 timestamp = block.timestamp; - vm.warp(timestamp + 30 hours - 1); - adapter.getLastUpdateDetails(bytes32("ETH")); + uint256 expiry = adapter.maxDataStaleness(); + vm.warp(timestamp + expiry - 1); + adapter.getLastUpdateDetails(bytes32("ASSET")); _assertQuote(2000e8, timestamp); - vm.warp(timestamp + 30 hours); + vm.warp(timestamp + expiry); vm.expectRevert("Redstone expired"); - adapter.getLastUpdateDetails(bytes32("ETH")); + adapter.getLastUpdateDetails(bytes32("ASSET")); _assertQuote(2000e8, timestamp); - vm.warp(timestamp + 30 hours + 1); + vm.warp(timestamp + expiry + 1); _assertQuote(2000e8, timestamp); vm.warp(timestamp + 90 days); _assertQuote(2000e8, timestamp); @@ -72,9 +74,56 @@ contract RedstoneStoredPriceFeedTest is Test { } function test_usesConfiguredDataFeedId() public { - RedstoneStoredPriceFeed btc = new RedstoneStoredPriceFeed(address(adapter), bytes32("BTC"), "BTC / USD"); - vm.expectCall(address(adapter), abi.encodeWithSignature("getLastUpdateDetailsUnsafe(bytes32)", bytes32("BTC"))); - btc.latestRoundData(); + RedstoneStoredPriceFeed other = + new RedstoneStoredPriceFeed(address(adapter), bytes32("OTHER"), 18, 0, "OTHER / QUOTE"); + vm.expectCall( + address(adapter), abi.encodeWithSignature("getLastUpdateDetailsUnsafe(bytes32)", bytes32("OTHER")) + ); + other.latestRoundData(); + } + + function test_configurableForwardClockSkew() public { + RedstoneStoredPriceFeed strict = + new RedstoneStoredPriceFeed(address(adapter), bytes32("ASSET"), 8, 0, "ASSET / QUOTE"); + adapter.set((block.timestamp + 1) * 1000, block.timestamp, 2000e8); + vm.expectRevert(RedstoneStoredPriceFeed.InvalidStoredPrice.selector); + strict.latestRoundData(); + + RedstoneStoredPriceFeed tolerant = + new RedstoneStoredPriceFeed(address(adapter), bytes32("ASSET"), 8, 2 minutes, "ASSET / QUOTE"); + adapter.set((block.timestamp + 2 minutes) * 1000, block.timestamp, 2000e8); + (,,, uint256 timestamp,) = tolerant.latestRoundData(); + assertEq(timestamp, block.timestamp); + adapter.set((block.timestamp + 2 minutes + 1) * 1000, block.timestamp, 2000e8); + vm.expectRevert(RedstoneStoredPriceFeed.InvalidStoredPrice.selector); + tolerant.latestRoundData(); + } + + function testFuzz_retainsStoredQuoteAcrossConfiguredProviderExpiries(uint256 expiry) public { + expiry = bound(expiry, 1, 7 days); + RedstoneAdapterMock provider = new RedstoneAdapterMock(expiry); + uint256 timestamp = block.timestamp; + provider.set(timestamp * 1000, timestamp, 2e18); + RedstoneStoredPriceFeed stored = + new RedstoneStoredPriceFeed(address(provider), bytes32("ASSET"), 18, 0, "ASSET / QUOTE"); + vm.warp(timestamp + expiry); + vm.expectRevert("Redstone expired"); + provider.getLastUpdateDetails(bytes32("ASSET")); + (, int256 value,, uint256 updatedAt,) = stored.latestRoundData(); + assertEq(value, 2e18); + assertEq(updatedAt, timestamp); + } + + function testFuzz_configuredDecimalsAreNormalizedBySelector(uint8 sourceDecimals) public { + sourceDecimals = uint8(bound(sourceDecimals, 0, 36)); + adapter.set(block.timestamp * 1000, block.timestamp, 2 * 10 ** sourceDecimals); + RedstoneStoredPriceFeed stored = + new RedstoneStoredPriceFeed(address(adapter), bytes32("ASSET"), sourceDecimals, 0, "ASSET / QUOTE"); + DualFeedSourceMock primary = new DualFeedSourceMock(8); + primary.setFailRead(true); + DualFeedPriceFeed selector = new DualFeedPriceFeed(address(this), address(primary), address(stored), 1 hours); + assertEq(stored.decimals(), sourceDecimals); + assertEq(selector.latestAnswer(), 2e18); } function _expectInvalid() internal { diff --git a/test/mocks/DualFeedMocks.sol b/test/mocks/DualFeedMocks.sol index f1e788b4..e0ea3e94 100644 --- a/test/mocks/DualFeedMocks.sol +++ b/test/mocks/DualFeedMocks.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.20; contract DualFeedSourceMock { uint8 public immutable decimals; - string public description = "ETH / USD"; + string public description = "ASSET / QUOTE"; int256 public answer; uint256 public updatedAt; uint80 public roundId = 7; @@ -64,11 +64,16 @@ contract DualFeedSourceMock { } contract RedstoneAdapterMock { + uint256 public immutable maxDataStaleness; uint256 public dataMilliseconds; uint256 public publishedAt; uint256 public value; bool public failRead; + constructor(uint256 _maxDataStaleness) { + maxDataStaleness = _maxDataStaleness; + } + function set(uint256 data_, uint256 published_, uint256 value_) external { dataMilliseconds = data_; publishedAt = published_; @@ -86,7 +91,7 @@ contract RedstoneAdapterMock { function getLastUpdateDetails(bytes32) external view returns (uint256, uint256, uint256) { require(!failRead, "Adapter failed"); - require(value > 0 && publishedAt + 30 hours > block.timestamp, "Redstone expired"); + require(value > 0 && publishedAt + maxDataStaleness > block.timestamp, "Redstone expired"); return (dataMilliseconds, publishedAt, value); } } @@ -100,7 +105,7 @@ contract ExpiringRedstoneFeedMock { } function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { - (, uint256 timestamp, uint256 value) = adapter.getLastUpdateDetails(bytes32("ETH")); + (, uint256 timestamp, uint256 value) = adapter.getLastUpdateDetails(bytes32("ASSET")); return (1, int256(value), timestamp, timestamp, 1); } } @@ -123,8 +128,8 @@ contract DualFeedCurvePoolMock { } } -contract DualFeedInvTokenMock { +contract DualFeedTokenMock { function symbol() external pure returns (string memory) { - return "INV"; + return "ASSET"; } } From 5665c6cc48c29dd72d260ec8bd2a9d762c7aed42 Mon Sep 17 00:00:00 2001 From: cryptoharry <110295612+cryptoharry@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:13:06 +0400 Subject: [PATCH 3/3] Test selected metadata and base-feed compatibility --- test/DualFeedCompatibility.t.sol | 137 +++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 test/DualFeedCompatibility.t.sol diff --git a/test/DualFeedCompatibility.t.sol b/test/DualFeedCompatibility.t.sol new file mode 100644 index 00000000..863a1dda --- /dev/null +++ b/test/DualFeedCompatibility.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {IChainlinkFeed} from "src/interfaces/IChainlinkFeed.sol"; +import {ChainlinkBasePriceFeed} from "src/feeds/ChainlinkBasePriceFeed.sol"; +import {DualFeedPriceFeed} from "src/feeds/DualFeedPriceFeed.sol"; +import {DualFeedSourceMock} from "test/mocks/DualFeedMocks.sol"; + +contract DualFeedCompatibilityTest is Test { + uint256 constant HEARTBEAT = 3660; + DualFeedSourceMock primary; + DualFeedSourceMock secondary; + ChainlinkBasePriceFeed legacy; + DualFeedPriceFeed dual; + + function setUp() public { + vm.warp(1000 days); + _configure(8, 8); + } + + function test_freshPrimaryPreservesDistinctMetadata() public { + _assertMetadata(false, false); + } + + function test_freshFallbackPreservesDistinctMetadata() public { + _assertMetadata(true, false); + } + + function test_newerStalePrimaryPreservesDistinctMetadata() public { + _assertMetadata(false, true); + } + + function test_newerStaleFallbackPreservesDistinctMetadata() public { + _assertMetadata(true, true); + } + + function testFuzz_matchesBaseForFreshPrimary( + uint8 primaryDecimals, + uint8 fallbackDecimals, + uint256 rawPrice, + uint256 age + ) public { + primaryDecimals = uint8(bound(primaryDecimals, 0, 36)); + fallbackDecimals = uint8(bound(fallbackDecimals, 0, 36)); + _configure(primaryDecimals, fallbackDecimals); + primary.setRound(_validPrice(rawPrice, primaryDecimals), block.timestamp - bound(age, 0, HEARTBEAT)); + _assertMatchesBase(7); + } + + function testFuzz_matchesBaseForFreshFallback( + uint8 primaryDecimals, + uint8 fallbackDecimals, + uint256 rawPrice, + uint256 age + ) public { + primaryDecimals = uint8(bound(primaryDecimals, 0, 36)); + fallbackDecimals = uint8(bound(fallbackDecimals, 0, 36)); + _configure(primaryDecimals, fallbackDecimals); + primary.setRound(primary.answer(), block.timestamp - HEARTBEAT - 1); + secondary.setRound(_validPrice(rawPrice, fallbackDecimals), block.timestamp - bound(age, 0, HEARTBEAT)); + _assertMatchesBase(9); + } + + function testFuzz_matchesBaseAroundPrimaryBounds( + uint8 primaryDecimals, + uint8 fallbackDecimals, + uint256 rawPrice, + bool upperBound + ) public { + primaryDecimals = uint8(bound(primaryDecimals, 0, 36)); + fallbackDecimals = uint8(bound(fallbackDecimals, 0, 36)); + _configure(primaryDecimals, fallbackDecimals); + int256 boundary = _validPrice(rawPrice, primaryDecimals); + if (upperBound) primary.setBounds(1, int192(boundary)); + else primary.setBounds(int192(boundary), type(int192).max); + + for (int256 delta = -1; delta <= 1; ++delta) { + primary.setRound(boundary + delta, block.timestamp); + bool withinBounds = upperBound ? delta < 0 : delta > 0; + _assertMatchesBase(withinBounds ? 7 : 9); + } + } + + function _configure(uint8 primaryDecimals, uint8 fallbackDecimals) internal { + primary = new DualFeedSourceMock(primaryDecimals); + secondary = new DualFeedSourceMock(fallbackDecimals); + primary.setRound(int256(2 * 10 ** primaryDecimals), block.timestamp); + secondary.setRound(int256(3 * 10 ** fallbackDecimals), block.timestamp); + secondary.setRoundId(9); + legacy = new ChainlinkBasePriceFeed(address(this), address(primary), address(secondary), HEARTBEAT); + dual = new DualFeedPriceFeed(address(this), address(primary), address(secondary), HEARTBEAT); + } + + function _validPrice(uint256 seed, uint8 sourceDecimals) internal returns (int256) { + // Keep quotes positive, nonzero after normalization, and within int192 bounds. + uint256 unit = 10 ** sourceDecimals; + return int256(bound(seed, 3 * unit, 1_000_000 * unit)); + } + + function _assertMatchesBase(uint80 expectedRound) internal view { + assertEq(_roundData(IChainlinkFeed(address(dual))), _roundData(IChainlinkFeed(address(legacy)))); + (uint80 roundId,,,,) = dual.latestRoundData(); + assertEq(roundId, expectedRound); + assertEq(dual.latestAnswer(), legacy.latestAnswer()); + } + + function _roundData(IChainlinkFeed source) internal view returns (bytes memory) { + (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = + source.latestRoundData(); + return abi.encode(roundId, answer, startedAt, updatedAt, answeredInRound); + } + + function _assertMetadata(bool useFallback, bool bothStale) internal { + uint256 primaryAt = block.timestamp - (bothStale ? 2 hours : 2 minutes); + uint256 fallbackAt = block.timestamp - (bothStale ? 3 hours : 1 minutes); + if (useFallback) primaryAt = block.timestamp - (bothStale ? 4 hours : 2 hours); + uint256 primaryStart = block.timestamp - 12 hours; + uint256 fallbackStart = block.timestamp - 24 hours; + vm.mockCall( + address(primary), + abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector), + abi.encode(uint80(17), int256(2000e8), primaryStart, primaryAt, uint80(19)) + ); + vm.mockCall( + address(secondary), + abi.encodeWithSelector(IChainlinkFeed.latestRoundData.selector), + abi.encode(uint80(31), int256(1900e8), fallbackStart, fallbackAt, uint80(37)) + ); + + bytes memory expected = useFallback + ? abi.encode(uint80(31), int256(1900e18), fallbackStart, fallbackAt, uint80(37)) + : abi.encode(uint80(17), int256(2000e18), primaryStart, primaryAt, uint80(19)); + assertEq(_roundData(IChainlinkFeed(address(dual))), expected); + assertEq(dual.latestAnswer(), useFallback ? int256(1900e18) : int256(2000e18)); + } +}