diff --git a/src/adapters/source-adapters/SnapshotSource.sol b/src/adapters/lib/SnapshotSourceLib.sol similarity index 67% rename from src/adapters/source-adapters/SnapshotSource.sol rename to src/adapters/lib/SnapshotSourceLib.sol index 358743e..e10c2c5 100644 --- a/src/adapters/source-adapters/SnapshotSource.sol +++ b/src/adapters/lib/SnapshotSourceLib.sol @@ -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 { 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 @@ -45,24 +44,34 @@ 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, + uint256 maxAge + ) 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 or too old we fallback to returning the current latest round data. - if (historicalData.timestamp >= block.timestamp - maxAge()) return historicalData; + if (historicalData.timestamp >= block.timestamp - maxAge) return historicalData; return latestData; } // 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. diff --git a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol index a5acafc..d485a0d 100644 --- a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol +++ b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol @@ -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 @@ -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, @@ -48,11 +60,8 @@ 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); } /** @@ -72,13 +81,15 @@ abstract contract BoundedUnionSourceAdapter is } /** - * @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. @@ -91,32 +102,53 @@ abstract contract BoundedUnionSourceAdapter is override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) returns (int256, uint256, 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(data); + if (boundedTimestamp <= timestamp) return (boundedAnswer, boundedTimestamp, 1); - // 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); - // 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. // 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. + SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt( + boundedUnionSnapshots, boundedAnswer, boundedTimestamp, timestamp, maxTraversal, maxAge() + ); + + // Update bounded data with constrained source data. + (boundedAnswer, boundedTimestamp) = _selectBoundedPrice(data); + + // Return bounded data unless there is a newer snapshotted data that still satisfies time constraint. + if (boundedTimestamp > snapshot.timestamp || snapshot.timestamp > timestamp) { + return (boundedAnswer, boundedTimestamp, 1); + } + return (snapshot.answer, snapshot.timestamp, 1); + } + + // 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(); - (int256 boundedAnswer, uint256 boundedTimestamp) = - _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp); - return (boundedAnswer, boundedTimestamp, 1); + return data; } // Selects the appropriate price from the three sources based on the bounding tolerance and logic. - function _selectBoundedPrice(int256 cl, uint256 clT, int256 cr, uint256 crT, int256 py, uint256 pyT) - internal - view - returns (int256, uint256) - { + function _selectBoundedPrice(AllSourceData memory data) internal view returns (int256, uint256) { int256 newestVal = 0; uint256 newestT = 0; + // Unpack the data to short named variables for better code readability below. + (int256 cl, uint256 clT, int256 cr, uint256 crT, int256 py, uint256 pyT) = + (data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp); + // For each price, check if it is within tolerance of the other two. If so, check if it is the newest. if (pyT > newestT && (_withinTolerance(py, cr) || _withinTolerance(py, cl))) (newestVal, newestT) = (py, pyT); if (crT > newestT && (_withinTolerance(cr, py) || _withinTolerance(cr, cl))) (newestVal, newestT) = (cr, crT); diff --git a/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol b/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol index 8c0c18b..9e30bb5 100644 --- a/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol +++ b/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol @@ -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) { @@ -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 @@ -43,7 +54,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. @@ -57,7 +68,10 @@ abstract contract ChronicleMedianSourceAdapter is SnapshotSource { override returns (int256, uint256, uint256) { - Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt( + chronicleMedianSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal, maxAge() + ); return (snapshot.answer, snapshot.timestamp, 1); } } diff --git a/src/adapters/source-adapters/OSMSourceAdapter.sol b/src/adapters/source-adapters/OSMSourceAdapter.sol index cf4fb15..308383e 100644 --- a/src/adapters/source-adapters/OSMSourceAdapter.sol +++ b/src/adapters/source-adapters/OSMSourceAdapter.sol @@ -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 { 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. + event SourceSet(address indexed sourceOracle); constructor(IOSM source) { @@ -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. @@ -44,7 +55,7 @@ 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. @@ -57,7 +68,10 @@ abstract contract OSMSourceAdapter is SnapshotSource { override returns (int256, uint256, uint256) { - Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = OSMSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt( + osmSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal, maxAge() + ); return (snapshot.answer, snapshot.timestamp, 1); } } diff --git a/src/adapters/source-adapters/PythSourceAdapter.sol b/src/adapters/source-adapters/PythSourceAdapter.sol index 80cfaff..f0f98c3 100644 --- a/src/adapters/source-adapters/PythSourceAdapter.sol +++ b/src/adapters/source-adapters/PythSourceAdapter.sol @@ -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) { @@ -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. @@ -44,7 +55,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. @@ -58,7 +69,10 @@ abstract contract PythSourceAdapter is SnapshotSource { override returns (int256, uint256, uint256) { - Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = PythSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt( + pythSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal, maxAge() + ); return (snapshot.answer, snapshot.timestamp, 1); } diff --git a/src/adapters/source-adapters/UnionSourceAdapter.sol b/src/adapters/source-adapters/UnionSourceAdapter.sol index 85ada54..3141391 100644 --- a/src/adapters/source-adapters/UnionSourceAdapter.sol +++ b/src/adapters/source-adapters/UnionSourceAdapter.sol @@ -7,7 +7,6 @@ import {IPyth} from "../../interfaces/pyth/IPyth.sol"; import {ChainlinkSourceAdapter} from "./ChainlinkSourceAdapter.sol"; import {ChronicleMedianSourceAdapter} from "./ChronicleMedianSourceAdapter.sol"; import {PythSourceAdapter} from "./PythSourceAdapter.sol"; -import {SnapshotSource} from "./SnapshotSource.sol"; /** * @title UnionSourceAdapter contract to read data from multiple sources and return the newest. @@ -61,8 +60,9 @@ abstract contract UnionSourceAdapter is ChainlinkSourceAdapter, ChronicleMedianS /** * @notice Snapshots data from all sources that require it. */ - function snapshotData() public override(ChainlinkSourceAdapter, SnapshotSource) { - SnapshotSource.snapshotData(); + function snapshotData() public override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) { + ChronicleMedianSourceAdapter.snapshotData(); + PythSourceAdapter.snapshotData(); } /** diff --git a/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol b/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol index 130200a..1ed59d2 100644 --- a/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol +++ b/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol @@ -1,8 +1,9 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.17; +import {DiamondRootOval} from "../../DiamondRootOval.sol"; import {DecimalLib} from "../lib/DecimalLib.sol"; -import {SnapshotSource} from "./SnapshotSource.sol"; +import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol"; import {IAggregatorV3Source} from "../../interfaces/chainlink/IAggregatorV3Source.sol"; import {IUniswapAnchoredView} from "../../interfaces/compound/IUniswapAnchoredView.sol"; import {IValidatorProxy} from "../../interfaces/compound/IValidatorProxy.sol"; @@ -11,13 +12,15 @@ import {IValidatorProxy} from "../../interfaces/compound/IValidatorProxy.sol"; * @title UniswapAnchoredViewSourceAdapter contract to read data from UniswapAnchoredView and standardize it for Oval. * */ -abstract contract UniswapAnchoredViewSourceAdapter is SnapshotSource { +abstract contract UniswapAnchoredViewSourceAdapter is DiamondRootOval { IUniswapAnchoredView public immutable UNISWAP_ANCHORED_VIEW; address public immutable C_TOKEN; uint8 public immutable SOURCE_DECIMALS; IAggregatorV3Source public aggregator; + SnapshotSourceLib.Snapshot[] public uniswapAnchoredViewSnapshots; // Historical answer and timestamp snapshots. + event SourceSet(address indexed sourceOracle, address indexed cToken, uint8 indexed sourceDecimals); event AggregatorSet(address indexed aggregator); @@ -51,6 +54,14 @@ abstract contract UniswapAnchoredViewSourceAdapter is SnapshotSource { emit AggregatorSet(current); } + /** + * @notice Snapshot the current source data. + */ + function snapshotData() public virtual override { + (int256 latestAnswer, uint256 latestTimestamp) = UniswapAnchoredViewSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.snapshotData(uniswapAnchoredViewSnapshots, latestAnswer, latestTimestamp); + } + /** * @notice Returns the latest data from the source. * @return answer The latest answer in 18 decimals. @@ -75,7 +86,7 @@ abstract contract UniswapAnchoredViewSourceAdapter 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 UniswapAnchoredView does not support historical lookups so this uses SnapshotSource to get historic data. + * @dev UniswapAnchoredView 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. @@ -88,7 +99,10 @@ abstract contract UniswapAnchoredViewSourceAdapter is SnapshotSource { override returns (int256, uint256, uint256) { - Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = UniswapAnchoredViewSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt( + uniswapAnchoredViewSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal, maxAge() + ); return (snapshot.answer, snapshot.timestamp, 1); } } diff --git a/test/fork/adapters/BoundedUnionSourceAdapter.sol b/test/fork/adapters/BoundedUnionSourceAdapter.sol new file mode 100644 index 0000000..caf8843 --- /dev/null +++ b/test/fork/adapters/BoundedUnionSourceAdapter.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {CommonTest} from "../../Common.sol"; + +import {BaseController} from "../../../src/controllers/BaseController.sol"; +import {BoundedUnionSourceAdapter} from "../../../src/adapters/source-adapters/BoundedUnionSourceAdapter.sol"; +import {IAggregatorV3Source} from "../../../src/interfaces/chainlink/IAggregatorV3Source.sol"; +import {IMedian} from "../../../src/interfaces/chronicle/IMedian.sol"; +import {IPyth} from "../../../src/interfaces/pyth/IPyth.sol"; +import {MockPyth} from "../../mocks/MockPyth.sol"; +import {MockChronicleMedianSource} from "../../mocks/MockChronicleMedianSource.sol"; + +contract TestedSourceAdapter is BoundedUnionSourceAdapter, BaseController { + constructor( + IAggregatorV3Source chainlink, + IMedian chronicle, + IPyth pyth, + bytes32 pythPriceId, + uint256 boundingTolerance + ) BoundedUnionSourceAdapter(chainlink, chronicle, pyth, pythPriceId, boundingTolerance) {} +} + +contract BoundedUnionSourceAdapterTest is CommonTest { + uint256 targetBlock = 18419040; + + IAggregatorV3Source chainlink = IAggregatorV3Source(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419); + MockChronicleMedianSource chronicle; + MockPyth pyth; + bytes32 pythPriceId = 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace; + uint256 boundingTolerance = 0.1e18; + + uint256 lockWindow = 60; + uint256 maxTraversal = 10; + + TestedSourceAdapter sourceAdapter; + + function setUp() public { + vm.createSelectFork("mainnet", targetBlock); + chronicle = new MockChronicleMedianSource(); + pyth = new MockPyth(); + sourceAdapter = new TestedSourceAdapter(chainlink, chronicle, pyth, pythPriceId, boundingTolerance); + vm.makePersistent(address(sourceAdapter)); + } + + function testLookbackDoesNotFlipBackward() public { + // Set initial Pyth price 1% above current Chainlink price at current timestamp. + (, int256 chainlinkPrice,, uint256 chainlinkTime,) = chainlink.latestRoundData(); + int64 pythPrice = int64(chainlinkPrice) * 101 / 100; + pyth.setLatestPrice(pythPrice, 0, -8, block.timestamp); + + // Check that the locked price (lockWindow ago) is the same as the latest Chainlink price. + (int256 lockedAnswer, uint256 lockedTimestamp, uint256 lockedRoundId) = + sourceAdapter.tryLatestDataAt(block.timestamp - lockWindow, maxTraversal); + int256 standardizedChainlinkAnswer = chainlinkPrice * 10 ** (18 - 8); + assertTrue(lockedAnswer == standardizedChainlinkAnswer); + assertTrue(lockedTimestamp == chainlinkTime); + assertTrue(lockedRoundId == 1); // roundId not supported, hardcoded to 1. + + // Simulate unlock by snapshotting the current data and checking the price matches the latest Pyth price. + sourceAdapter.snapshotData(); // In Oval this should get automatically called via unlockLatestValue. + (int256 unlockedAnswer, uint256 unlockedTimestamp, uint256 unlockedRoundId) = + sourceAdapter.tryLatestDataAt(block.timestamp, maxTraversal); + int256 standardizedPythAnswer = int256(pythPrice) * 10 ** (18 - 8); + assertTrue(unlockedAnswer == standardizedPythAnswer); + assertTrue(unlockedTimestamp == block.timestamp); + assertTrue(unlockedRoundId == 1); // roundId not supported, hardcoded to 1. + + // Update Pyth price by additional 1% after 10 minutes. + skip(600); + int64 nextPythPrice = pythPrice * 101 / 100; + pyth.setLatestPrice(nextPythPrice, 0, -8, block.timestamp); + + // Check that the locked price (lockWindow ago) is the same as the prior Pyth price and not flipping back to the + // old Chainlink price. + (int256 nextLockedAnswer, uint256 nextLockedTimestamp, uint256 nextLockedRoundId) = + sourceAdapter.tryLatestDataAt(block.timestamp - lockWindow, maxTraversal); + assertTrue(nextLockedAnswer == standardizedPythAnswer); + assertTrue(nextLockedTimestamp == unlockedTimestamp); + assertTrue(nextLockedTimestamp > chainlinkTime); + assertTrue(nextLockedRoundId == 1); // roundId not supported, hardcoded to 1. + } +} diff --git a/test/mocks/MockChronicleMedianSource.sol b/test/mocks/MockChronicleMedianSource.sol new file mode 100644 index 0000000..ddd156a --- /dev/null +++ b/test/mocks/MockChronicleMedianSource.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {IMedian} from "../../src/interfaces/chronicle/IMedian.sol"; +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; + +contract MockChronicleMedianSource is IMedian, Ownable { + uint256 public value; + uint32 public ageValue; + + function age() external view returns (uint32) { + return ageValue; + } + + function read() external view returns (uint256) { + return value; + } + + function peek() external view returns (uint256, bool) { + return (value, true); + } + + function setLatestSourceData(uint256 _value, uint32 _age) public onlyOwner { + value = _value; + ageValue = _age; + } + + function kiss(address) external override {} +} diff --git a/test/mocks/MockPyth.sol b/test/mocks/MockPyth.sol new file mode 100644 index 0000000..ff7deba --- /dev/null +++ b/test/mocks/MockPyth.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {IPyth} from "../../src/interfaces/pyth/IPyth.sol"; + +contract MockPyth is IPyth { + int64 public price; + uint64 public conf; + int32 public expo; + uint256 public publishTime; + + function setLatestPrice(int64 _price, uint64 _conf, int32 _expo, uint256 _publishTime) public { + price = _price; + conf = _conf; + expo = _expo; + publishTime = _publishTime; + } + + function getPrice(bytes32 id) external view returns (IPyth.Price memory) { + return getPriceUnsafe(id); + } + + function getPriceUnsafe(bytes32 /* id */ ) public view returns (IPyth.Price memory) { + return IPyth.Price({price: price, conf: conf, expo: expo, publishTime: publishTime}); + } +} diff --git a/test/mocks/MockSnapshotSourceAdapter.sol b/test/mocks/MockSnapshotSourceAdapter.sol index e4e1e37..d6d3b93 100644 --- a/test/mocks/MockSnapshotSourceAdapter.sol +++ b/test/mocks/MockSnapshotSourceAdapter.sol @@ -1,9 +1,10 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.17; -import {SnapshotSource} from "../../src/adapters/source-adapters/SnapshotSource.sol"; +import {SnapshotSourceLib} from "../../src/adapters/lib/SnapshotSourceLib.sol"; +import {DiamondRootOval} from "../../src/DiamondRootOval.sol"; -abstract contract MockSnapshotSourceAdapter is SnapshotSource { +abstract contract MockSnapshotSourceAdapter is DiamondRootOval { struct SourceData { int256 answer; uint256 timestamp; @@ -11,10 +12,17 @@ abstract contract MockSnapshotSourceAdapter is SnapshotSource { SourceData[] public sourceRounds; + SnapshotSourceLib.Snapshot[] public mockSnapshots; + function publishSourceData(int256 answer, uint256 timestamp) public { sourceRounds.push(SourceData(answer, timestamp)); } + function snapshotData() public virtual override { + (int256 latestAnswer, uint256 latestTimestamp) = MockSnapshotSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.snapshotData(mockSnapshots, latestAnswer, latestTimestamp); + } + function getLatestSourceData() public view virtual override returns (int256, uint256) { SourceData memory latestData = _latestSourceData(); return (latestData.answer, latestData.timestamp); @@ -31,10 +39,18 @@ abstract contract MockSnapshotSourceAdapter is SnapshotSource { override returns (int256, uint256, uint256) { - SnapshotSource.Snapshot memory latestData = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = MockSnapshotSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory latestData = SnapshotSourceLib._tryLatestDataAt( + mockSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal, maxAge() + ); return (latestData.answer, latestData.timestamp, 1); } + function latestSnapshotData() public view returns (SnapshotSourceLib.Snapshot memory) { + if (mockSnapshots.length > 0) return mockSnapshots[mockSnapshots.length - 1]; + return SnapshotSourceLib.Snapshot(0, 0); + } + function _latestSourceData() internal view returns (SourceData memory) { if (sourceRounds.length > 0) return sourceRounds[sourceRounds.length - 1]; return SourceData(0, 0); diff --git a/test/unit/SnapshotSource.SnapshotData.sol b/test/unit/SnapshotSource.SnapshotData.sol index f5b501f..01dd781 100644 --- a/test/unit/SnapshotSource.SnapshotData.sol +++ b/test/unit/SnapshotSource.SnapshotData.sol @@ -5,7 +5,7 @@ import {CommonTest} from "../Common.sol"; import {MockSnapshotSourceAdapter} from "../mocks/MockSnapshotSourceAdapter.sol"; import {Oval} from "../../src/Oval.sol"; import {BaseController} from "../../src/controllers/BaseController.sol"; -import "forge-std/console.sol"; +import {SnapshotSourceLib} from "../../src/adapters/lib/SnapshotSourceLib.sol"; contract TestSnapshotSource is MockSnapshotSourceAdapter, Oval, BaseController {} @@ -22,10 +22,10 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { // Verify that the snapshotting did not store any data (snapshots array is empty). vm.expectRevert(); - snapshotSource.snapshots(0); + snapshotSource.mockSnapshots(0); // latestSnapshotData should return uninitialized data. - MockSnapshotSourceAdapter.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); + SnapshotSourceLib.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); assertTrue(snapshot.answer == 0 && snapshot.timestamp == 0); } @@ -35,7 +35,7 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { snapshotSource.snapshotData(); // Verify snapshotted data. - MockSnapshotSourceAdapter.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); + SnapshotSourceLib.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); assertTrue(snapshot.answer == 100 && snapshot.timestamp == 1000); } @@ -46,7 +46,7 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { snapshotSource.snapshotData(); // Verify the latest data got snapshotted. - MockSnapshotSourceAdapter.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); + SnapshotSourceLib.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); assertTrue(snapshot.answer == 200 && snapshot.timestamp == 2000); } @@ -56,11 +56,11 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { snapshotSource.snapshotData(); // Verify snapshotted data. - MockSnapshotSourceAdapter.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); + SnapshotSourceLib.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); assertTrue(snapshot.answer == 100 && snapshot.timestamp == 1000); // The first snapshots element should match the latest snapshot data. - (int256 snapshotAnswer, uint256 snapshotTimestamp) = snapshotSource.snapshots(0); + (int256 snapshotAnswer, uint256 snapshotTimestamp) = snapshotSource.mockSnapshots(0); assertTrue(snapshotAnswer == 100 && snapshotTimestamp == 1000); // Publish and snapshot the same source data again. @@ -69,7 +69,7 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { // Verify that the snapshotting did not store any new data (snapshots array still holds one element). vm.expectRevert(); - snapshotSource.snapshots(1); + snapshotSource.mockSnapshots(1); // latestSnapshotData should return the same data. snapshot = snapshotSource.latestSnapshotData(); diff --git a/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol index 2b4c88d..bbc3e7f 100644 --- a/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol +++ b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol @@ -6,9 +6,10 @@ import {IMedian} from "../../../src/interfaces/chronicle/IMedian.sol"; import {IPyth} from "../../../src/interfaces/pyth/IPyth.sol"; import {BoundedUnionSourceAdapter} from "../../../src/adapters/source-adapters/BoundedUnionSourceAdapter.sol"; +import {BaseController} from "../../../src/controllers/BaseController.sol"; import {CommonTest} from "../../Common.sol"; -contract TestBoundedUnionSource is BoundedUnionSourceAdapter { +contract TestBoundedUnionSource is BoundedUnionSourceAdapter, BaseController { constructor(address chainlink) BoundedUnionSourceAdapter( IAggregatorV3Source(chainlink), @@ -24,22 +25,20 @@ contract TestBoundedUnionSource is BoundedUnionSourceAdapter { view returns (int256, uint256) { - return _selectBoundedPrice(cl, clT, cr, crT, py, pyT); + AllSourceData memory data = AllSourceData({ + clAnswer: cl, + clTimestamp: clT, + crAnswer: cr, + crTimestamp: crT, + pyAnswer: py, + pyTimestamp: pyT + }); + return _selectBoundedPrice(data); } function withinTolerance(int256 a, int256 b) public view returns (bool) { return _withinTolerance(a, b); } - - function internalLatestData() public view override returns (int256, uint256, uint256) {} - - function internalDataAtRound(uint256 roundId) public view override returns (int256, uint256) {} - - function canUnlock(address caller, uint256 cachedLatestTimestamp) public view virtual override returns (bool) {} - - function lockWindow() public view virtual override returns (uint256) {} - function maxTraversal() public view virtual override returns (uint256) {} - function maxAge() public view virtual override returns (uint256) {} } contract MinimalChainlinkAdapter {