From 8a21cce577e00e9ada4cd840d48d6e810ac1c31f Mon Sep 17 00:00:00 2001 From: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> Date: Mon, 20 May 2024 08:48:54 +0300 Subject: [PATCH 1/2] feat: support chainlink round id (#8) Signed-off-by: Reinis Martinsons --- src/DiamondRootOval.sol | 29 +++++++- src/Oval.sol | 22 +++++- .../BaseDestinationAdapter.sol | 4 +- .../ChainlinkDestinationAdapter.sol | 34 +++++++-- .../ChronicleMedianDestinationAdapter.sol | 6 +- .../OSMDestinationAdapter.sol | 6 +- .../PythDestinationAdapter.sol | 2 +- .../UniswapAnchoredViewDestinationAdapter.sol | 2 +- .../BoundedUnionSourceAdapter.sol | 25 ++++++- .../ChainlinkSourceAdapter.sol | 44 ++++++++--- .../ChronicleMedianSourceAdapter.sol | 15 +++- .../source-adapters/OSMSourceAdapter.sol | 20 ++++- .../source-adapters/PythSourceAdapter.sol | 15 +++- .../source-adapters/UnionSourceAdapter.sol | 27 +++++-- .../UniswapAnchoredViewSourceAdapter.sol | 20 ++++- src/controllers/BaseController.sol | 4 +- src/interfaces/IBaseOracleAdapter.sol | 2 +- src/interfaces/IOval.sol | 2 +- src/interfaces/chainlink/IAggregatorV3.sol | 10 +-- test/fork/adapters/ChainlinkSourceAdapter.sol | 57 ++++++++++---- .../adapters/ChronicleMedianSourceAdapter.sol | 22 ++++-- test/fork/adapters/OSMSourceAdapter.sol | 22 ++++-- test/fork/adapters/PythSourceAdapter.sol | 23 ++++-- test/fork/adapters/UnionSourceAdapter.sol | 19 +++-- .../UniswapAnchoredViewSourceAdapter.sol | 26 +++++-- test/mocks/MockSnapshotSourceAdapter.sol | 8 +- test/mocks/MockSourceAdapter.sol | 23 ++++-- .../unit/Oval.ChainlinkDestinationAdapter.sol | 74 +++++++++++++++---- ...Oval.ChronicleMedianDestinationAdapter.sol | 2 +- test/unit/Oval.OSMDestinationAdapter.sol | 2 +- test/unit/Oval.PythDestinationAdapter.sol | 17 ++++- ....UniswapAnchoredViewDestinationAdapter.sol | 5 +- test/unit/Oval.UnlockLatestValue.sol | 73 ++++++++++++++---- .../BoundedUnionSource.SelectBoundedPrice.sol | 4 +- 34 files changed, 515 insertions(+), 151 deletions(-) diff --git a/src/DiamondRootOval.sol b/src/DiamondRootOval.sol index f64b08b..c8223eb 100644 --- a/src/DiamondRootOval.sol +++ b/src/DiamondRootOval.sol @@ -18,6 +18,15 @@ abstract contract DiamondRootOval is IBaseController, IOval, IBaseOracleAdapter */ function getLatestSourceData() public view virtual returns (int256, uint256); + /** + * @notice Returns the requested round data from the source. + * @dev If the source does not support rounds this would return uninitialized data. + * @param roundId The roundId to retrieve the round data for. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getSourceDataAtRound(uint256 roundId) public view virtual returns (int256, uint256); + /** * @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. @@ -25,16 +34,32 @@ abstract contract DiamondRootOval is IBaseController, IOval, IBaseOracleAdapter * @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. + * @return roundId The roundId of the answer. */ - function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view virtual returns (int256, uint256); + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) + public + view + virtual + returns (int256, uint256, uint256); /** * @notice Returns the latest data from the source. Depending on when Oval was last unlocked this might * return an slightly stale value to protect the OEV from being stolen by a front runner. * @return answer The latest answer in 18 decimals. * @return updatedAt The timestamp of the answer. + * @return roundId The roundId of the answer. + */ + function internalLatestData() public view virtual returns (int256, uint256, uint256); + + /** + * @notice Returns the requested round data from the source. Depending on when Oval was last unlocked this might + * also return uninitialized value to protect the OEV from being stolen by a front runner. + * @dev If the source does not support rounds this would always return uninitialized data. + * @param roundId The roundId to retrieve the round data for. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. */ - function internalLatestData() public view virtual returns (int256, uint256); + function internalDataAtRound(uint256 roundId) public view virtual returns (int256, uint256); /** * @notice Snapshot the current source data. Is a no-op if the source does not require snapshotting. diff --git a/src/Oval.sol b/src/Oval.sol index a5d4e6c..d519d09 100644 --- a/src/Oval.sol +++ b/src/Oval.sol @@ -38,11 +38,31 @@ abstract contract Oval is DiamondRootOval { * @notice Returns latest data from source, governed by lockWindow controlling if returned data is stale. * @return answer The latest answer in 18 decimals. * @return timestamp The timestamp of the answer. + * @return roundId The roundId of the answer. */ - function internalLatestData() public view override returns (int256, uint256) { + function internalLatestData() public view override returns (int256, uint256, uint256) { // Case work: //-> If unlockLatestValue has been called within lockWindow, then return most recent price as of unlockLatestValue call. //-> If unlockLatestValue has not been called in lockWindow, then return most recent value that is at least lockWindow old. return tryLatestDataAt(Math.max(lastUnlockTime, block.timestamp - lockWindow()), maxTraversal()); } + + /** + * @notice Returns the requested round data from the source. Depending on when Oval was last unlocked this might + * also return uninitialized values to protect the OEV from being stolen by a front runner. + * @dev If the source does not support rounds this would always return uninitialized data. + * @param roundId The roundId to retrieve the round data for. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function internalDataAtRound(uint256 roundId) public view override returns (int256, uint256) { + (int256 answer, uint256 timestamp) = getSourceDataAtRound(roundId); + + // Return source data for the requested round only if it has been either explicitly or implicitly unlocked: + //-> explicit unlock when source time is not newer than the time when last unlockLatestValue has been called, or + //-> implicit unlock when source data is at least lockWindow old. + uint256 latestUnlockedTimestamp = Math.max(lastUnlockTime, block.timestamp - lockWindow()); + if (timestamp <= latestUnlockedTimestamp) return (answer, timestamp); + return (0, 0); // Source data is too recent, return uninitialized values. + } } diff --git a/src/adapters/destination-adapters/BaseDestinationAdapter.sol b/src/adapters/destination-adapters/BaseDestinationAdapter.sol index 136fd26..be3684b 100644 --- a/src/adapters/destination-adapters/BaseDestinationAdapter.sol +++ b/src/adapters/destination-adapters/BaseDestinationAdapter.sol @@ -16,7 +16,7 @@ abstract contract BaseDestinationAdapter is DiamondRootOval { * @return answer The latest answer in 18 decimals. */ function latestAnswer() public view returns (int256) { - (int256 answer,) = internalLatestData(); + (int256 answer,,) = internalLatestData(); return answer; } @@ -25,7 +25,7 @@ abstract contract BaseDestinationAdapter is DiamondRootOval { * @return timestamp The timestamp of the most recent update. */ function latestTimestamp() public view returns (uint256) { - (, uint256 timestamp) = internalLatestData(); + (, uint256 timestamp,) = internalLatestData(); return timestamp; } } diff --git a/src/adapters/destination-adapters/ChainlinkDestinationAdapter.sol b/src/adapters/destination-adapters/ChainlinkDestinationAdapter.sol index 7798437..358ae42 100644 --- a/src/adapters/destination-adapters/ChainlinkDestinationAdapter.sol +++ b/src/adapters/destination-adapters/ChainlinkDestinationAdapter.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.17; +import {SafeCast} from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; + import {DecimalLib} from "../lib/DecimalLib.sol"; import {IAggregatorV3} from "../../interfaces/chainlink/IAggregatorV3.sol"; import {DiamondRootOval} from "../../DiamondRootOval.sol"; @@ -24,7 +26,7 @@ abstract contract ChainlinkDestinationAdapter is DiamondRootOval, IAggregatorV3 * @return answer The latest answer in the configured number of decimals. */ function latestAnswer() public view override returns (int256) { - (int256 answer,) = internalLatestData(); + (int256 answer,,) = internalLatestData(); return DecimalLib.convertDecimals(answer, 18, decimals); } @@ -33,21 +35,37 @@ abstract contract ChainlinkDestinationAdapter is DiamondRootOval, IAggregatorV3 * @return timestamp The timestamp of the latest answer. */ function latestTimestamp() public view override returns (uint256) { - (, uint256 timestamp) = internalLatestData(); + (, uint256 timestamp,) = internalLatestData(); return timestamp; } /** - * @notice Returns an approximate form of the latest Round data. This does not implement the notion of "roundId" that - * the normal chainlink aggregator does and returns hardcoded values for those fields. - * @return roundId The roundId of the latest answer, hardcoded to 1. + * @notice Returns the latest Round data. + * @return roundId The roundId of the latest answer (sources that do not support it hardcodes to 1). * @return answer The latest answer in the configured number of decimals. * @return startedAt The timestamp when the value was updated. * @return updatedAt The timestamp when the value was updated. - * @return answeredInRound The roundId of the round in which the answer was computed, hardcoded to 1. + * @return answeredInRound The roundId of the round in which the answer was computed (sources that do not support it + * hardcodes to 1). */ function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) { - (int256 answer, uint256 updatedAt) = internalLatestData(); - return (1, DecimalLib.convertDecimals(answer, 18, decimals), updatedAt, updatedAt, 1); + (int256 answer, uint256 updatedAt, uint256 _roundId) = internalLatestData(); + uint80 roundId = SafeCast.toUint80(_roundId); + return (roundId, DecimalLib.convertDecimals(answer, 18, decimals), updatedAt, updatedAt, roundId); + } + + /** + * @notice Returns the requested round data if available or uninitialized values then it is too recent. + * @dev If the source does not support round data, always returns uninitialized answer and timestamp values. + * @param _roundId The roundId to retrieve the round data for. + * @return roundId The roundId of the latest answer (same as requested roundId). + * @return answer The latest answer in the configured number of decimals. + * @return startedAt The timestamp when the value was updated. + * @return updatedAt The timestamp when the value was updated. + * @return answeredInRound The roundId of the round in which the answer was computed (same as requested roundId). + */ + function getRoundData(uint80 _roundId) external view returns (uint80, int256, uint256, uint256, uint80) { + (int256 answer, uint256 updatedAt) = internalDataAtRound(_roundId); + return (_roundId, DecimalLib.convertDecimals(answer, 18, decimals), updatedAt, updatedAt, _roundId); } } diff --git a/src/adapters/destination-adapters/ChronicleMedianDestinationAdapter.sol b/src/adapters/destination-adapters/ChronicleMedianDestinationAdapter.sol index 1055348..b85e0c4 100644 --- a/src/adapters/destination-adapters/ChronicleMedianDestinationAdapter.sol +++ b/src/adapters/destination-adapters/ChronicleMedianDestinationAdapter.sol @@ -19,7 +19,7 @@ abstract contract ChronicleMedianDestinationAdapter is IMedian, DiamondRootOval * @return answer The latest answer in 18 decimals. */ function read() public view override returns (uint256) { - (int256 answer,) = internalLatestData(); + (int256 answer,,) = internalLatestData(); require(answer > 0, "Median/invalid-price-feed"); return uint256(answer); } @@ -30,7 +30,7 @@ abstract contract ChronicleMedianDestinationAdapter is IMedian, DiamondRootOval * @return valid True if the value returned is valid. */ function peek() public view override returns (uint256, bool) { - (int256 answer,) = internalLatestData(); + (int256 answer,,) = internalLatestData(); return (uint256(answer), answer > 0); } @@ -39,7 +39,7 @@ abstract contract ChronicleMedianDestinationAdapter is IMedian, DiamondRootOval * @return timestamp The timestamp of the most recent update. */ function age() public view override returns (uint32) { - (, uint256 timestamp) = internalLatestData(); + (, uint256 timestamp,) = internalLatestData(); return uint32(timestamp); } } diff --git a/src/adapters/destination-adapters/OSMDestinationAdapter.sol b/src/adapters/destination-adapters/OSMDestinationAdapter.sol index 4edec5a..5dfb25c 100644 --- a/src/adapters/destination-adapters/OSMDestinationAdapter.sol +++ b/src/adapters/destination-adapters/OSMDestinationAdapter.sol @@ -19,7 +19,7 @@ abstract contract OSMDestinationAdapter is IOSM, DiamondRootOval { function read() public view override returns (bytes32) { // MakerDAO performs decimal conversion in collateral adapter contracts, so all oracle prices are expected to // have 18 decimals, the same as returned by the internalLatestData().answer. - (int256 answer,) = internalLatestData(); + (int256 answer,,) = internalLatestData(); return bytes32(uint256(answer)); } @@ -29,7 +29,7 @@ abstract contract OSMDestinationAdapter is IOSM, DiamondRootOval { * @return valid True if the value returned is valid. */ function peek() public view override returns (bytes32, bool) { - (int256 answer,) = internalLatestData(); + (int256 answer,,) = internalLatestData(); // This might be required for MakerDAO when voiding Oracle sources. return (bytes32(uint256(answer)), answer > 0); } @@ -39,7 +39,7 @@ abstract contract OSMDestinationAdapter is IOSM, DiamondRootOval { * @return timestamp The timestamp of the most recent update. */ function zzz() public view override returns (uint64) { - (, uint256 timestamp) = internalLatestData(); + (, uint256 timestamp,) = internalLatestData(); return uint64(timestamp); } } diff --git a/src/adapters/destination-adapters/PythDestinationAdapter.sol b/src/adapters/destination-adapters/PythDestinationAdapter.sol index 3a79bf8..35a70d1 100644 --- a/src/adapters/destination-adapters/PythDestinationAdapter.sol +++ b/src/adapters/destination-adapters/PythDestinationAdapter.sol @@ -51,7 +51,7 @@ contract PythDestinationAdapter is Ownable, IPyth { if (address(idToOval[id]) == address(0)) { return basePythProvider.getPriceUnsafe(id); } - (int256 answer, uint256 timestamp) = idToOval[id].internalLatestData(); + (int256 answer, uint256 timestamp,) = idToOval[id].internalLatestData(); return Price({ price: SafeCast.toInt64(DecimalLib.convertDecimals(answer, 18, idToDecimal[id])), conf: 0, diff --git a/src/adapters/destination-adapters/UniswapAnchoredViewDestinationAdapter.sol b/src/adapters/destination-adapters/UniswapAnchoredViewDestinationAdapter.sol index e858290..5669567 100644 --- a/src/adapters/destination-adapters/UniswapAnchoredViewDestinationAdapter.sol +++ b/src/adapters/destination-adapters/UniswapAnchoredViewDestinationAdapter.sol @@ -56,7 +56,7 @@ contract UniswapAnchoredViewDestinationAdapter is Ownable, IUniswapAnchoredView if (cTokenToOval[cToken] == address(0)) { return uniswapAnchoredViewSource.getUnderlyingPrice(cToken); } - (int256 answer,) = IOval(cTokenToOval[cToken]).internalLatestData(); + (int256 answer,,) = IOval(cTokenToOval[cToken]).internalLatestData(); return DecimalLib.convertDecimals(uint256(answer), 18, cTokenToDecimal[cToken]); } diff --git a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol index 7497c0b..a5acafc 100644 --- a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol +++ b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol @@ -55,6 +55,22 @@ abstract contract BoundedUnionSourceAdapter is return _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp); } + /** + * @notice Returns the requested round data from the source. + * @dev Not all aggregated adapters support this, so this returns uninitialized data. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getSourceDataAtRound(uint256 /* roundId */ ) + public + view + virtual + override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) + returns (int256, uint256) + { + return (0, 0); + } + /** * @notice Snapshots is a no-op for this adapter as its never used. */ @@ -67,15 +83,16 @@ abstract contract BoundedUnionSourceAdapter is * @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. + * @return roundId The roundId of the answer (hardcoded to 1 as not all aggregated adapters support it). */ function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) - returns (int256, uint256) + 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); + (int256 clAnswer, uint256 clTimestamp,) = ChainlinkSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); // 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(); @@ -86,7 +103,9 @@ abstract contract BoundedUnionSourceAdapter is if (crTimestamp > timestamp) crTimestamp = 0; if (pyTimestamp > timestamp) pyTimestamp = 0; - return _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp); + (int256 boundedAnswer, uint256 boundedTimestamp) = + _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp); + return (boundedAnswer, boundedTimestamp, 1); } // Selects the appropriate price from the three sources based on the bounding tolerance and logic. diff --git a/src/adapters/source-adapters/ChainlinkSourceAdapter.sol b/src/adapters/source-adapters/ChainlinkSourceAdapter.sol index dd4241d..7fb3fae 100644 --- a/src/adapters/source-adapters/ChainlinkSourceAdapter.sol +++ b/src/adapters/source-adapters/ChainlinkSourceAdapter.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.17; +import {SafeCast} from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; + import {DecimalLib} from "../lib/DecimalLib.sol"; import {IAggregatorV3Source} from "../../interfaces/chainlink/IAggregatorV3Source.sol"; import {DiamondRootOval} from "../../DiamondRootOval.sol"; @@ -34,16 +36,17 @@ abstract contract ChainlinkSourceAdapter is DiamondRootOval { * @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. + * @return roundId The roundId of the answer. */ function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view virtual override - returns (int256, uint256) + returns (int256, uint256, uint256) { - (int256 answer, uint256 updatedAt) = _tryLatestRoundDataAt(timestamp, maxTraversal); - return (DecimalLib.convertDecimals(answer, SOURCE_DECIMALS, 18), updatedAt); + (int256 answer, uint256 updatedAt, uint80 roundId) = _tryLatestRoundDataAt(timestamp, maxTraversal); + return (DecimalLib.convertDecimals(answer, SOURCE_DECIMALS, 18), updatedAt, roundId); } /** @@ -61,21 +64,38 @@ abstract contract ChainlinkSourceAdapter is DiamondRootOval { return (DecimalLib.convertDecimals(sourceAnswer, SOURCE_DECIMALS, 18), updatedAt); } + /** + * @notice Returns the requested round data from the source. + * @dev If the source does not have the requested round it would return uninitialized data. + * @param roundId The roundId to retrieve the round data for. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getSourceDataAtRound(uint256 roundId) public view virtual override returns (int256, uint256) { + (, int256 sourceAnswer,, uint256 updatedAt,) = CHAINLINK_SOURCE.getRoundData(SafeCast.toUint80(roundId)); + return (DecimalLib.convertDecimals(sourceAnswer, SOURCE_DECIMALS, 18), updatedAt); + } + // Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data available // past the requested timestamp considering the maxTraversal limitations. - function _tryLatestRoundDataAt(uint256 timestamp, uint256 maxTraversal) internal view returns (int256, uint256) { + function _tryLatestRoundDataAt(uint256 timestamp, uint256 maxTraversal) + internal + view + returns (int256, uint256, uint80) + { (uint80 roundId, int256 answer,, uint256 updatedAt,) = CHAINLINK_SOURCE.latestRoundData(); // In the happy path there have been no source updates since requested time, so we can return the latest data. // We can use updatedAt property as it matches the block timestamp of the latest source transmission. - if (updatedAt <= timestamp) return (answer, updatedAt); + if (updatedAt <= timestamp) return (answer, updatedAt, roundId); // Attempt traversing historical round data backwards from roundId. This might still be newer or uninitialized. - (int256 historicalAnswer, uint256 historicalUpdatedAt) = _searchRoundDataAt(timestamp, roundId, maxTraversal); + (int256 historicalAnswer, uint256 historicalUpdatedAt, uint80 historicalRoundId) = + _searchRoundDataAt(timestamp, roundId, maxTraversal); // Validate returned data. If it is uninitialized we fallback to returning the current latest round data. - if (historicalUpdatedAt > 0) return (historicalAnswer, historicalUpdatedAt); - return (answer, updatedAt); + if (historicalUpdatedAt > 0) return (historicalAnswer, historicalUpdatedAt, historicalRoundId); + return (answer, updatedAt, roundId); } // Tries finding latest historical data (ignoring current roundId) not newer than requested timestamp. Might return @@ -83,7 +103,7 @@ abstract contract ChainlinkSourceAdapter is DiamondRootOval { function _searchRoundDataAt(uint256 timestamp, uint80 targetRoundId, uint256 maxTraversal) internal view - returns (int256, uint256) + returns (int256, uint256, uint80) { uint80 roundId; int256 answer; @@ -100,11 +120,11 @@ abstract contract ChainlinkSourceAdapter is DiamondRootOval { // aggregator that was not yet available on the aggregator proxy at the requested timestamp. (roundId, answer,, updatedAt,) = CHAINLINK_SOURCE.getRoundData(targetRoundId); - if (!(roundId == targetRoundId && updatedAt > 0)) return (0, 0); - if (updatedAt <= timestamp) return (answer, updatedAt); + if (!(roundId == targetRoundId && updatedAt > 0)) return (0, 0, 0); + if (updatedAt <= timestamp) return (answer, updatedAt, roundId); traversedRounds++; } - return (answer, updatedAt); // Did not find requested round. Return earliest round or uninitialized data. + return (answer, updatedAt, roundId); // Did not find requested round. Return earliest round or uninitialized data. } } diff --git a/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol b/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol index c4049ed..8c0c18b 100644 --- a/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol +++ b/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol @@ -30,6 +30,16 @@ abstract contract ChronicleMedianSourceAdapter is SnapshotSource { return (SafeCast.toInt256(CHRONICLE_SOURCE.read()), CHRONICLE_SOURCE.age()); } + /** + * @notice Returns the requested round data from the source. + * @dev Chronicle Median does not support this and returns uninitialized data. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getSourceDataAtRound(uint256 /* roundId */ ) public view virtual override returns (int256, uint256) { + return (0, 0); + } + /** * @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. @@ -38,15 +48,16 @@ abstract contract ChronicleMedianSourceAdapter is SnapshotSource { * @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. + * @return roundId The roundId of the answer (hardcoded to 1 as Chronicle Median does not support it). */ function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view virtual override - returns (int256, uint256) + returns (int256, uint256, uint256) { Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); - return (snapshot.answer, snapshot.timestamp); + return (snapshot.answer, snapshot.timestamp, 1); } } diff --git a/src/adapters/source-adapters/OSMSourceAdapter.sol b/src/adapters/source-adapters/OSMSourceAdapter.sol index f53d73e..cf4fb15 100644 --- a/src/adapters/source-adapters/OSMSourceAdapter.sol +++ b/src/adapters/source-adapters/OSMSourceAdapter.sol @@ -31,6 +31,16 @@ abstract contract OSMSourceAdapter is SnapshotSource { return (int256(uint256(osmSource.read())), osmSource.zzz()); } + /** + * @notice Returns the requested round data from the source. + * @dev OSM does not support this and returns uninitialized data. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getSourceDataAtRound(uint256 /* roundId */ ) public view virtual override returns (int256, uint256) { + return (0, 0); + } + /** * @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. @@ -39,9 +49,15 @@ abstract contract OSMSourceAdapter is SnapshotSource { * @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. + * @return roundId The roundId of the answer (hardcoded to 1 as OSM does not support it). */ - function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view override returns (int256, uint256) { + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) + public + view + override + returns (int256, uint256, uint256) + { Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); - return (snapshot.answer, snapshot.timestamp); + return (snapshot.answer, snapshot.timestamp, 1); } } diff --git a/src/adapters/source-adapters/PythSourceAdapter.sol b/src/adapters/source-adapters/PythSourceAdapter.sol index c9e0f2d..80cfaff 100644 --- a/src/adapters/source-adapters/PythSourceAdapter.sol +++ b/src/adapters/source-adapters/PythSourceAdapter.sol @@ -31,6 +31,16 @@ abstract contract PythSourceAdapter is SnapshotSource { return (_convertDecimalsWithExponent(pythPrice.price, pythPrice.expo), pythPrice.publishTime); } + /** + * @notice Returns the requested round data from the source. + * @dev Pyth does not support this and returns uninitialized data. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getSourceDataAtRound(uint256 /* roundId */ ) public view virtual override returns (int256, uint256) { + return (0, 0); + } + /** * @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. @@ -39,16 +49,17 @@ abstract contract PythSourceAdapter is SnapshotSource { * @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. + * @return roundId The roundId of the answer (hardcoded to 1 as Pyth does not support it). */ function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view virtual override - returns (int256, uint256) + returns (int256, uint256, uint256) { Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); - return (snapshot.answer, snapshot.timestamp); + return (snapshot.answer, snapshot.timestamp, 1); } // Handle a per-price "expo" (decimal) value from pyth. diff --git a/src/adapters/source-adapters/UnionSourceAdapter.sol b/src/adapters/source-adapters/UnionSourceAdapter.sol index 76a25d0..85ada54 100644 --- a/src/adapters/source-adapters/UnionSourceAdapter.sol +++ b/src/adapters/source-adapters/UnionSourceAdapter.sol @@ -42,6 +42,22 @@ abstract contract UnionSourceAdapter is ChainlinkSourceAdapter, ChronicleMedianS else return (pyAnswer, pyTimestamp); } + /** + * @notice Returns the requested round data from the source. + * @dev Not all aggregated adapters support this, so this returns uninitialized data. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getSourceDataAtRound(uint256 /* roundId */ ) + public + view + virtual + override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) + returns (int256, uint256) + { + return (0, 0); + } + /** * @notice Snapshots data from all sources that require it. */ @@ -56,15 +72,16 @@ abstract contract UnionSourceAdapter is ChainlinkSourceAdapter, ChronicleMedianS * @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. + * @return roundId The roundId of the answer (hardcoded to 1 as not all aggregated adapters support it). */ function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) - returns (int256, uint256) + returns (int256, uint256, uint256) { // Chainlink has price history, so just use tryLatestDataAt to pull the most recent price that satisfies the timestamp constraint. - (int256 clAnswer, uint256 clTimestamp) = ChainlinkSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); + (int256 clAnswer, uint256 clTimestamp,) = ChainlinkSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); // 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(); @@ -77,8 +94,8 @@ abstract contract UnionSourceAdapter is ChainlinkSourceAdapter, ChronicleMedianS // This if/else block matches the one in getLatestSourceData, since it is now just looking for the most recent // timestamp, as all prices that violate the input constraint have had their timestamps set to 0. - if (clTimestamp >= crTimestamp && clTimestamp >= pyTimestamp) return (clAnswer, clTimestamp); - else if (crTimestamp >= pyTimestamp) return (crAnswer, crTimestamp); - else return (pyAnswer, pyTimestamp); + if (clTimestamp >= crTimestamp && clTimestamp >= pyTimestamp) return (clAnswer, clTimestamp, 1); + else if (crTimestamp >= pyTimestamp) return (crAnswer, crTimestamp, 1); + else return (pyAnswer, pyTimestamp, 1); } } diff --git a/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol b/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol index 0ebe586..130200a 100644 --- a/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol +++ b/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol @@ -62,6 +62,16 @@ abstract contract UniswapAnchoredViewSourceAdapter is SnapshotSource { return (DecimalLib.convertDecimals(sourcePrice, SOURCE_DECIMALS, 18), latestTimestamp); } + /** + * @notice Returns the requested round data from the source. + * @dev UniswapAnchoredView does not support this and returns uninitialized data. + * @return answer Round answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getSourceDataAtRound(uint256 /* roundId */ ) public view virtual override returns (int256, uint256) { + return (0, 0); + } + /** * @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. @@ -70,9 +80,15 @@ abstract contract UniswapAnchoredViewSourceAdapter is SnapshotSource { * @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. + * @return roundId The roundId of the answer (hardcoded to 1 as UniswapAnchoredView does not support it). */ - function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view override returns (int256, uint256) { + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) + public + view + override + returns (int256, uint256, uint256) + { Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); - return (snapshot.answer, snapshot.timestamp); + return (snapshot.answer, snapshot.timestamp, 1); } } diff --git a/src/controllers/BaseController.sol b/src/controllers/BaseController.sol index 9000ca7..b4b7504 100644 --- a/src/controllers/BaseController.sol +++ b/src/controllers/BaseController.sol @@ -45,12 +45,12 @@ abstract contract BaseController is Ownable, Oval { * @param newLockWindow The lockWindow to set. */ function setLockWindow(uint256 newLockWindow) public onlyOwner { - (int256 currentAnswer, uint256 currentTimestamp) = internalLatestData(); + (int256 currentAnswer, uint256 currentTimestamp,) = internalLatestData(); lockWindow_ = newLockWindow; // Compare Oval results so that change in lock window does not change returned data. - (int256 newAnswer, uint256 newTimestamp) = internalLatestData(); + (int256 newAnswer, uint256 newTimestamp,) = internalLatestData(); require(currentAnswer == newAnswer && currentTimestamp == newTimestamp, "Must unlock first"); emit LockWindowSet(newLockWindow); diff --git a/src/interfaces/IBaseOracleAdapter.sol b/src/interfaces/IBaseOracleAdapter.sol index 0552fd0..c94378d 100644 --- a/src/interfaces/IBaseOracleAdapter.sol +++ b/src/interfaces/IBaseOracleAdapter.sol @@ -5,7 +5,7 @@ interface IBaseOracleAdapter { function tryLatestDataAt(uint256 _timestamp, uint256 _maxTraversal) external view - returns (int256 answer, uint256 timestamp); + returns (int256 answer, uint256 timestamp, uint256 roundId); function getLatestSourceData() external view returns (int256 answer, uint256 timestamp); } diff --git a/src/interfaces/IOval.sol b/src/interfaces/IOval.sol index 548120d..256ab77 100644 --- a/src/interfaces/IOval.sol +++ b/src/interfaces/IOval.sol @@ -4,5 +4,5 @@ pragma solidity 0.8.17; interface IOval { event LatestValueUnlocked(uint256 indexed timestamp); - function internalLatestData() external view returns (int256 answer, uint256 timestamp); + function internalLatestData() external view returns (int256 answer, uint256 timestamp, uint256 roundId); } diff --git a/src/interfaces/chainlink/IAggregatorV3.sol b/src/interfaces/chainlink/IAggregatorV3.sol index 5bb8be5..3c2c6cb 100644 --- a/src/interfaces/chainlink/IAggregatorV3.sol +++ b/src/interfaces/chainlink/IAggregatorV3.sol @@ -13,6 +13,11 @@ interface IAggregatorV3 { view returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); + function getRoundData(uint80 _roundId) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); + // Other Chainlink functions we don't need. // function latestRound() external view returns (uint256); @@ -25,11 +30,6 @@ interface IAggregatorV3 { // function version() external view returns (uint256); - // function getRoundData(uint80 _roundId) - // external - // view - // returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); - // event AnswerUpdated(int256 indexed current, uint256 indexed roundId, uint256 updatedAt); // event NewRound(uint256 indexed roundId, address indexed startedBy, uint256 startedAt); diff --git a/test/fork/adapters/ChainlinkSourceAdapter.sol b/test/fork/adapters/ChainlinkSourceAdapter.sol index 096221f..6ec2c96 100644 --- a/test/fork/adapters/ChainlinkSourceAdapter.sol +++ b/test/fork/adapters/ChainlinkSourceAdapter.sol @@ -11,7 +11,9 @@ import {IAggregatorV3Source} from "../../../src/interfaces/chainlink/IAggregator contract TestedSourceAdapter is ChainlinkSourceAdapter { constructor(IAggregatorV3Source source) ChainlinkSourceAdapter(source) {} - function internalLatestData() public view override returns (int256, uint256) {} + 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) {} @@ -39,32 +41,44 @@ contract ChainlinkSourceAdapterTest is CommonTest { assertTrue(latestSourceTimestamp == latestChainlinkTimestamp); } + function testCorrectlyStandardizesRoundOutputs() public { + (uint80 latestRound,,,,) = chainlink.latestRoundData(); + (, int256 chainlinkAnswer,, uint256 chainlinkTimestamp,) = chainlink.getRoundData(latestRound); + (int256 sourceAnswer, uint256 sourceTimestamp) = sourceAdapter.getSourceDataAtRound(latestRound); + assertTrue(scaleChainlinkTo18(chainlinkAnswer) == sourceAnswer); + assertTrue(sourceTimestamp == chainlinkTimestamp); + } + function testCorrectlyLooksBackThroughRounds() public { // Try fetching the price an hour before. At the sample data block there was not a lot of price action and one // hour ago is simply the previous round (there was only one update in that interval due to chainlink heartbeat) uint256 targetTime = block.timestamp - 1 hours; (uint80 latestRound,,,,) = chainlink.latestRoundData(); - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 10); - (, int256 answer, uint256 startedAt,,) = chainlink.getRoundData(latestRound - 1); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(targetTime, 10); + (uint80 roundId, int256 answer, uint256 startedAt,,) = chainlink.getRoundData(latestRound - 1); assertTrue(startedAt <= targetTime); // The time from the chainlink source is at least 1 hours old. assertTrue(scaleChainlinkTo18(answer) == lookBackPrice); assertTrue(startedAt == lookBackTimestamp); + assertTrue(uint256(roundId) == lookBackRoundId); // Next, try looking back 2 hours. Equally, we should get the price from 2 rounds ago. targetTime = block.timestamp - 2 hours; - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 10); - (, answer, startedAt,,) = chainlink.getRoundData(latestRound - 2); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = sourceAdapter.tryLatestDataAt(targetTime, 10); + (roundId, answer, startedAt,,) = chainlink.getRoundData(latestRound - 2); assertTrue(startedAt <= targetTime); // The time from the chainlink source is at least 2 hours old. assertTrue(scaleChainlinkTo18(answer) == lookBackPrice); assertTrue(startedAt == lookBackTimestamp); + assertTrue(uint256(roundId) == lookBackRoundId); // Now, try 3 hours old. again, The value should be at least 3 hours old. However, for this lookback the chainlink // souce was updated 2x in the interval. Therefore, we should get the price from 4 rounds ago. targetTime = block.timestamp - 3 hours; - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 10); - (, answer, startedAt,,) = chainlink.getRoundData(latestRound - 4); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = sourceAdapter.tryLatestDataAt(targetTime, 10); + (roundId, answer, startedAt,,) = chainlink.getRoundData(latestRound - 4); assertTrue(startedAt <= block.timestamp - 3 hours); // The time from the chainlink source is at least 3 hours old. assertTrue(startedAt > block.timestamp - 4 hours); // Time from chainlink source is at not more than 4 hours. + assertTrue(uint256(roundId) == lookBackRoundId); } function testCorrectlyBoundsMaxLookBack() public { @@ -72,31 +86,37 @@ contract ChainlinkSourceAdapterTest is CommonTest { // that limit. From the previous tests we showed that looking back 2 hours should return the price from round 2. // If we try look back longer than this we should get the price from round 2, no matter how far we look back. uint256 targetTime = block.timestamp - 2 hours; - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 2); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(targetTime, 2); (uint80 latestRound,,,,) = chainlink.latestRoundData(); - (, int256 answer, uint256 startedAt,,) = chainlink.getRoundData(latestRound - 2); + (uint80 roundId, int256 answer, uint256 startedAt,,) = chainlink.getRoundData(latestRound - 2); assertTrue(scaleChainlinkTo18(answer) == lookBackPrice); assertTrue(startedAt == lookBackTimestamp); + assertTrue(uint256(roundId) == lookBackRoundId); // Now, lookback longer than 2 hours. should get the same value as before. targetTime = block.timestamp - 3 hours; - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 2); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = sourceAdapter.tryLatestDataAt(targetTime, 2); assertTrue(scaleChainlinkTo18(answer) == lookBackPrice); assertTrue(startedAt == lookBackTimestamp); + assertTrue(uint256(roundId) == lookBackRoundId); targetTime = block.timestamp - 10 hours; - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 2); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = sourceAdapter.tryLatestDataAt(targetTime, 2); assertTrue(scaleChainlinkTo18(answer) == lookBackPrice); assertTrue(startedAt == lookBackTimestamp); + assertTrue(uint256(roundId) == lookBackRoundId); } function testNonHistoricalData() public { uint256 targetTime = block.timestamp - 1 hours; - (, int256 answer,, uint256 updatedAt,) = chainlink.latestRoundData(); + (uint80 roundId, int256 answer,, uint256 updatedAt,) = chainlink.latestRoundData(); - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 0); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(targetTime, 0); assertEq(lookBackPrice / 10 ** 10, answer); assertEq(lookBackTimestamp, updatedAt); + assertEq(uint256(roundId), lookBackRoundId); } function testMismatchedRoundId() public { @@ -108,13 +128,22 @@ contract ChainlinkSourceAdapterTest is CommonTest { abi.encode(latestRound, 1000, block.timestamp - 5 days, block.timestamp, latestRound) ); - (int256 resultPrice, uint256 resultTimestamp) = sourceAdapter.tryLatestDataAt(block.timestamp - 2 hours, 10); + (int256 resultPrice, uint256 resultTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(block.timestamp - 2 hours, 10); (, int256 latestAnswer,, uint256 latestUpdatedAt,) = chainlink.latestRoundData(); // Check if the return value matches the latest round data, given the fallback logic in _tryLatestRoundDataAt assertTrue(resultPrice == DecimalLib.convertDecimals(latestAnswer, 8, 18)); assertTrue(resultTimestamp == latestUpdatedAt); + assertTrue(uint256(latestRound) == lookBackRoundId); + } + + function testNonExistentRoundData() public { + (uint80 latestRound,,,,) = chainlink.latestRoundData(); + (int256 sourceAnswer, uint256 sourceTimestamp) = sourceAdapter.getSourceDataAtRound(latestRound + 1); + assertTrue(sourceAnswer == 0); + assertTrue(sourceTimestamp == 0); } function scaleChainlinkTo18(int256 input) public pure returns (int256) { diff --git a/test/fork/adapters/ChronicleMedianSourceAdapter.sol b/test/fork/adapters/ChronicleMedianSourceAdapter.sol index 2a1233d..1c184f7 100644 --- a/test/fork/adapters/ChronicleMedianSourceAdapter.sol +++ b/test/fork/adapters/ChronicleMedianSourceAdapter.sol @@ -8,7 +8,8 @@ import {IMedian} from "../../../src/interfaces/chronicle/IMedian.sol"; contract TestedSourceAdapter is ChronicleMedianSourceAdapter { constructor(IMedian source) ChronicleMedianSourceAdapter(source) {} - function internalLatestData() public view override returns (int256, uint256) {} + 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) {} @@ -52,9 +53,11 @@ contract ChronicleMedianSourceAdapterTest is CommonTest { assertTrue(latestChronicleTimestamp > targetTime); // Chronicle does not support historical lookups so this should still return latest data without snapshotting. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 100); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(targetTime, 100); assertTrue(int256(latestChronicleAnswer) == lookBackPrice); assertTrue(latestChronicleTimestamp == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testCorrectlyLooksBackThroughSnapshots() public { @@ -62,18 +65,24 @@ contract ChronicleMedianSourceAdapterTest is CommonTest { for (uint256 i = 0; i < snapshotAnswers.length; i++) { // Lookback at exact snapshot timestamp should return the same answer and timestamp. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i], 10); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i], 10); assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. // Source updates were more than 1 hour apart, so lookback 1 hour later should return the same answer. - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] + 3600, 10); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] + 3600, 10); assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. // Source updates were more than 1 hour apart, so lookback 1 hour earlier should return the previous answer, // except for the first snapshot which should return the same answer as it does not have earlier data. - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] - 3600, 10); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] - 3600, 10); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. if (i > 0) { assertTrue(int256(snapshotAnswers[i - 1]) == lookBackPrice); assertTrue(snapshotTimestamps[i - 1] == lookBackTimestamp); @@ -90,11 +99,12 @@ contract ChronicleMedianSourceAdapterTest is CommonTest { // If we limit how far we can lookback the source adapter snapshot should correctly return the oldest data it // can find, up to that limit. When searching for the earliest possible snapshot while limiting maximum snapshot // traversal to 1 we should still get the latest data. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(0, 1); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = sourceAdapter.tryLatestDataAt(0, 1); uint256 latestChronicleAnswer = chronicle.read(); uint256 latestChronicleTimestamp = chronicle.age(); assertTrue(int256(latestChronicleAnswer) == lookBackPrice); assertTrue(latestChronicleTimestamp == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function _whitelistOnChronicle() internal { diff --git a/test/fork/adapters/OSMSourceAdapter.sol b/test/fork/adapters/OSMSourceAdapter.sol index eafff12..b9d34e6 100644 --- a/test/fork/adapters/OSMSourceAdapter.sol +++ b/test/fork/adapters/OSMSourceAdapter.sol @@ -9,7 +9,8 @@ import {IMedian} from "../../../src/interfaces/chronicle/IMedian.sol"; contract TestedSourceAdapter is OSMSourceAdapter { constructor(IOSM source) OSMSourceAdapter(source) {} - function internalLatestData() public view override returns (int256, uint256) {} + 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) {} @@ -53,9 +54,11 @@ contract OSMSourceAdapterTest is CommonTest { assertTrue(latestOSMTimestamp > targetTime); // OSM does not support historical lookups so this should still return latest data without snapshotting. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 100); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(targetTime, 100); assertTrue(int256(uint256(latestOSMAnswer)) == lookBackPrice); assertTrue(latestOSMTimestamp == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testCorrectlyLooksBackThroughSnapshots() public { @@ -63,18 +66,24 @@ contract OSMSourceAdapterTest is CommonTest { for (uint256 i = 0; i < snapshotAnswers.length; i++) { // Lookback at exact snapshot timestamp should return the same answer and timestamp. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i], 10); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i], 10); assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. // Source updates were ~1 hour apart, so lookback 10 minutes later should return the same answer. - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] + 600, 10); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] + 600, 10); assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. // Source updates were ~1 hour apart, so lookback 10 minutes earlier should return the previous answer, // except for the first snapshot which should return the same answer as it does not have earlier data. - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] - 600, 10); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] - 600, 10); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. if (i > 0) { assertTrue(int256(snapshotAnswers[i - 1]) == lookBackPrice); assertTrue(snapshotTimestamps[i - 1] == lookBackTimestamp); @@ -91,11 +100,12 @@ contract OSMSourceAdapterTest is CommonTest { // If we limit how far we can lookback the source adapter snapshot should correctly return the oldest data it // can find, up to that limit. When searching for the earliest possible snapshot while limiting maximum snapshot // traversal to 1 we should still get the latest data. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(0, 1); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = sourceAdapter.tryLatestDataAt(0, 1); bytes32 latestOSMAnswer = osm.read(); uint64 latestOSMTimestamp = osm.zzz(); assertTrue(int256(uint256(latestOSMAnswer)) == lookBackPrice); assertTrue(latestOSMTimestamp == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function _whitelistOnOSM() internal { diff --git a/test/fork/adapters/PythSourceAdapter.sol b/test/fork/adapters/PythSourceAdapter.sol index ecfcdaa..55c4f0d 100644 --- a/test/fork/adapters/PythSourceAdapter.sol +++ b/test/fork/adapters/PythSourceAdapter.sol @@ -9,7 +9,9 @@ import {IPyth} from "../../../src/interfaces/pyth/IPyth.sol"; contract TestedSourceAdapter is PythSourceAdapter { constructor(IPyth source, bytes32 priceId) PythSourceAdapter(source, priceId) {} - function internalLatestData() public view override returns (int256, uint256) {} + 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) {} @@ -52,9 +54,11 @@ contract PythSourceAdapterTest is CommonTest { assertTrue(latestPythPrice.publishTime > targetTime); // Pyth does not support historical lookups so this should still return latest data without snapshotting. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 100); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(targetTime, 100); assertTrue(_scalePythTo18(latestPythPrice) == lookBackPrice); assertTrue(latestPythPrice.publishTime == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testCorrectlyLooksBackThroughSnapshots() public { @@ -62,18 +66,24 @@ contract PythSourceAdapterTest is CommonTest { for (uint256 i = 0; i < snapshotAnswers.length; i++) { // Lookback at exact snapshot timestamp should return the same answer and timestamp. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i], 10); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i], 10); assertTrue(snapshotAnswers[i] == lookBackPrice); assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. // Source updates were more than 1 minute apart, so lookback 1 minute later should return the same answer. - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] + 60, 10); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] + 60, 10); assertTrue(snapshotAnswers[i] == lookBackPrice); assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. // Source updates were more than 1 minute apart, so lookback 1 minute earlier should return the previous answer, // except for the first snapshot which should return the same answer as it does not have earlier data. - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] - 60, 10); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] - 60, 10); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. if (i > 0) { assertTrue(snapshotAnswers[i - 1] == lookBackPrice); assertTrue(snapshotTimestamps[i - 1] == lookBackTimestamp); @@ -90,10 +100,11 @@ contract PythSourceAdapterTest is CommonTest { // If we limit how far we can lookback the source adapter snapshot should correctly return the oldest data it // can find, up to that limit. When searching for the earliest possible snapshot while limiting maximum snapshot // traversal to 1 we should still get the latest data. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(0, 1); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = sourceAdapter.tryLatestDataAt(0, 1); IPyth.Price memory latestPythPrice = pyth.getPriceUnsafe(priceId); assertTrue(_scalePythTo18(latestPythPrice) == lookBackPrice); assertTrue(latestPythPrice.publishTime == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testPositiveExpo() public { diff --git a/test/fork/adapters/UnionSourceAdapter.sol b/test/fork/adapters/UnionSourceAdapter.sol index 34d4b33..262e9c4 100644 --- a/test/fork/adapters/UnionSourceAdapter.sol +++ b/test/fork/adapters/UnionSourceAdapter.sol @@ -13,7 +13,9 @@ contract TestedSourceAdapter is UnionSourceAdapter { UnionSourceAdapter(chainlink, chronicle, pyth, pythPriceId) {} - function internalLatestData() public view override returns (int256, uint256) {} + 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) {} @@ -129,10 +131,11 @@ contract UnionSourceAdapterTest is CommonTest { assertTrue(latest.pyth.timestamp == historic.pyth.timestamp); // As no sources had updated we still expect historic union to match chainlink. - (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp) = + (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp, uint256 lookBackRoundId) = sourceAdapter.tryLatestDataAt(targetTimestamp, 10); assertTrue(lookbackUnionAnswer == historic.chainlink.answer); assertTrue(lookbackUnionTimestamp == historic.chainlink.timestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testLookbackChronicle() public { @@ -160,10 +163,11 @@ contract UnionSourceAdapterTest is CommonTest { assertTrue(latest.pyth.timestamp == historic.pyth.timestamp); // As no sources had updated we still expect historic union to match chronicle. - (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp) = + (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp, uint256 lookBackRoundId) = sourceAdapter.tryLatestDataAt(targetTimestamp, 10); assertTrue(lookbackUnionAnswer == historic.chronicle.answer); assertTrue(lookbackUnionTimestamp == historic.chronicle.timestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testLookbackDropChronicle() public { @@ -190,10 +194,11 @@ contract UnionSourceAdapterTest is CommonTest { // We cannot lookback to the historic timestamp as chronicle does not support historical lookups. // So we expect union lookback to fallback to chainlink. - (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp) = + (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp, uint256 lookBackRoundId) = sourceAdapter.tryLatestDataAt(targetTimestamp, 100); assertTrue(lookbackUnionAnswer == historic.chainlink.answer); assertTrue(lookbackUnionTimestamp == historic.chainlink.timestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testLookbackPyth() public { @@ -221,10 +226,11 @@ contract UnionSourceAdapterTest is CommonTest { assertTrue(latest.pyth.timestamp == historic.pyth.timestamp); // As no sources had updated we still expect historic union to match pyth. - (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp) = + (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp, uint256 lookBackRoundId) = sourceAdapter.tryLatestDataAt(targetTimestamp, 10); assertTrue(lookbackUnionAnswer == historic.pyth.answer); assertTrue(lookbackUnionTimestamp == historic.pyth.timestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testLookbackDropPyth() public { @@ -251,10 +257,11 @@ contract UnionSourceAdapterTest is CommonTest { // We cannot lookback to the historic timestamp as pyth does not support historical lookups. // So we expect union lookback to fallback to chainlink. - (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp) = + (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp, uint256 lookBackRoundId) = sourceAdapter.tryLatestDataAt(targetTimestamp, 100); assertTrue(lookbackUnionAnswer == historic.chainlink.answer); assertTrue(lookbackUnionTimestamp == historic.chainlink.timestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function _convertDecimalsWithExponent(int256 answer, int32 expo) internal pure returns (int256) { diff --git a/test/fork/adapters/UniswapAnchoredViewSourceAdapter.sol b/test/fork/adapters/UniswapAnchoredViewSourceAdapter.sol index 596a575..d04c3e7 100644 --- a/test/fork/adapters/UniswapAnchoredViewSourceAdapter.sol +++ b/test/fork/adapters/UniswapAnchoredViewSourceAdapter.sol @@ -12,7 +12,8 @@ import {IUniswapAnchoredView} from "../../../src/interfaces/compound/IUniswapAnc contract TestedSourceAdapter is UniswapAnchoredViewSourceAdapter { constructor(IUniswapAnchoredView source, address cToken) UniswapAnchoredViewSourceAdapter(source, cToken) {} - function internalLatestData() public view override returns (int256, uint256) {} + 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) {} @@ -80,12 +81,14 @@ contract UniswapAnchoredViewSourceAdapterTest is CommonTest { // UniswapAnchoredView does not support historical lookups so this should still return latest data without snapshotting. uint256 latestUniswapAnchoredViewAnswer = uniswapAnchoredView.getUnderlyingPrice(cWBTC); uint256 latestAggregatorTimestamp = aggregator.latestTimestamp(); - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 100); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(targetTime, 100); // WBTC has 8 decimals, so source price feed is scaled at (36 - 8) = 28 decimals. uint256 standardizedAnswer = latestUniswapAnchoredViewAnswer / 10 ** (28 - 18); assertTrue(int256(standardizedAnswer) == lookBackPrice); assertTrue(latestAggregatorTimestamp == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testReturnsLatestSourceDataNoSnapshot() public { @@ -102,9 +105,11 @@ contract UniswapAnchoredViewSourceAdapterTest is CommonTest { assertTrue(latestAggregatorTimestamp > targetTime); // UniswapAnchoredView does not support historical lookups so this should still return latest data without snapshotting. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(targetTime, 100); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(targetTime, 100); assertTrue(int256(latestUniswapAnchoredViewAnswer) == lookBackPrice); assertTrue(latestAggregatorTimestamp == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testCorrectlyLooksBackThroughSnapshots() public { @@ -112,18 +117,24 @@ contract UniswapAnchoredViewSourceAdapterTest is CommonTest { for (uint256 i = 0; i < snapshotAnswers.length; i++) { // Lookback at exact snapshot timestamp should return the same answer and timestamp. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i], 10); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i], 10); assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. // Source updates were more than 30 minutes apart, so lookback 30 minutes later should return the same answer. - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] + 1800, 10); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] + 1800, 10); assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. // Source updates were more than 30 minutes apart, so lookback 30 minutes earlier should return the previous answer, // except for the first snapshot which should return the same answer as it does not have earlier data. - (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] - 1800, 10); + (lookBackPrice, lookBackTimestamp, lookBackRoundId) = + sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] - 1800, 10); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. if (i > 0) { assertTrue(int256(snapshotAnswers[i - 1]) == lookBackPrice); assertTrue(snapshotTimestamps[i - 1] == lookBackTimestamp); @@ -140,11 +151,12 @@ contract UniswapAnchoredViewSourceAdapterTest is CommonTest { // If we limit how far we can lookback the source adapter snapshot should correctly return the oldest data it // can find, up to that limit. When searching for the earliest possible snapshot while limiting maximum snapshot // traversal to 1 we should still get the latest data. - (int256 lookBackPrice, uint256 lookBackTimestamp) = sourceAdapter.tryLatestDataAt(0, 1); + (int256 lookBackPrice, uint256 lookBackTimestamp, uint256 lookBackRoundId) = sourceAdapter.tryLatestDataAt(0, 1); uint256 latestUniswapAnchoredViewAnswer = uniswapAnchoredView.getUnderlyingPrice(cToken); uint256 latestAggregatorTimestamp = aggregator.latestTimestamp(); assertTrue(int256(latestUniswapAnchoredViewAnswer) == lookBackPrice); assertTrue(latestAggregatorTimestamp == lookBackTimestamp); + assertTrue(lookBackRoundId == 1); // roundId not supported, hardcoded to 1. } function testUpgradeAggregator() public { diff --git a/test/mocks/MockSnapshotSourceAdapter.sol b/test/mocks/MockSnapshotSourceAdapter.sol index 76b2413..e4e1e37 100644 --- a/test/mocks/MockSnapshotSourceAdapter.sol +++ b/test/mocks/MockSnapshotSourceAdapter.sol @@ -20,15 +20,19 @@ abstract contract MockSnapshotSourceAdapter is SnapshotSource { return (latestData.answer, latestData.timestamp); } + function getSourceDataAtRound(uint256 /* roundId */ ) public view virtual override returns (int256, uint256) { + return (0, 0); + } + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view virtual override - returns (int256, uint256) + returns (int256, uint256, uint256) { SnapshotSource.Snapshot memory latestData = _tryLatestDataAt(timestamp, maxTraversal); - return (latestData.answer, latestData.timestamp); + return (latestData.answer, latestData.timestamp, 1); } function _latestSourceData() internal view returns (SourceData memory) { diff --git a/test/mocks/MockSourceAdapter.sol b/test/mocks/MockSourceAdapter.sol index 1b213a6..91c1f28 100644 --- a/test/mocks/MockSourceAdapter.sol +++ b/test/mocks/MockSourceAdapter.sol @@ -9,6 +9,7 @@ abstract contract MockSourceAdapter is DiamondRootOval { struct RoundData { int256 answer; uint256 timestamp; + uint256 roundId; // Assigned automatically, starting at 1. } RoundData[] public rounds; @@ -20,7 +21,7 @@ abstract contract MockSourceAdapter is DiamondRootOval { function snapshotData() public override {} function publishRoundData(int256 answer, uint256 timestamp) public { - rounds.push(RoundData(answer, timestamp)); + rounds.push(RoundData(answer, timestamp, rounds.length + 1)); } function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) @@ -28,10 +29,10 @@ abstract contract MockSourceAdapter is DiamondRootOval { view virtual override - returns (int256, uint256) + returns (int256, uint256, uint256) { RoundData memory latestData = _tryLatestDataAt(timestamp, maxTraversal); - return (latestData.answer, latestData.timestamp); + return (latestData.answer, latestData.timestamp, latestData.roundId); } function getLatestSourceData() public view virtual override returns (int256, uint256) { @@ -39,9 +40,15 @@ abstract contract MockSourceAdapter is DiamondRootOval { return (latestData.answer, latestData.timestamp); } + function getSourceDataAtRound(uint256 roundId) public view virtual override returns (int256, uint256) { + if (roundId == 0 || rounds.length < roundId) return (0, 0); + RoundData memory roundData = rounds[roundId - 1]; + return (roundData.answer, roundData.timestamp); + } + function _latestRoundData() internal view returns (RoundData memory) { if (rounds.length > 0) return rounds[rounds.length - 1]; - return RoundData(0, 0); + return RoundData(0, 0, 0); } function _tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) internal view returns (RoundData memory) { @@ -57,11 +64,11 @@ abstract contract MockSourceAdapter is DiamondRootOval { function _searchDataAt(uint256 timestamp, uint256 maxTraversal) internal view returns (RoundData memory) { RoundData memory roundData; uint256 traversedRounds = 0; - uint256 roundId = rounds.length; + uint256 roundIndex = rounds.length; - while (traversedRounds < maxTraversal && roundId > 0) { - roundId--; - roundData = rounds[roundId]; + while (traversedRounds < maxTraversal && roundIndex > 0) { + roundIndex--; + roundData = rounds[roundIndex]; if (roundData.timestamp <= timestamp) return roundData; traversedRounds++; } diff --git a/test/unit/Oval.ChainlinkDestinationAdapter.sol b/test/unit/Oval.ChainlinkDestinationAdapter.sol index aa79987..8522757 100644 --- a/test/unit/Oval.ChainlinkDestinationAdapter.sol +++ b/test/unit/Oval.ChainlinkDestinationAdapter.sol @@ -25,6 +25,8 @@ contract OvalChainlinkDestinationAdapter is CommonTest { TestOval oval; + uint256 latestPublishedRound; + function setUp() public { vm.warp(initialTimestamp); @@ -33,15 +35,21 @@ contract OvalChainlinkDestinationAdapter is CommonTest { oval.setUnlocker(permissionedUnlocker, true); vm.stopPrank(); - oval.publishRoundData(initialPrice, initialTimestamp); + publishRoundData(initialPrice, initialTimestamp); + } + + function publishRoundData(int256 answer, uint256 timestamp) public { + oval.publishRoundData(answer, timestamp); + ++latestPublishedRound; } function verifyOvalMatchesOval() public { - (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + (int256 latestAnswer, uint256 latestTimestamp, uint256 latestRoundId) = oval.internalLatestData(); assertTrue( latestAnswer / internalDecimalsToSourceDecimals == oval.latestAnswer() && latestTimestamp == oval.latestTimestamp() ); + assertTrue(latestRoundId == latestPublishedRound); } function syncOvalWithOval() public { @@ -52,30 +60,68 @@ contract OvalChainlinkDestinationAdapter is CommonTest { } function testUpdatesWithinLockWindow() public { - // Publish an update to the mock source adapter. - oval.publishRoundData(newAnswer, newTimestamp); - syncOvalWithOval(); - assertTrue(oval.lastUnlockTime() == block.timestamp); - // Apply an unlock with no diff in source adapter. - uint256 unlockTimestamp = block.timestamp + 1 minutes; - vm.warp(unlockTimestamp); + // Advance time to within the lock window and update the source. + uint256 beforeLockWindow = block.timestamp + oval.lockWindow() - 1; + vm.warp(beforeLockWindow); + publishRoundData(newAnswer, newTimestamp); + + // Before updating, initial values from cache would be returned. + (int256 latestAnswer, uint256 latestTimestamp, uint256 latestRoundId) = oval.internalLatestData(); + assertTrue(latestAnswer == initialPrice && latestTimestamp == initialTimestamp); + assertTrue(latestRoundId == latestPublishedRound - 1); + + // After updating we should return the new values. vm.prank(permissionedUnlocker); oval.unlockLatestValue(); - - // Check that the update timestamp was unlocked and that the answer and timestamp are unchanged. - assertTrue(oval.lastUnlockTime() == unlockTimestamp); verifyOvalMatchesOval(); (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = oval.latestRoundData(); // Check that Oval return the correct values scaled to the source oracle decimals. - assertTrue(roundId == 1); + assertTrue(roundId == latestPublishedRound); + assertTrue(answer == newAnswer / internalDecimalsToSourceDecimals); + assertTrue(startedAt == newTimestamp); + assertTrue(updatedAt == newTimestamp); + assertTrue(answeredInRound == latestPublishedRound); + } + + function testReturnUninitializedRoundData() public { + // Advance time to within the lock window and update the source. + uint256 beforeLockWindow = block.timestamp + oval.lockWindow() - 1; + vm.warp(beforeLockWindow); + publishRoundData(newAnswer, newTimestamp); + + // Before updating, uninitialized values would be returned. + (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = + oval.getRoundData(uint80(latestPublishedRound)); + assertTrue(roundId == latestPublishedRound); + assertTrue(answer == 0); + assertTrue(startedAt == 0); + assertTrue(updatedAt == 0); + assertTrue(answeredInRound == latestPublishedRound); + } + + function testReturnUnlockedRoundData() public { + // Advance time to within the lock window and update the source. + uint256 beforeLockWindow = block.timestamp + oval.lockWindow() - 1; + vm.warp(beforeLockWindow); + publishRoundData(newAnswer, newTimestamp); + + // Unlock new round values. + vm.prank(permissionedUnlocker); + oval.unlockLatestValue(); + verifyOvalMatchesOval(); + + // After unlock we should return the new values. + (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) = + oval.getRoundData(uint80(latestPublishedRound)); + assertTrue(roundId == latestPublishedRound); assertTrue(answer == newAnswer / internalDecimalsToSourceDecimals); assertTrue(startedAt == newTimestamp); assertTrue(updatedAt == newTimestamp); - assertTrue(answeredInRound == 1); + assertTrue(answeredInRound == latestPublishedRound); } } diff --git a/test/unit/Oval.ChronicleMedianDestinationAdapter.sol b/test/unit/Oval.ChronicleMedianDestinationAdapter.sol index b1d0e7e..125868c 100644 --- a/test/unit/Oval.ChronicleMedianDestinationAdapter.sol +++ b/test/unit/Oval.ChronicleMedianDestinationAdapter.sol @@ -42,7 +42,7 @@ contract OvalChronicleMedianDestinationAdapter is CommonTest { } function verifyOvalOracleMatchesOvalOracle() public { - (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + (int256 latestAnswer, uint256 latestTimestamp,) = oval.internalLatestData(); (, bool sourceValid) = oval.peek(); assertTrue(sourceValid); diff --git a/test/unit/Oval.OSMDestinationAdapter.sol b/test/unit/Oval.OSMDestinationAdapter.sol index 4a8fcdf..21df32c 100644 --- a/test/unit/Oval.OSMDestinationAdapter.sol +++ b/test/unit/Oval.OSMDestinationAdapter.sol @@ -33,7 +33,7 @@ contract OvalChronicleMedianDestinationAdapter is CommonTest { } function verifyOvalOracleMatchesOvalOracle() public { - (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + (int256 latestAnswer, uint256 latestTimestamp,) = oval.internalLatestData(); (, bool sourceValid) = oval.peek(); assertTrue(sourceValid); diff --git a/test/unit/Oval.PythDestinationAdapter.sol b/test/unit/Oval.PythDestinationAdapter.sol index 822d997..270d091 100644 --- a/test/unit/Oval.PythDestinationAdapter.sol +++ b/test/unit/Oval.PythDestinationAdapter.sol @@ -24,6 +24,7 @@ contract OvalChronicleMedianDestinationAdapter is CommonTest { int256 newAnswer = 1900 * 1e18; uint256 newTimestamp = initialTimestamp + 1; + uint256 roundId = 1; // Pyth does not support roundId and has it hardcoded to 1. bytes32 testId = keccak256("testId"); uint8 testDecimals = 8; @@ -49,7 +50,9 @@ contract OvalChronicleMedianDestinationAdapter is CommonTest { function testGetPriceUnsafe() public { destinationAdapter.setOval(testId, testDecimals, testValidTimePeriod, IOval(OvalAddress)); vm.mockCall( - OvalAddress, abi.encodeWithSelector(IOval.internalLatestData.selector), abi.encode(newAnswer, newTimestamp) + OvalAddress, + abi.encodeWithSelector(IOval.internalLatestData.selector), + abi.encode(newAnswer, newTimestamp, roundId) ); IPyth.Price memory price = destinationAdapter.getPriceUnsafe(testId); @@ -63,7 +66,9 @@ contract OvalChronicleMedianDestinationAdapter is CommonTest { destinationAdapter.setOval(testId, testDecimals, testValidTimePeriod, IOval(OvalAddress)); uint256 timestamp = block.timestamp; vm.mockCall( - OvalAddress, abi.encodeWithSelector(IOval.internalLatestData.selector), abi.encode(newAnswer, timestamp) + OvalAddress, + abi.encodeWithSelector(IOval.internalLatestData.selector), + abi.encode(newAnswer, timestamp, roundId) ); IPyth.Price memory price = destinationAdapter.getPrice(testId); @@ -78,7 +83,9 @@ contract OvalChronicleMedianDestinationAdapter is CommonTest { vm.warp(newTimestamp + testValidTimePeriod + 1); // Warp to after the valid time period. vm.mockCall( - OvalAddress, abi.encodeWithSelector(IOval.internalLatestData.selector), abi.encode(newAnswer, newTimestamp) + OvalAddress, + abi.encodeWithSelector(IOval.internalLatestData.selector), + abi.encode(newAnswer, newTimestamp, roundId) ); vm.expectRevert("Not within valid window"); @@ -90,7 +97,9 @@ contract OvalChronicleMedianDestinationAdapter is CommonTest { vm.warp(newTimestamp - 1); // Warp to before publish time. vm.mockCall( - OvalAddress, abi.encodeWithSelector(IOval.internalLatestData.selector), abi.encode(newAnswer, newTimestamp) + OvalAddress, + abi.encodeWithSelector(IOval.internalLatestData.selector), + abi.encode(newAnswer, newTimestamp, roundId) ); IPyth.Price memory price = destinationAdapter.getPrice(testId); diff --git a/test/unit/Oval.UniswapAnchoredViewDestinationAdapter.sol b/test/unit/Oval.UniswapAnchoredViewDestinationAdapter.sol index 32453ca..49f0371 100644 --- a/test/unit/Oval.UniswapAnchoredViewDestinationAdapter.sol +++ b/test/unit/Oval.UniswapAnchoredViewDestinationAdapter.sol @@ -11,6 +11,7 @@ import {CommonTest} from "../Common.sol"; contract OvalUniswapAnchoredViewDestinationAdapter is CommonTest { int256 newAnswer = 1900 * 1e18; uint256 newTimestamp = 1690000000; + uint256 roundId = 1; // UniswapAnchoredView does not support roundId and has it hardcoded to 1. int256 internalDecimalsToSourceDecimals = 1e10; @@ -73,7 +74,9 @@ contract OvalUniswapAnchoredViewDestinationAdapter is CommonTest { destinationAdapter.setOval(cTokenAddress, OvalAddress); vm.mockCall( - OvalAddress, abi.encodeWithSelector(IOval.internalLatestData.selector), abi.encode(newAnswer, newTimestamp) + OvalAddress, + abi.encodeWithSelector(IOval.internalLatestData.selector), + abi.encode(newAnswer, newTimestamp, roundId) ); uint256 underlyingPrice = destinationAdapter.getUnderlyingPrice(cTokenAddress); diff --git a/test/unit/Oval.UnlockLatestValue.sol b/test/unit/Oval.UnlockLatestValue.sol index 3bf4c26..f254bc3 100644 --- a/test/unit/Oval.UnlockLatestValue.sol +++ b/test/unit/Oval.UnlockLatestValue.sol @@ -20,6 +20,8 @@ contract OvalUnlockLatestValue is CommonTest { TestOval oval; + uint256 latestPublishedRound; + function setUp() public { vm.warp(initialTimestamp); @@ -28,12 +30,18 @@ contract OvalUnlockLatestValue is CommonTest { oval.setUnlocker(permissionedUnlocker, true); vm.stopPrank(); - oval.publishRoundData(initialPrice, initialTimestamp); + publishRoundData(initialPrice, initialTimestamp); + } + + function publishRoundData(int256 answer, uint256 timestamp) public { + oval.publishRoundData(answer, timestamp); + ++latestPublishedRound; } function verifyOvalMatchesOval() public { - (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + (int256 latestAnswer, uint256 latestTimestamp, uint256 latestRoundId) = oval.internalLatestData(); assertTrue(latestAnswer == oval.latestAnswer() && latestTimestamp == oval.latestTimestamp()); + assertTrue(latestRoundId == latestPublishedRound); } function syncOvalWithOval() public { @@ -61,18 +69,18 @@ contract OvalUnlockLatestValue is CommonTest { function testUnlockerCanUnlockLatestValue() public { syncOvalWithOval(); - oval.publishRoundData(newAnswer, newTimestamp); + publishRoundData(newAnswer, newTimestamp); vm.warp(newTimestamp); vm.prank(permissionedUnlocker); oval.unlockLatestValue(); verifyOvalMatchesOval(); - (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + (int256 latestAnswer, uint256 latestTimestamp,) = oval.internalLatestData(); assertTrue(latestAnswer == newAnswer && latestTimestamp == newTimestamp); // Advance time. Add a diff to the source adapter and verify that it is applied. vm.warp(newTimestamp + 2); - oval.publishRoundData(newAnswer + 1, newTimestamp + 2); + publishRoundData(newAnswer + 1, newTimestamp + 2); vm.prank(permissionedUnlocker); oval.unlockLatestValue(); verifyOvalMatchesOval(); @@ -81,15 +89,16 @@ contract OvalUnlockLatestValue is CommonTest { function testNonUnlockerCannotUnlockLatestValue() public { syncOvalWithOval(); - oval.publishRoundData(newAnswer, newTimestamp); + publishRoundData(newAnswer, newTimestamp); vm.warp(newTimestamp); vm.expectRevert("Controller blocked: canUnlock"); vm.prank(random); oval.unlockLatestValue(); - (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + (int256 latestAnswer, uint256 latestTimestamp, uint256 latestRoundId) = oval.internalLatestData(); assertTrue(latestAnswer == initialPrice && latestTimestamp == initialTimestamp); + assertTrue(latestRoundId == latestPublishedRound - 1); } function testUpdatesWithinLockWindow() public { @@ -98,11 +107,12 @@ contract OvalUnlockLatestValue is CommonTest { // Advance time to within the lock window and update the source. uint256 beforeLockWindow = block.timestamp + oval.lockWindow() - 1; vm.warp(beforeLockWindow); - oval.publishRoundData(newAnswer, beforeLockWindow); + publishRoundData(newAnswer, beforeLockWindow); // Before updating, initial values from cache would be returned. - (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + (int256 latestAnswer, uint256 latestTimestamp, uint256 latestRoundId) = oval.internalLatestData(); assertTrue(latestAnswer == initialPrice && latestTimestamp == initialTimestamp); + assertTrue(latestRoundId == latestPublishedRound - 1); // After updating we should return the new values. vm.prank(permissionedUnlocker); @@ -116,18 +126,20 @@ contract OvalUnlockLatestValue is CommonTest { uint256 beforeOEVLockWindow = unlockTimestamp + 59; // Default lock window is 10 minutes. vm.warp(beforeOEVLockWindow); // Advance before the end of the lock window. - oval.publishRoundData(newAnswer, beforeOEVLockWindow); // Update the source. + publishRoundData(newAnswer, beforeOEVLockWindow); // Update the source. // Within original lock window (after OEV unlock), initial values from cache would be returned. - (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + (int256 latestAnswer, uint256 latestTimestamp, uint256 latestRoundId) = oval.internalLatestData(); assertTrue(latestAnswer == initialPrice && latestTimestamp == initialTimestamp, "1"); + assertTrue(latestRoundId == latestPublishedRound - 1); // Advancing time past the original lock window but before new lock window since source update // should not yet pass through source values. uint256 pastOEVLockWindow = beforeOEVLockWindow + 2; vm.warp(pastOEVLockWindow); - (latestAnswer, latestTimestamp) = oval.internalLatestData(); + (latestAnswer, latestTimestamp, latestRoundId) = oval.internalLatestData(); assertTrue(latestAnswer == initialPrice && latestTimestamp == initialTimestamp); + assertTrue(latestRoundId == latestPublishedRound - 1); // Advancing time past the new lock window should pass through source values. uint256 pastSourceLockWindow = beforeOEVLockWindow + 69; @@ -141,11 +153,12 @@ contract OvalUnlockLatestValue is CommonTest { // Advance time to within the lock window and update the source. uint256 beforeLockWindow = block.timestamp + oval.lockWindow() - 1; vm.warp(beforeLockWindow); - oval.publishRoundData(newAnswer, beforeLockWindow); + publishRoundData(newAnswer, beforeLockWindow); // Before updating, initial values from cache would be returned. - (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + (int256 latestAnswer, uint256 latestTimestamp, uint256 latestRoundId) = oval.internalLatestData(); assertTrue(latestAnswer == initialPrice && latestTimestamp == initialTimestamp); + assertTrue(latestRoundId == latestPublishedRound - 1); // Sync and verify updated values. syncOvalWithOval(); @@ -154,10 +167,38 @@ contract OvalUnlockLatestValue is CommonTest { uint256 nextBeforeLockWindow = block.timestamp + oval.lockWindow() - 1; vm.warp(nextBeforeLockWindow); int256 nextNewAnswer = newAnswer + 1e18; - oval.publishRoundData(nextNewAnswer, nextBeforeLockWindow); + publishRoundData(nextNewAnswer, nextBeforeLockWindow); // Within lock window, values from previous update would be returned. - (latestAnswer, latestTimestamp) = oval.internalLatestData(); + (latestAnswer, latestTimestamp, latestRoundId) = oval.internalLatestData(); assertTrue(latestAnswer == newAnswer && latestTimestamp == beforeLockWindow); + assertTrue(latestRoundId == latestPublishedRound - 1); + } + + function testReturnUninitializedRoundData() public { + // Advance time to within the lock window and update the source. + uint256 beforeLockWindow = block.timestamp + oval.lockWindow() - 1; + vm.warp(beforeLockWindow); + publishRoundData(newAnswer, newTimestamp); + + // Before updating, uninitialized values would be returned. + (int256 latestAnswer, uint256 latestTimestamp) = oval.internalDataAtRound(latestPublishedRound); + assertTrue(latestAnswer == 0 && latestTimestamp == 0); + } + + function testReturnUnlockedRoundData() public { + // Advance time to within the lock window and update the source. + uint256 beforeLockWindow = block.timestamp + oval.lockWindow() - 1; + vm.warp(beforeLockWindow); + publishRoundData(newAnswer, newTimestamp); + + // Unlock new round values. + vm.prank(permissionedUnlocker); + oval.unlockLatestValue(); + verifyOvalMatchesOval(); + + // After unlock we should return the new values. + (int256 latestAnswer, uint256 latestTimestamp) = oval.internalDataAtRound(latestPublishedRound); + assertTrue(latestAnswer == newAnswer && latestTimestamp == newTimestamp); } } diff --git a/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol index 9545852..521c941 100644 --- a/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol +++ b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol @@ -31,7 +31,9 @@ contract TestBoundedUnionSource is BoundedUnionSourceAdapter { return _withinTolerance(a, b); } - function internalLatestData() public view override returns (int256, uint256) {} + 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) {} From e494b51d7cc9d42cd26f0f374556320f34957e7f Mon Sep 17 00:00:00 2001 From: Pablo Maldonado Date: Mon, 20 May 2024 08:18:42 +0100 Subject: [PATCH 2/2] feat: coinbase api oracle and api adapter (#6) Signed-off-by: Pablo Maldonado Signed-off-by: Matt Rice Signed-off-by: Reinis Martinsons Co-authored-by: Matt Rice Co-authored-by: Reinis Martinsons Co-authored-by: chrismaree Co-authored-by: Reinis Martinsons <77973553+Reinis-FRP@users.noreply.github.com> --- .../source-adapters/CoinbaseSourceAdapter.sol | 105 +++++++++++++ .../coinbase/IAggregatorV3SourceCoinbase.sol | 16 ++ src/oracles/CoinbaseOracle.sol | 116 ++++++++++++++ test/unit/CoinbaseOracle.sol | 59 +++++++ test/unit/CoinbaseSourceAdapter.sol | 148 ++++++++++++++++++ 5 files changed, 444 insertions(+) create mode 100644 src/adapters/source-adapters/CoinbaseSourceAdapter.sol create mode 100644 src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol create mode 100644 src/oracles/CoinbaseOracle.sol create mode 100644 test/unit/CoinbaseOracle.sol create mode 100644 test/unit/CoinbaseSourceAdapter.sol diff --git a/src/adapters/source-adapters/CoinbaseSourceAdapter.sol b/src/adapters/source-adapters/CoinbaseSourceAdapter.sol new file mode 100644 index 0000000..2c9e3c1 --- /dev/null +++ b/src/adapters/source-adapters/CoinbaseSourceAdapter.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {DecimalLib} from "../lib/DecimalLib.sol"; +import {IAggregatorV3SourceCoinbase} from "../../interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; +import {DiamondRootOval} from "../../DiamondRootOval.sol"; + +/** + * @title CoinbaseSourceAdapter + * @notice A contract to read data from CoinbaseOracle and standardize it for Oval. + * @dev Can fetch information from CoinbaseOracle source at a desired timestamp for historic lookups. + */ +abstract contract CoinbaseSourceAdapter is DiamondRootOval { + IAggregatorV3SourceCoinbase public immutable COINBASE_SOURCE; + uint8 private immutable SOURCE_DECIMALS; + string public TICKER; + + event SourceSet(address indexed sourceOracle, uint8 indexed sourceDecimals, string ticker); + + constructor(IAggregatorV3SourceCoinbase _source, string memory _ticker) { + COINBASE_SOURCE = _source; + SOURCE_DECIMALS = _source.decimals(); + TICKER = _ticker; + + emit SourceSet(address(_source), SOURCE_DECIMALS, TICKER); + } + + /** + * @notice Tries getting the latest data as of the requested timestamp. + * If this is not possible, returns the earliest data available past the requested timestamp within provided traversal limitations. + * @param timestamp The timestamp to try getting the latest data at. + * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. + * @return answer The answer as of the requested timestamp, or the earliest available data if not available, in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) + public + view + virtual + override + returns (int256, uint256, uint256) + { + (int256 answer, uint256 updatedAt) = _tryLatestRoundDataAt(timestamp, maxTraversal); + return (DecimalLib.convertDecimals(answer, SOURCE_DECIMALS, 18), updatedAt, 1); + } + + /** + * @notice Initiate a snapshot of the source data. This is a no-op for Coinbase. + */ + function snapshotData() public virtual override {} + + /** + * @notice Returns the latest data from the source. + * @return answer The latest answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getLatestSourceData() public view virtual override returns (int256, uint256) { + (, int256 sourceAnswer,, uint256 updatedAt,) = COINBASE_SOURCE.latestRoundData(TICKER); + return (DecimalLib.convertDecimals(sourceAnswer, SOURCE_DECIMALS, 18), updatedAt); + } + + function getSourceDataAtRound(uint256 /* roundId */ ) public view virtual override returns (int256, uint256) { + return (0, 0); + } + + // Tries getting the latest data as of the requested timestamp. If this is not possible, + // returns the earliest data available past the requested timestamp considering the maxTraversal limitations. + function _tryLatestRoundDataAt(uint256 timestamp, uint256 maxTraversal) internal view returns (int256, uint256) { + (uint80 roundId, int256 answer,, uint256 updatedAt,) = COINBASE_SOURCE.latestRoundData(TICKER); + + // If the latest update is older than or equal to the requested timestamp, return the latest data. + if (updatedAt <= timestamp) { + return (answer, updatedAt); + } + + // Attempt traversing historical round data backwards from roundId. + (int256 historicalAnswer, uint256 historicalUpdatedAt) = _searchRoundDataAt(timestamp, roundId, maxTraversal); + + // Validate returned data. If it is uninitialized, fall back to returning the current latest round data. + if (historicalUpdatedAt > 0) { + return (historicalAnswer, historicalUpdatedAt); + } + + return (answer, updatedAt); + } + + // Searches for the latest historical data not newer than the requested timestamp. + // Returns newer data than requested if it exceeds traversal limits or holds uninitialized data that should be handled by the caller. + function _searchRoundDataAt(uint256 timestamp, uint80 latestRoundId, uint256 maxTraversal) + internal + view + returns (int256, uint256) + { + int256 answer; + uint256 updatedAt; + for (uint80 i = 1; i <= maxTraversal && latestRoundId >= i; i++) { + (, answer,, updatedAt,) = COINBASE_SOURCE.getRoundData(TICKER, latestRoundId - i); + if (updatedAt <= timestamp) { + return (answer, updatedAt); + } + } + + return (answer, updatedAt); // Did not find requested round. Return earliest round or uninitialized data. + } +} diff --git a/src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol b/src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol new file mode 100644 index 0000000..157a184 --- /dev/null +++ b/src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.17; + +interface IAggregatorV3SourceCoinbase { + function decimals() external view returns (uint8); + + function latestRoundData(string memory ticker) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); + + function getRoundData(string memory ticker, uint80 _roundId) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound); +} diff --git a/src/oracles/CoinbaseOracle.sol b/src/oracles/CoinbaseOracle.sol new file mode 100644 index 0000000..9517a58 --- /dev/null +++ b/src/oracles/CoinbaseOracle.sol @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {IAggregatorV3SourceCoinbase} from "../interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; + +/** + * @title CoinbaseOracle + * @notice A smart contract that serves as an oracle for price data reported by a designated reporter. + */ +contract CoinbaseOracle is IAggregatorV3SourceCoinbase { + address immutable reporter; + + uint8 public immutable decimals; + + struct PriceData { + uint80 lastRoundId; + mapping(uint80 => int256) roundAnswers; + mapping(uint80 => uint256) roundTimestamps; + } + + mapping(string => PriceData) private prices; + + event PricePushed(string indexed ticker, uint80 indexed roundId, int256 price, uint256 timestamp); + + /** + * @notice Constructor to initialize the CoinbaseOracle contract. + * @param _decimals The number of decimals in the reported price. + * @param _reporter The address of the reporter allowed to push price data. + */ + constructor(uint8 _decimals, address _reporter) { + decimals = _decimals; + reporter = _reporter; + } + + /** + * @notice Returns the latest round data for a given ticker. + * @param ticker The ticker symbol to retrieve the data for. + * @return roundId The ID of the latest round. + * @return answer The latest price. + * @return startedAt The timestamp when the round started. + * @return updatedAt The timestamp when the round was updated. + * @return answeredInRound The round ID in which the answer was computed. + */ + function latestRoundData(string memory ticker) + external + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + PriceData storage priceData = prices[ticker]; + int256 latestAnswer = priceData.roundAnswers[priceData.lastRoundId]; + uint256 latestTimestamp = priceData.roundTimestamps[priceData.lastRoundId]; + return (priceData.lastRoundId, latestAnswer, latestTimestamp, latestTimestamp, priceData.lastRoundId); + } + + /** + * @notice Returns the data for a specific round for a given ticker. + * @param ticker The ticker symbol to retrieve the data for. + * @param roundId The round ID to retrieve the data for. + * @return roundId The ID of the round. + * @return answer The price of the round. + * @return startedAt The timestamp when the round started. + * @return updatedAt The timestamp when the round was updated. + * @return answeredInRound The round ID in which the answer was computed. + */ + function getRoundData(string memory ticker, uint80 roundId) + external + view + returns (uint80, int256, uint256, uint256, uint80) + { + PriceData storage priceData = prices[ticker]; + int256 latestAnswer = priceData.roundAnswers[roundId]; + uint256 latestTimestamp = priceData.roundTimestamps[roundId]; + return (roundId, latestAnswer, latestTimestamp, latestTimestamp, roundId); + } + + /** + * @notice Pushes a new price to the oracle for a given ticker. + * @param priceData The encoded price data. + * @param signature The signature to verify the authenticity of the data. + */ + function pushPrice(bytes memory priceData, bytes memory signature) external { + ( + string memory kind, // e.g. "price" + uint256 timestamp, // e.g. 1629350000 + string memory ticker, // e.g. "BTC" + uint256 price // 6 decimals + ) = abi.decode(priceData, (string, uint256, string, uint256)); + + require(keccak256(abi.encodePacked(kind)) == keccak256(abi.encodePacked("price")), "Invalid kind."); + + PriceData storage priceDataStruct = prices[ticker]; + uint256 latestTimestamp = priceDataStruct.roundTimestamps[priceDataStruct.lastRoundId]; + + require(timestamp > latestTimestamp, "Invalid timestamp."); + require(recoverSigner(priceData, signature) == reporter, "Invalid signature."); + require(price < uint256(type(int256).max), "Price exceeds max value."); + + priceDataStruct.lastRoundId++; + priceDataStruct.roundAnswers[priceDataStruct.lastRoundId] = int256(price); + priceDataStruct.roundTimestamps[priceDataStruct.lastRoundId] = timestamp; + + emit PricePushed(ticker, priceDataStruct.lastRoundId, int256(price), timestamp); + } + + /** + * @notice Internal function to recover the signer of a message. + * @param message The message that was signed. + * @param signature The signature to recover the signer from. + * @return The address of the signer. + */ + function recoverSigner(bytes memory message, bytes memory signature) internal pure returns (address) { + (bytes32 r, bytes32 s, uint8 v) = abi.decode(signature, (bytes32, bytes32, uint8)); + bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(message))); + return ecrecover(hash, v, r, s); + } +} diff --git a/test/unit/CoinbaseOracle.sol b/test/unit/CoinbaseOracle.sol new file mode 100644 index 0000000..4a20c6f --- /dev/null +++ b/test/unit/CoinbaseOracle.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {CommonTest} from "../Common.sol"; +import {BaseController} from "../../src/controllers/BaseController.sol"; +import {CoinbaseSourceAdapter} from "../../src/adapters/source-adapters/CoinbaseSourceAdapter.sol"; +import {DecimalLib} from "../../src/adapters/lib/DecimalLib.sol"; +import {IAggregatorV3SourceCoinbase} from "../../src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; +import {CoinbaseOracle} from "../../src/oracles/CoinbaseOracle.sol"; + +contract CoinbaseSourceAdapterTest is CommonTest { + CoinbaseOracle coinbaseOracle; + + address public reporter; + uint256 public reporterPk; + string public constant ethTicker = "ETH"; + string public constant btcTicker = "BTC"; + + function setUp() public { + (address _reporter, uint256 _reporterPk) = makeAddrAndKey("reporter"); + reporter = _reporter; + reporterPk = _reporterPk; + coinbaseOracle = new CoinbaseOracle(6, reporter); + } + + function testPushPriceETH() public { + _testPushPrice(ethTicker, 10e6); + } + + function testPushPriceBTC() public { + _testPushPrice(btcTicker, 20e6); + } + + function testPushPriceBothTickers() public { + _testPushPrice(ethTicker, 10e6); + vm.warp(block.timestamp + 1); + _testPushPrice(btcTicker, 20e6); + } + + function _testPushPrice(string memory ticker, uint256 price) internal { + string memory kind = "price"; + uint256 timestamp = block.timestamp; + + bytes memory encodedData = abi.encode(kind, timestamp, ticker, price); + + bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(encodedData))); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(reporterPk, hash); + + bytes memory signature = abi.encode(r, s, v); + + coinbaseOracle.pushPrice(encodedData, signature); + + (, int256 answer, uint256 updatedAt,,) = coinbaseOracle.latestRoundData(ticker); + + assertEq(uint256(answer), price); + assertEq(updatedAt, timestamp); + } +} diff --git a/test/unit/CoinbaseSourceAdapter.sol b/test/unit/CoinbaseSourceAdapter.sol new file mode 100644 index 0000000..df7cf27 --- /dev/null +++ b/test/unit/CoinbaseSourceAdapter.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {CommonTest} from "../Common.sol"; + +import {BaseController} from "../../src/controllers/BaseController.sol"; +import {CoinbaseSourceAdapter} from "../../src/adapters/source-adapters/CoinbaseSourceAdapter.sol"; +import {DecimalLib} from "../../src/adapters/lib/DecimalLib.sol"; +import {IAggregatorV3SourceCoinbase} from "../../src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; +import {CoinbaseOracle} from "../../src/oracles/CoinbaseOracle.sol"; + +contract TestedSourceAdapter is CoinbaseSourceAdapter { + constructor(IAggregatorV3SourceCoinbase source, string memory ticker) CoinbaseSourceAdapter(source, ticker) {} + + function internalLatestData() public view override returns (int256, uint256, 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 internalDataAtRound(uint256 roundId) public view override returns (int256, uint256) {} +} + +contract CoinbaseSourceAdapterTest is CommonTest { + CoinbaseOracle coinbase; + TestedSourceAdapter sourceAdapter; + + address public reporter; + uint256 public reporterPk; + + string public ticker = "ETH"; + uint256 public price = 3000e6; + + function pushPrice(string memory ticker, uint256 price, uint256 timestamp) public { + string memory kind = "price"; + + bytes memory encodedData = abi.encode(kind, timestamp, ticker, price); + + bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", keccak256(encodedData))); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(reporterPk, hash); + + bytes memory signature = abi.encode(r, s, v); + + coinbase.pushPrice(encodedData, signature); + } + + function scaleCoinbaseTo18(int256 input) public pure returns (int256) { + return (input * 10 ** 18) / 10 ** 6; + } + + function setUp() public { + (address _reporter, uint256 _reporterPk) = makeAddrAndKey("reporter"); + reporter = _reporter; + reporterPk = _reporterPk; + coinbase = new CoinbaseOracle(6, reporter); + sourceAdapter = new TestedSourceAdapter(IAggregatorV3SourceCoinbase(address(coinbase)), ticker); + + // Push some prices to the oracle + vm.warp(100000000); + pushPrice(ticker, price, block.timestamp); + vm.warp(block.timestamp + 1 hours); + pushPrice(ticker, price - 500, block.timestamp); + vm.warp(block.timestamp + 1 hours); + pushPrice(ticker, price - 1000, block.timestamp); + vm.warp(block.timestamp + 1 hours); + pushPrice(ticker, price - 1500, block.timestamp); + } + + function testCorrectlyStandardizesOutputs() public { + (, int256 latestCoinbasePrice,, uint256 latestCoinbaseTimestamp,) = coinbase.latestRoundData(ticker); + (int256 latestSourceAnswer, uint256 latestSourceTimestamp) = sourceAdapter.getLatestSourceData(); + + assertTrue(scaleCoinbaseTo18(latestCoinbasePrice) == latestSourceAnswer); + assertTrue(latestSourceTimestamp == latestCoinbaseTimestamp); + } + + function testCorrectlyLooksBackThroughRounds() public { + (uint80 latestRound, int256 latestAnswer,, uint256 latestUpdatedAt,) = coinbase.latestRoundData(ticker); + assertTrue(uint256(latestAnswer) == price - 1500); + + uint256 targetTime = block.timestamp - 1 hours; + (int256 lookBackPrice, uint256 lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 10); + (, int256 answer, uint256 startedAt,,) = coinbase.getRoundData(ticker, latestRound - 1); + assertTrue(startedAt <= targetTime); // The time from the chainlink source is at least 1 hours old. + assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); + assertTrue(uint256(answer) == (price - 1000)); + assertTrue(startedAt == lookBackTimestamp); + + // Next, try looking back 2 hours. Equally, we should get the price from 2 rounds ago. + targetTime = block.timestamp - 2 hours; + (lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 10); + (, answer, startedAt,,) = coinbase.getRoundData(ticker, latestRound - 2); + assertTrue(startedAt <= targetTime); // The time from the chainlink source is at least 2 hours old. + assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); + assertTrue(uint256(answer) == (price - 500)); + assertTrue(startedAt == lookBackTimestamp); + + // Now, try 4 hours old, this time we don't have data from 4 hours ago, so we should get the latest data available. + targetTime = block.timestamp - 4 hours; + (lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 10); + + assertTrue(scaleCoinbaseTo18(latestAnswer) == lookBackPrice); + assertTrue(latestUpdatedAt == lookBackTimestamp); + } + + function testCorrectlyBoundsMaxLookBack() public { + // If we limit how far we can lookback the source should correctly return the oldest data it can find, up to + // that limit. From the previous tests we showed that looking back 2 hours should return the price from round 2. + // If we try look back longer than this we should get the price from round 2, no matter how far we look back. + uint256 targetTime = block.timestamp - 2 hours; + (int256 lookBackPrice, uint256 lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 2); + (uint80 latestRound,,,,) = coinbase.latestRoundData(ticker); + (, int256 answer, uint256 startedAt,,) = coinbase.getRoundData(ticker, latestRound - 2); + + assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); + assertTrue(startedAt == lookBackTimestamp); + + // Now, lookback longer than 2 hours. should get the same value as before. + targetTime = block.timestamp - 3 hours; + (lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 2); + assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); + assertTrue(startedAt == lookBackTimestamp); + targetTime = block.timestamp - 10 hours; + (lookBackPrice, lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 2); + assertTrue(scaleCoinbaseTo18(answer) == lookBackPrice); + assertTrue(startedAt == lookBackTimestamp); + } + + function testNonHistoricalData() public { + coinbase = new CoinbaseOracle(6, reporter); + sourceAdapter = new TestedSourceAdapter(IAggregatorV3SourceCoinbase(address(coinbase)), ticker); + + // Push only one price to the oracle + vm.warp(100000000); + pushPrice(ticker, price, block.timestamp); + + uint256 targetTime = block.timestamp - 1 hours; + + (, int256 answer,, uint256 updatedAt,) = coinbase.latestRoundData(ticker); + + (int256 lookBackPrice, uint256 lookBackTimestamp,) = sourceAdapter.tryLatestDataAt(targetTime, 0); + assertEq(lookBackPrice, scaleCoinbaseTo18(answer)); + assertEq(lookBackTimestamp, updatedAt); + } +}