Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: use snapshotting library in bounded union source adapter #12

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
@@ -1,37 +1,36 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.17;

import {DiamondRootOval} from "../../DiamondRootOval.sol";

/**
* @title SnapshotSource contract to be used in conjunction with a source adapter that needs to snapshot historic data.
* @title SnapshotSourceLib library to be used by a source adapter that needs to snapshot historic data.
*/
abstract contract SnapshotSource is DiamondRootOval {
library SnapshotSourceLib {
// Snapshot records the historical answer at a specific timestamp.
struct Snapshot {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no reason really to snapshot round ID I guess? especially given any source that has the notion of a round ID also has history?

int256 answer;
uint256 timestamp;
}

Snapshot[] public snapshots; // Historical answer and timestamp snapshots.

event SnapshotTaken(uint256 snapshotIndex, uint256 indexed timestamp, int256 indexed answer);

/**
* @notice Returns the latest snapshot data.
* @param snapshots Pointer to source adapter's snapshots array.
* @return Snapshot The latest snapshot data.
*/
function latestSnapshotData() public view returns (Snapshot memory) {
function latestSnapshotData(Snapshot[] storage snapshots) internal view returns (Snapshot memory) {
if (snapshots.length > 0) return snapshots[snapshots.length - 1];
return Snapshot(0, 0);
}

/**
* @notice Snapshot the current source data.
* @param snapshots Pointer to source adapter's snapshots array.
* @param latestAnswer The latest answer from the source.
* @param latestTimestamp The timestamp of the latest answer from the source.
*/
function snapshotData() public virtual override {
(int256 answer, uint256 timestamp) = getLatestSourceData();
Snapshot memory snapshot = Snapshot(answer, timestamp);
function snapshotData(Snapshot[] storage snapshots, int256 latestAnswer, uint256 latestTimestamp) internal {
Snapshot memory snapshot = Snapshot(latestAnswer, latestTimestamp);
if (snapshot.timestamp == 0) return; // Should not store invalid data.

// We expect source timestamps to be increasing over time, but there is little we can do to recover if source
Expand All @@ -45,15 +44,20 @@ abstract contract SnapshotSource is DiamondRootOval {
emit SnapshotTaken(snapshotIndex, snapshot.timestamp, snapshot.answer);
}

function _tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) internal view returns (Snapshot memory) {
(int256 answer, uint256 _timestamp) = getLatestSourceData();
Snapshot memory latestData = Snapshot(answer, _timestamp);
function _tryLatestDataAt(
Snapshot[] storage snapshots,
int256 latestAnswer,
uint256 latestTimestamp,
uint256 timestamp,
uint256 maxTraversal
) internal view returns (Snapshot memory) {
Snapshot memory latestData = Snapshot(latestAnswer, latestTimestamp);
// In the happy path there have been no source updates since requested time, so we can return the latest data.
// We can use timestamp property as it matches the block timestamp of the latest source update.
if (latestData.timestamp <= timestamp) return latestData;

// Attempt traversing historical snapshot data. This might still be newer or uninitialized.
Snapshot memory historicalData = _searchSnapshotAt(timestamp, maxTraversal);
Snapshot memory historicalData = _searchSnapshotAt(snapshots, timestamp, maxTraversal);

// Validate returned data. If it is uninitialized we fallback to returning the current latest round data.
if (historicalData.timestamp > 0) return historicalData;
Expand All @@ -62,7 +66,11 @@ abstract contract SnapshotSource is DiamondRootOval {

// Tries finding latest snapshotted data not newer than requested timestamp. Might still return newer data than
// requested if exceeded traversal or hold uninitialized data that should be handled by the caller.
function _searchSnapshotAt(uint256 timestamp, uint256 maxTraversal) internal view returns (Snapshot memory) {
function _searchSnapshotAt(Snapshot[] storage snapshots, uint256 timestamp, uint256 maxTraversal)
internal
view
returns (Snapshot memory)
{
Snapshot memory snapshot;
uint256 traversedSnapshots = 0;
uint256 snapshotId = snapshots.length; // Will decrement when entering loop.
Expand Down
78 changes: 59 additions & 19 deletions src/adapters/source-adapters/BoundedUnionSourceAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import {SignedMath} from "openzeppelin-contracts/contracts/utils/math/SignedMath
import {IAggregatorV3Source} from "../../interfaces/chainlink/IAggregatorV3Source.sol";
import {IMedian} from "../../interfaces/chronicle/IMedian.sol";
import {IPyth} from "../../interfaces/pyth/IPyth.sol";
import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol";
import {ChainlinkSourceAdapter} from "./ChainlinkSourceAdapter.sol";
import {ChronicleMedianSourceAdapter} from "./ChronicleMedianSourceAdapter.sol";
import {PythSourceAdapter} from "./PythSourceAdapter.sol";
import {SnapshotSource} from "./SnapshotSource.sol";

/**
* @title BoundedUnionSourceAdapter contract to read data from multiple sources and return the newest, contingent on it
Expand All @@ -25,8 +25,20 @@ abstract contract BoundedUnionSourceAdapter is
ChronicleMedianSourceAdapter,
PythSourceAdapter
{
// Pack all source data into a struct to avoid stack too deep errors.
struct AllSourceData {
int256 clAnswer;
uint256 clTimestamp;
int256 crAnswer;
uint256 crTimestamp;
int256 pyAnswer;
uint256 pyTimestamp;
}

uint256 public immutable BOUNDING_TOLERANCE;

SnapshotSourceLib.Snapshot[] public boundedUnionSnapshots; // Historical answer and timestamp snapshots.

constructor(
IAggregatorV3Source chainlink,
IMedian chronicle,
Expand All @@ -48,21 +60,22 @@ abstract contract BoundedUnionSourceAdapter is
override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter)
returns (int256 answer, uint256 timestamp)
{
(int256 clAnswer, uint256 clTimestamp) = ChainlinkSourceAdapter.getLatestSourceData();
(int256 crAnswer, uint256 crTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData();
(int256 pyAnswer, uint256 pyTimestamp) = PythSourceAdapter.getLatestSourceData();

return _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp);
AllSourceData memory data = _getAllLatestSourceData();
return _selectBoundedPrice(
data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp
);
}

/**
* @notice Snapshots is a no-op for this adapter as its never used.
* @notice Snapshot the current bounded union source data.
*/
function snapshotData() public override(ChainlinkSourceAdapter, SnapshotSource) {}
function snapshotData() public override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) {
(int256 latestAnswer, uint256 latestTimestamp) = BoundedUnionSourceAdapter.getLatestSourceData();
SnapshotSourceLib.snapshotData(boundedUnionSnapshots, latestAnswer, latestTimestamp);
}

/**
* @notice Tries getting latest data as of requested timestamp. Note that for all historic lookups we simply return
* the Chainlink data as this is the only supported source that has historical data.
* @notice Tries getting latest data as of requested timestamp.
* @param timestamp The timestamp to try getting latest data at.
* @param maxTraversal The maximum number of rounds to traverse when looking for historical data.
* @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals.
Expand All @@ -74,19 +87,46 @@ abstract contract BoundedUnionSourceAdapter is
override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter)
returns (int256, uint256)
{
// Chainlink has price history, so use tryLatestDataAt to pull the most recent price that satisfies the timestamp constraint.
(int256 clAnswer, uint256 clTimestamp) = ChainlinkSourceAdapter.tryLatestDataAt(timestamp, maxTraversal);
// In the happy path there have been no source updates since requested time, so we can return the latest data.
AllSourceData memory data = _getAllLatestSourceData();
(int256 boundedAnswer, uint256 boundedTimestamp) = _selectBoundedPrice(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do _selectBoundedPrice(data)

and update _selectBoundedPrice like

    function _selectBoundedPrice(AllSourceData memory data)
        internal
        view
        returns (int256, uint256)
    {

Wdyt?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great suggestion, pushing commit for this

data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp
);
if (boundedTimestamp <= timestamp) return (boundedAnswer, boundedTimestamp);

// For Chronicle and Pyth, just pull the most recent prices and drop them if they don't satisfy the constraint.
(int256 crAnswer, uint256 crTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData();
(int256 pyAnswer, uint256 pyTimestamp) = PythSourceAdapter.getLatestSourceData();
// Chainlink has price history, so use tryLatestDataAt to pull the most recent price that satisfies the timestamp constraint.
(data.clAnswer, data.clTimestamp) = ChainlinkSourceAdapter.tryLatestDataAt(timestamp, maxTraversal);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could still be potentially optimized to avoid duplicate calls, but would require introducing version of tryLatestDataAt that accepts already fetched latest data as inputs.


// To "drop" Chronicle and Pyth, we set their timestamps to 0 (as old as possible) if they are too recent.
// "Drop" Chronicle and/or Pyth by setting their timestamps to 0 (as old as possible) if they are too recent.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the drop logic here I wanted to investigate a bit more:
consider chronicle or pyth have their second most recent values being newer than chainlink, but their newest value is too recent. the implementation below, due to setting these timestamps to zero, will result in this value not being returned, even though the snapshot for these sources might have a "better" value to return (it's newer than chainlink, but still old enough to not be filtered out by the lockWindow).

I might be missing something here on how we want to compare these kinds of sources, but given we now have the ability to snapshot & query all parts of the BoundedUnion can we not consider them equally and look within all sources to find the newest source that is old enough?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only drop the latest pyth and chronicle values if they are too recent. but we might still end up using any of these sources if they had been selected as part of snapshoted values.

// This means that they will never be used if either or both are 0.
if (crTimestamp > timestamp) crTimestamp = 0;
if (pyTimestamp > timestamp) pyTimestamp = 0;
if (data.crTimestamp > timestamp) data.crTimestamp = 0;
if (data.pyTimestamp > timestamp) data.pyTimestamp = 0;

// Bounded union prices could have been captured at snapshot that satisfies time constraint.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 great comments throughout this file

SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt(
boundedUnionSnapshots, boundedAnswer, boundedTimestamp, timestamp, maxTraversal
);

// Update bounded data with constrained source data.
(boundedAnswer, boundedTimestamp) = _selectBoundedPrice(
data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp
);

// Return bounded data unless there is a newer snapshotted data that still satisfies time constraint.
if (boundedTimestamp >= snapshot.timestamp || snapshot.timestamp > timestamp) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I've followed the logic correctly, should we have if (boundedTimestamp > snapshot.timestamp) instead, so we give preference to the snapshot data in case there is one?

I understand that at this point (boundedAnswer, boundedTimestamp) and (snapshot.answer, snapshot.timestamp) could be different values if boundedTimestamp == snapshot.timestamp, as the snapshot could have a more complete information?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this makes sense, pushed the fix

return (boundedAnswer, boundedTimestamp);
}
return (snapshot.answer, snapshot.timestamp);
}

// Internal helper to get the latest data from all sources.
function _getAllLatestSourceData() internal view returns (AllSourceData memory) {
AllSourceData memory data;
(data.clAnswer, data.clTimestamp) = ChainlinkSourceAdapter.getLatestSourceData();
(data.crAnswer, data.crTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData();
(data.pyAnswer, data.pyTimestamp) = PythSourceAdapter.getLatestSourceData();

return _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp);
return data;
}

// Selects the appropriate price from the three sources based on the bounding tolerance and logic.
Expand Down
22 changes: 18 additions & 4 deletions src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.17;

import {SnapshotSource} from "./SnapshotSource.sol";
import {DiamondRootOval} from "../../DiamondRootOval.sol";
import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol";
import {IMedian} from "../../interfaces/chronicle/IMedian.sol";
import {SafeCast} from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol";

/**
* @title ChronicleMedianSourceAdapter contract to read data from Chronicle and standardize it for Oval.
*/
abstract contract ChronicleMedianSourceAdapter is SnapshotSource {
abstract contract ChronicleMedianSourceAdapter is DiamondRootOval {
IMedian public immutable CHRONICLE_SOURCE;

SnapshotSourceLib.Snapshot[] public chronicleMedianSnapshots; // Historical answer and timestamp snapshots.

event SourceSet(address indexed sourceOracle);

constructor(IMedian _chronicleSource) {
Expand All @@ -19,6 +22,14 @@ abstract contract ChronicleMedianSourceAdapter is SnapshotSource {
emit SourceSet(address(_chronicleSource));
}

/**
* @notice Snapshot the current source data.
*/
function snapshotData() public virtual override {
(int256 latestAnswer, uint256 latestTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData();
SnapshotSourceLib.snapshotData(chronicleMedianSnapshots, latestAnswer, latestTimestamp);
}

/**
* @notice Returns the latest data from the source.
* @dev The standard chronicle implementation will revert if the latest answer is not valid when calling the read
Expand All @@ -33,7 +44,7 @@ abstract contract ChronicleMedianSourceAdapter is SnapshotSource {
/**
* @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data
* available past the requested timestamp within provided traversal limitations.
* @dev Chronicle does not support historical lookups so this uses SnapshotSource to get historic data.
* @dev Chronicle does not support historical lookups so this uses SnapshotSourceLib to get historic data.
* @param timestamp The timestamp to try getting latest data at.
* @param maxTraversal The maximum number of rounds to traverse when looking for historical data.
* @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals.
Expand All @@ -46,7 +57,10 @@ abstract contract ChronicleMedianSourceAdapter is SnapshotSource {
override
returns (int256, uint256)
{
Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal);
(int256 latestAnswer, uint256 latestTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData();
SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt(
chronicleMedianSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal
);
return (snapshot.answer, snapshot.timestamp);
}
}
21 changes: 17 additions & 4 deletions src/adapters/source-adapters/OSMSourceAdapter.sol
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.17;

import {SnapshotSource} from "./SnapshotSource.sol";
import {DiamondRootOval} from "../../DiamondRootOval.sol";
import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol";
import {IOSM} from "../../interfaces/makerdao/IOSM.sol";

/**
* @title OSMSourceAdapter contract to read data from MakerDAO OSM and standardize it for Oval.
*/
abstract contract OSMSourceAdapter is SnapshotSource {
abstract contract OSMSourceAdapter is DiamondRootOval {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice. this is more consistent now.

IOSM public immutable osmSource;

// MakerDAO performs decimal conversion in collateral adapter contracts, so all oracle prices are expected to have
// 18 decimals and we can skip decimal conversion.
uint8 public constant decimals = 18;

SnapshotSourceLib.Snapshot[] public osmSnapshots; // Historical answer and timestamp snapshots.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also this works well I think. when you need the snapshot within the adapter you simple: a) import the lib b) have a local snapshots array.


event SourceSet(address indexed sourceOracle);

constructor(IOSM source) {
Expand All @@ -22,6 +25,14 @@ abstract contract OSMSourceAdapter is SnapshotSource {
emit SourceSet(address(source));
}

/**
* @notice Snapshot the current source data.
*/
function snapshotData() public virtual override {
(int256 latestAnswer, uint256 latestTimestamp) = OSMSourceAdapter.getLatestSourceData();
SnapshotSourceLib.snapshotData(osmSnapshots, latestAnswer, latestTimestamp);
}

/**
* @notice Returns the latest data from the source.
* @return answer The latest answer in 18 decimals.
Expand All @@ -34,14 +45,16 @@ abstract contract OSMSourceAdapter is SnapshotSource {
/**
* @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data
* available past the requested timestamp within provided traversal limitations.
* @dev OSM does not support historical lookups so this uses SnapshotSource to get historic data.
* @dev OSM does not support historical lookups so this uses SnapshotSourceLib to get historic data.
* @param timestamp The timestamp to try getting latest data at.
* @param maxTraversal The maximum number of rounds to traverse when looking for historical data.
* @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals.
* @return updatedAt The timestamp of the answer.
*/
function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view override returns (int256, uint256) {
Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal);
(int256 latestAnswer, uint256 latestTimestamp) = OSMSourceAdapter.getLatestSourceData();
SnapshotSourceLib.Snapshot memory snapshot =
SnapshotSourceLib._tryLatestDataAt(osmSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal);
return (snapshot.answer, snapshot.timestamp);
}
}
21 changes: 17 additions & 4 deletions src/adapters/source-adapters/PythSourceAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@
pragma solidity 0.8.17;

import {IPyth} from "../../interfaces/pyth/IPyth.sol";
import {SnapshotSource} from "./SnapshotSource.sol";
import {DiamondRootOval} from "../../DiamondRootOval.sol";
import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol";
import {DecimalLib} from "../lib/DecimalLib.sol";

/**
* @title PythSourceAdapter contract to read data from Pyth and standardize it for Oval.
*/
abstract contract PythSourceAdapter is SnapshotSource {
abstract contract PythSourceAdapter is DiamondRootOval {
IPyth public immutable PYTH_SOURCE;
bytes32 public immutable PYTH_PRICE_ID;

SnapshotSourceLib.Snapshot[] public pythSnapshots; // Historical answer and timestamp snapshots.

event SourceSet(address indexed sourceOracle, bytes32 indexed pythPriceId);

constructor(IPyth _pyth, bytes32 _pythPriceId) {
Expand All @@ -21,6 +24,14 @@ abstract contract PythSourceAdapter is SnapshotSource {
emit SourceSet(address(_pyth), _pythPriceId);
}

/**
* @notice Snapshot the current source data.
*/
function snapshotData() public virtual override {
(int256 latestAnswer, uint256 latestTimestamp) = PythSourceAdapter.getLatestSourceData();
SnapshotSourceLib.snapshotData(pythSnapshots, latestAnswer, latestTimestamp);
}

/**
* @notice Returns the latest data from the source.
* @return answer The latest answer in 18 decimals.
Expand All @@ -34,7 +45,7 @@ abstract contract PythSourceAdapter is SnapshotSource {
/**
* @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data
* available past the requested timestamp within provided traversal limitations.
* @dev Pyth does not support historical lookups so this uses SnapshotSource to get historic data.
* @dev Pyth does not support historical lookups so this uses SnapshotSourceLib to get historic data.
* @param timestamp The timestamp to try getting latest data at.
* @param maxTraversal The maximum number of rounds to traverse when looking for historical data.
* @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals.
Expand All @@ -47,7 +58,9 @@ abstract contract PythSourceAdapter is SnapshotSource {
override
returns (int256, uint256)
{
Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal);
(int256 latestAnswer, uint256 latestTimestamp) = PythSourceAdapter.getLatestSourceData();
SnapshotSourceLib.Snapshot memory snapshot =
SnapshotSourceLib._tryLatestDataAt(pythSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal);
return (snapshot.answer, snapshot.timestamp);
}

Expand Down
Loading
Loading