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..bbdd40c7 --- /dev/null +++ b/docs/dual-feed-price-feeds.md @@ -0,0 +1,102 @@ +# Dual-source price feeds + +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. + +## DualFeedPriceFeed + +`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. + +Constructor: `(owner, primary, fallback, heartbeat)`. + +- `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. + +Selection: + +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. + +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. + +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. + +## RedstoneStoredPriceFeed + +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. + +Constructor: `(adapter, dataFeedId, decimals, maxDataTimestampAhead, description)`. + +| Parameter | Meaning | +| --- | --- | +| `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 + +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 +``` + +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 new file mode 100644 index 00000000..386a0e1d --- /dev/null +++ b/src/feeds/DualFeedPriceFeed.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {ChainlinkBasePriceFeed} from "src/feeds/ChainlinkBasePriceFeed.sol"; +import {IChainlinkFeed} from "src/interfaces/IChainlinkFeed.sol"; + +/// @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 { + uint80 roundId; + int256 answer; + uint256 startedAt; + uint256 updatedAt; + uint80 answeredInRound; + } + + constructor(address _owner, address _primary, address _fallback, uint256 _heartbeat) + ChainlinkBasePriceFeed(_owner, _primary, _fallback, _heartbeat) + { + require(_owner != address(0), "Invalid owner"); + require(_primary != _fallback, "Identical sources"); + require(_heartbeat > 0, "Invalid heartbeat"); + require(assetToUsdDecimals <= 36 && assetToUsdFallbackDecimals <= 36, "Unsupported decimals"); + } + + 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(true); + if (secondary.updatedAt != 0 && _isFresh(secondary.updatedAt)) 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 setHeartbeat(uint256 newHeartbeat) public override { + require(newHeartbeat > 0, "Invalid heartbeat"); + super.setHeartbeat(newHeartbeat); + } + + /// @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 _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) { + return block.timestamp - updatedAt <= assetToUsdHeartbeat; + } + + 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..a319aa7d --- /dev/null +++ b/src/feeds/RedstoneStoredPriceFeed.sol @@ -0,0 +1,57 @@ +// 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 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 { + error InvalidStoredPrice(); + + IRedstoneStoredAdapter public immutable adapter; + bytes32 public immutable dataFeedId; + uint8 public immutable decimals; + uint256 public immutable maxDataTimestampAhead; + string public 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; + } + + 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 > maxDataTimestampAhead) + ) 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/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)); + } +} diff --git a/test/DualFeedComposition.t.sol b/test/DualFeedComposition.t.sol new file mode 100644 index 00000000..fb05b442 --- /dev/null +++ b/test/DualFeedComposition.t.sol @@ -0,0 +1,71 @@ +// 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, + DualFeedTokenMock +} 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(30 hours); + adapter.set(block.timestamp * 1000, block.timestamp, 1900e8); + 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_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 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 + adapter.maxDataStaleness()); + vm.expectRevert("Redstone expired"); + adapter.getLastUpdateDetails(bytes32("ASSET")); + (, price,, timestamp,) = feed.latestRoundData(); + assertEq(price, 9.31e18); + assertEq(timestamp, fallbackTimestamp); + assertEq(feed.latestAnswer(), price); + assertEq(feed.maxFee(), 2e8); + } + + function test_bridgeCompositionRetainsOlderConversionLegTimestamp() 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..47ab5486 --- /dev/null +++ b/test/DualFeedMarket.t.sol @@ -0,0 +1,117 @@ +// 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(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); + 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 + adapter.maxDataStaleness()); + } +} diff --git a/test/DualFeedPriceFeed.t.sol b/test/DualFeedPriceFeed.t.sol new file mode 100644 index 00000000..0555e260 --- /dev/null +++ b/test/DualFeedPriceFeed.t.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: UNLICENSED +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 { + 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_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); + _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..3d52c328 --- /dev/null +++ b/test/RedstoneStoredPriceFeed.t.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {RedstoneStoredPriceFeed} from "src/feeds/RedstoneStoredPriceFeed.sol"; +import {DualFeedPriceFeed} from "src/feeds/DualFeedPriceFeed.sol"; +import {RedstoneAdapterMock, DualFeedSourceMock} from "test/mocks/DualFeedMocks.sol"; + +contract RedstoneStoredPriceFeedTest is Test { + RedstoneAdapterMock adapter; + RedstoneStoredPriceFeed feed; + + function setUp() public { + vm.warp(1000 days); + adapter = new RedstoneAdapterMock(30 hours); + adapter.set(block.timestamp * 1000, block.timestamp, 2000e8); + feed = new RedstoneStoredPriceFeed(address(adapter), bytes32("ASSET"), 8, 1 minutes, "ASSET / QUOTE"); + } + + function test_retainsStoredPriceBeforeAtAndAfterProviderExpiry() public { + uint256 timestamp = block.timestamp; + uint256 expiry = adapter.maxDataStaleness(); + vm.warp(timestamp + expiry - 1); + adapter.getLastUpdateDetails(bytes32("ASSET")); + _assertQuote(2000e8, timestamp); + vm.warp(timestamp + expiry); + vm.expectRevert("Redstone expired"); + adapter.getLastUpdateDetails(bytes32("ASSET")); + _assertQuote(2000e8, timestamp); + vm.warp(timestamp + expiry + 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 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 { + 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..e0ea3e94 --- /dev/null +++ b/test/mocks/DualFeedMocks.sol @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +contract DualFeedSourceMock { + uint8 public immutable decimals; + string public description = "ASSET / QUOTE"; + 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 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_; + 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 + maxDataStaleness > 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("ASSET")); + 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 DualFeedTokenMock { + function symbol() external pure returns (string memory) { + return "ASSET"; + } +}