Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/dual-feed-tests.yml
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions docs/dual-feed-price-feeds.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion src/feeds/ChainlinkBasePriceFeed.sol
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ contract ChainlinkBasePriceFeed {
function latestRoundData()
public
view
virtual
returns (
uint80 roundId,
int256 usdPrice,
Expand Down Expand Up @@ -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;
}

Expand Down
72 changes: 72 additions & 0 deletions src/feeds/DualFeedPriceFeed.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
57 changes: 57 additions & 0 deletions src/feeds/RedstoneStoredPriceFeed.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading