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 3f6a6ba..b4ec1e8 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"; @@ -35,16 +37,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); } /** @@ -62,21 +65,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 @@ -84,7 +104,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; @@ -101,11 +121,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/CoinbaseSourceAdapter.sol b/src/adapters/source-adapters/CoinbaseSourceAdapter.sol new file mode 100644 index 0000000..928a186 --- /dev/null +++ b/src/adapters/source-adapters/CoinbaseSourceAdapter.sol @@ -0,0 +1,112 @@ +// 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. + * @return roundId The roundId of the answer (hardcoded to 1 for Coinbase). + */ + 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); + } + + /** + * @notice Returns the requested round data from the source. + * @dev Round data not exposed for Coinbase, 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 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/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 a829a7e..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 (DecimalLib.convertDecimals(snapshot.answer, SOURCE_DECIMALS, 18), 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/controllers/MutableUnlockersController.sol b/src/controllers/MutableUnlockersController.sol new file mode 100644 index 0000000..1b6b89b --- /dev/null +++ b/src/controllers/MutableUnlockersController.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {Oval} from "../Oval.sol"; + +/** + * @title MutableUnlockersController is a controller that only allows unlockers to be change, but other params are immutable. + */ +abstract contract MutableUnlockersController is Ownable, Oval { + // these don't need to be public since they can be accessed via the accessor functions below. + uint256 private immutable LOCK_WINDOW; // The lockWindow in seconds. + uint256 private immutable MAX_TRAVERSAL; // The maximum number of rounds to traverse when looking for historical data. + + mapping(address => bool) public unlockers; + + constructor(uint256 _lockWindow, uint256 _maxTraversal, address[] memory _unlockers) { + LOCK_WINDOW = _lockWindow; + MAX_TRAVERSAL = _maxTraversal; + for (uint256 i = 0; i < _unlockers.length; i++) { + setUnlocker(_unlockers[i], true); + } + + emit LockWindowSet(_lockWindow); + emit MaxTraversalSet(_maxTraversal); + } + + /** + * @notice Enables the owner to set the unlocker status of an address. Once set, the address can unlock Oval + * and by calling unlockLatestValue as part of an MEV-share auction. + * @param unlocker The address to set the unlocker status of. + * @param allowed The unlocker status to set. + */ + function setUnlocker(address unlocker, bool allowed) public onlyOwner { + unlockers[unlocker] = allowed; + + emit UnlockerSet(unlocker, allowed); + } + + /** + * @notice Returns true if the caller is allowed to unlock Oval. + * @dev This implementation simply checks if the caller is in the unlockers mapping. Custom Controllers can override + * this function to provide more granular control over who can unlock Oval. + * @param caller The address to check. + * @param _lastUnlockTime The timestamp of the latest unlock to Oval. Might be useful in verification. + */ + function canUnlock(address caller, uint256 _lastUnlockTime) public view override returns (bool) { + return unlockers[caller]; + } + + /** + * @notice Time window that bounds how long the permissioned actor has to call the unlockLatestValue function after + * a new source update is posted. If the permissioned actor does not call unlockLatestValue within this window of a + * new source price, the latest value will be made available to everyone without going through an MEV-Share auction. + * @return lockWindow time in seconds. + */ + function lockWindow() public view override returns (uint256) { + return LOCK_WINDOW; + } + + /** + * @notice Max number of historical source updates to traverse when looking for a historic value in the past. + * @return maxTraversal max number of historical source updates to traverse. + */ + function maxTraversal() public view override returns (uint256) { + return MAX_TRAVERSAL; + } +} diff --git a/src/factories/BaseFactory.sol b/src/factories/BaseFactory.sol new file mode 100644 index 0000000..f4fcd8c --- /dev/null +++ b/src/factories/BaseFactory.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; + +/** + * @title BaseFactory This is the base contract for all Oval factories. It manages the maxTraversal and default + * unlockers used by all Oval factories. + * @dev Derived contracts should implement a create method with the parameters needed to instantiate that flavor of + * Oval. + */ +contract BaseFactory is Ownable { + uint256 public immutable MAX_TRAVERSAL; + address[] public defaultUnlockers; + + event DefaultUnlockersSet(address[] defaultUnlockers); + event OvalDeployed( + address indexed deployer, + address indexed oval, + uint256 indexed lockWindow, + uint256 maxTraversal, + address owner, + address[] unlockers + ); + + constructor(uint256 _maxTraversal, address[] memory _defaultUnlockers) { + MAX_TRAVERSAL = _maxTraversal; + setDefaultUnlockers(_defaultUnlockers); + } + + /** + * @notice Enables the owner to set the default unlockers that will be passed to all Oval instances created by this + * contract. + * @dev This and the owner, itself, is the only mutable portion of this factory. + * @param _defaultUnlockers default unlockers that will be used to instantiate new Oval instances. + */ + function setDefaultUnlockers(address[] memory _defaultUnlockers) public onlyOwner { + defaultUnlockers = _defaultUnlockers; + emit DefaultUnlockersSet(_defaultUnlockers); + } +} diff --git a/src/factories/PermissionProxy.sol b/src/factories/PermissionProxy.sol new file mode 100644 index 0000000..dbd9a75 --- /dev/null +++ b/src/factories/PermissionProxy.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {Multicall} from "openzeppelin-contracts/contracts/utils/Multicall.sol"; + +/** + * @title PermissionProxy is a proxy that allows extends the permissions given to it to a configurable set + * of addresses. + * @dev The intended use case for this contract is to add this as a single unlocker to oval contracts, allowing the + * owner of this contract to delegate that permission to many different unlocker addresses. + */ +contract PermissionProxy is Ownable, Multicall { + error SenderNotApproved(address sender); + error CallFailed(address target, uint256 value, bytes callData); + + event SenderSet(address sender, bool allowed); + + mapping(address => bool) public senders; + + /** + * @notice Enables or disables a sender. + * @param sender the sender to enable or disable. + * @param allowed whether the sender should be allowed. + */ + function setSender(address sender, bool allowed) external onlyOwner { + senders[sender] = allowed; + emit SenderSet(sender, allowed); + } + + /** + * @notice Executes a call from this contract. + * @dev Can only be called by an allowed sender. + * @param target the address to call. + * @param value the value to send. + * @param callData the calldata to use for the call. + * @return the data returned by the external call. + * + */ + function execute(address target, uint256 value, bytes memory callData) external returns (bytes memory) { + if (!senders[msg.sender]) { + revert SenderNotApproved(msg.sender); + } + + (bool success, bytes memory returnData) = target.call{value: value}(callData); + + if (!success) { + revert CallFailed(target, value, callData); + } + + return returnData; + } +} diff --git a/src/factories/StandardChainlinkFactory.sol b/src/factories/StandardChainlinkFactory.sol new file mode 100644 index 0000000..62fd00e --- /dev/null +++ b/src/factories/StandardChainlinkFactory.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {MutableUnlockersController} from "../controllers/MutableUnlockersController.sol"; +import {ChainlinkSourceAdapter} from "../adapters/source-adapters/ChainlinkSourceAdapter.sol"; +import {ChainlinkDestinationAdapter} from "../adapters/destination-adapters/ChainlinkDestinationAdapter.sol"; +import {IAggregatorV3Source} from "../interfaces/chainlink/IAggregatorV3Source.sol"; +import {BaseFactory} from "./BaseFactory.sol"; + +/** + * @title OvalChainlink is the recommended Oval Chainlink contract that allows Oval to extract OEV generated by + * Chainlink usage. + */ +contract OvalChainlink is MutableUnlockersController, ChainlinkSourceAdapter, ChainlinkDestinationAdapter { + constructor( + IAggregatorV3Source source, + address[] memory unlockers, + uint256 lockWindow, + uint256 maxTraversal, + address owner + ) + ChainlinkSourceAdapter(source) + MutableUnlockersController(lockWindow, maxTraversal, unlockers) + ChainlinkDestinationAdapter(18) + { + _transferOwnership(owner); + } +} + +/** + * @title StandardChainlinkFactory is the recommended factory for use cases that want a Chainlink source and Chainlink + * interface. + * @dev This is the best factory for most use cases, but there are other variants that may be needed if different + * mutability choices are desired. + */ +contract StandardChainlinkFactory is Ownable, BaseFactory { + constructor(uint256 _maxTraversal, address[] memory _defaultUnlockers) + BaseFactory(_maxTraversal, _defaultUnlockers) + {} + + /** + * @notice Creates the Chainlink Oval instance. + * @param source the Chainlink oracle source contract. + * @param lockWindow the lockWindow used for this Oval instance. This is the length of the window + * for the Oval auction to be run and, thus, the maximum time that prices will be delayed. + * @return oval deployed oval address. + */ + function create(IAggregatorV3Source source, uint256 lockWindow) external returns (address oval) { + oval = address(new OvalChainlink(source, defaultUnlockers, lockWindow, MAX_TRAVERSAL, owner())); + emit OvalDeployed(msg.sender, oval, lockWindow, MAX_TRAVERSAL, owner(), defaultUnlockers); + } +} diff --git a/src/factories/StandardChronicleFactory.sol b/src/factories/StandardChronicleFactory.sol new file mode 100644 index 0000000..62633bc --- /dev/null +++ b/src/factories/StandardChronicleFactory.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {MutableUnlockersController} from "../controllers/MutableUnlockersController.sol"; +import {ChronicleMedianSourceAdapter} from "../adapters/source-adapters/ChronicleMedianSourceAdapter.sol"; +import {ChainlinkDestinationAdapter} from "../adapters/destination-adapters/ChainlinkDestinationAdapter.sol"; +import {IAggregatorV3Source} from "../interfaces/chainlink/IAggregatorV3Source.sol"; +import {BaseFactory} from "./BaseFactory.sol"; +import {IMedian} from "../interfaces/chronicle/IMedian.sol"; + +/** + * @title OvalChronicle is the reccomended Oval contract that allows Oval to extract OEV generated by + * Chronicle price feeds and allow usage via the Chainlink interface. + */ +contract OvalChronicle is MutableUnlockersController, ChronicleMedianSourceAdapter, ChainlinkDestinationAdapter { + constructor( + IMedian _source, + address[] memory _unlockers, + uint256 _lockWindow, + uint256 _maxTraversal, + address _owner + ) + ChronicleMedianSourceAdapter(_source) + MutableUnlockersController(_lockWindow, _maxTraversal, _unlockers) + ChainlinkDestinationAdapter(18) + { + _transferOwnership(_owner); + } +} + +/** + * @title StandardChronicleFactory is the recommended factory for use cases that want a Chronicle source and Chainlink + * interface. + * @dev This is the best factory for most use cases that need a Chronicle source, but there are other variants that may be + * needed if different mutability or interface choices are desired. + */ +contract StandardChronicleFactory is Ownable, BaseFactory { + constructor(uint256 maxTraversal, address[] memory _defaultUnlockers) + BaseFactory(maxTraversal, _defaultUnlockers) + {} + + /** + * @notice Creates the Chronicle Oval instance. + * @param chronicle Chronicle source contract. + * @param lockWindow the lockWindow used for this Oval instance. This is the length of the window + * for the Oval auction to be run and, thus, the maximum time that prices will be delayed. + * @return oval deployed oval address. + */ + function create(IMedian chronicle, uint256 lockWindow) external returns (address oval) { + oval = address(new OvalChronicle(chronicle, defaultUnlockers, lockWindow, MAX_TRAVERSAL, owner())); + emit OvalDeployed(msg.sender, oval, lockWindow, MAX_TRAVERSAL, owner(), defaultUnlockers); + } +} diff --git a/src/factories/StandardCoinbaseFactory.sol b/src/factories/StandardCoinbaseFactory.sol new file mode 100644 index 0000000..8d5740a --- /dev/null +++ b/src/factories/StandardCoinbaseFactory.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {MutableUnlockersController} from "../controllers/MutableUnlockersController.sol"; +import {CoinbaseSourceAdapter} from "../adapters/source-adapters/CoinbaseSourceAdapter.sol"; +import {ChainlinkDestinationAdapter} from "../adapters/destination-adapters/ChainlinkDestinationAdapter.sol"; +import {IAggregatorV3SourceCoinbase} from "../interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; +import {BaseFactory} from "./BaseFactory.sol"; + +/** + * @title OvalCoinbase is the recommended Oval Coinbase contract that allows Oval to extract OEV generated by + * Coinbase usage. + */ +contract OvalCoinbase is MutableUnlockersController, CoinbaseSourceAdapter, ChainlinkDestinationAdapter { + constructor( + IAggregatorV3SourceCoinbase _source, + string memory _ticker, + address[] memory _unlockers, + uint256 _lockWindow, + uint256 _maxTraversal, + address _owner + ) + CoinbaseSourceAdapter(_source, _ticker) + MutableUnlockersController(_lockWindow, _maxTraversal, _unlockers) + ChainlinkDestinationAdapter(18) + { + _transferOwnership(_owner); + } +} + +/** + * @title StandardCoinbaseFactory is the recommended factory for use cases that want a Coinbase source and Chainlink + * interface. + * @dev This is the best factory for most use cases, but there are other variants that may be needed if different + * mutability choices are desired. + */ +contract StandardCoinbaseFactory is Ownable, BaseFactory { + IAggregatorV3SourceCoinbase immutable SOURCE; + + constructor(IAggregatorV3SourceCoinbase _source, uint256 _maxTraversal, address[] memory _defaultUnlockers) + BaseFactory(_maxTraversal, _defaultUnlockers) + { + SOURCE = _source; + } + + /** + * @notice Creates the Coinbase Oval instance. + * @param ticker the Coinbase oracle's ticker. + * @param lockWindow the lockWindow used for this Oval instance. This is the length of the window + * for the Oval auction to be run and, thus, the maximum time that prices will be delayed. + * @return oval deployed oval address. + */ + function create(string memory ticker, uint256 lockWindow) external returns (address oval) { + oval = address(new OvalCoinbase(SOURCE, ticker, defaultUnlockers, lockWindow, MAX_TRAVERSAL, owner())); + emit OvalDeployed(msg.sender, oval, lockWindow, MAX_TRAVERSAL, owner(), defaultUnlockers); + } +} diff --git a/src/factories/StandardPythFactory.sol b/src/factories/StandardPythFactory.sol new file mode 100644 index 0000000..9bcea08 --- /dev/null +++ b/src/factories/StandardPythFactory.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {MutableUnlockersController} from "../controllers/MutableUnlockersController.sol"; +import {PythSourceAdapter} from "../adapters/source-adapters/PythSourceAdapter.sol"; +import {ChainlinkDestinationAdapter} from "../adapters/destination-adapters/ChainlinkDestinationAdapter.sol"; +import {IAggregatorV3Source} from "../interfaces/chainlink/IAggregatorV3Source.sol"; +import {BaseFactory} from "./BaseFactory.sol"; +import {IPyth} from "../interfaces/pyth/IPyth.sol"; + +/** + * @title OvalPyth is the recommended Oval contract that allows Oval to extract OEV generated by + * Pyth price feeds and allow usage via the Chainlink interface. + */ +contract OvalPyth is MutableUnlockersController, PythSourceAdapter, ChainlinkDestinationAdapter { + constructor( + IPyth source, + bytes32 pythPriceId, + address[] memory unlockers, + uint256 lockWindow, + uint256 maxTraversal, + address owner + ) + PythSourceAdapter(source, pythPriceId) + MutableUnlockersController(lockWindow, maxTraversal, unlockers) + ChainlinkDestinationAdapter(18) + { + _transferOwnership(owner); + } +} + +/** + * @title StandardPythFactory is the recommended factory for use cases that want a Pyth source and Chainlink + * interface. + * @dev This is the best factory for most use cases that need a Pyth source, but there are other variants that may be + * needed if different mutability or interface choices are desired. + */ +contract StandardPythFactory is Ownable, BaseFactory { + IPyth immutable pyth; + + constructor(IPyth _pyth, uint256 _maxTraversal, address[] memory _defaultUnlockers) + BaseFactory(_maxTraversal, _defaultUnlockers) + { + pyth = _pyth; + } + + /** + * @notice Creates the Pyth Oval instance. + * @param pythPriceId the Pyth price id. + * @param lockWindow the lockWindow used for this Oval instance. This is the length of the window + * for the Oval auction to be run and, thus, the maximum time that prices will be delayed. + * @return oval deployed oval address. + */ + function create(bytes32 pythPriceId, uint256 lockWindow) external returns (address oval) { + oval = address(new OvalPyth(pyth, pythPriceId, defaultUnlockers, lockWindow, MAX_TRAVERSAL, owner())); + emit OvalDeployed(msg.sender, oval, lockWindow, MAX_TRAVERSAL, owner(), defaultUnlockers); + } +} 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/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/fork/adapters/ChainlinkSourceAdapter.sol b/test/fork/adapters/ChainlinkSourceAdapter.sol index d44a665..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 updatedAt,) = chainlink.getRoundData(latestRound - 1); - assertTrue(updatedAt <= targetTime); // The time from the chainlink source is at least 1 hours old. + (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(updatedAt == lookBackTimestamp); + 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,, updatedAt,) = chainlink.getRoundData(latestRound - 2); - assertTrue(updatedAt <= targetTime); // The time from the chainlink source is at least 2 hours old. + (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(updatedAt == lookBackTimestamp); + 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,, updatedAt,) = chainlink.getRoundData(latestRound - 4); - assertTrue(updatedAt <= block.timestamp - 3 hours); // The time from the chainlink source is at least 3 hours old. - assertTrue(updatedAt > block.timestamp - 4 hours); // Time from chainlink source is at not more than 4 hours. + (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 updatedAt,) = chainlink.getRoundData(latestRound - 2); + (uint80 roundId, int256 answer, uint256 startedAt,,) = chainlink.getRoundData(latestRound - 2); assertTrue(scaleChainlinkTo18(answer) == lookBackPrice); - assertTrue(updatedAt == lookBackTimestamp); + 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(updatedAt == lookBackTimestamp); + 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(updatedAt == lookBackTimestamp); + 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 e1bf827..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) {} @@ -47,7 +48,7 @@ contract UniswapAnchoredViewSourceAdapterTest is CommonTest { assertTrue(latestAggregatorTimestamp == latestSourceTimestamp); } - function testCorrectlyStandardizesOutputs() public { + function testCorrectlyStandardizesLatestOutputs() public { // Repeat the same test as above, but with cWBTC where underlying has 8 decimals. address cWBTC = 0xccF4429DB6322D5C611ee964527D42E5d685DD6a; sourceAdapter = new TestedSourceAdapter(uniswapAnchoredView, cWBTC); @@ -64,6 +65,32 @@ contract UniswapAnchoredViewSourceAdapterTest is CommonTest { assertTrue(latestAggregatorTimestamp == latestSourceTimestamp); } + function testCorrectlyStandardizesLatestAtOutputs() public { + // Repeat the same test for cWBTC as above, but for tryLatestDataAt. + uint256 targetTime = block.timestamp; + + address cWBTC = 0xccF4429DB6322D5C611ee964527D42E5d685DD6a; + sourceAdapter = new TestedSourceAdapter(uniswapAnchoredView, cWBTC); + aggregator = IAccessControlledAggregatorV3(address(sourceAdapter.aggregator())); + + // Fork ~24 hours (7200 blocks on mainnet) forward with persistent source adapter. + vm.makePersistent(address(sourceAdapter)); + vm.createSelectFork("mainnet", targetBlock + 7200); + _whitelistOnAggregator(); + + // 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, 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 { uint256 targetTime = block.timestamp; @@ -78,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 { @@ -88,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); @@ -116,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/BaseController.sol b/test/unit/BaseController.sol index e02247e..8caffc0 100644 --- a/test/unit/BaseController.sol +++ b/test/unit/BaseController.sol @@ -10,7 +10,7 @@ contract TestBaseController is BaseController, MockSourceAdapter, BaseDestinatio constructor(uint8 decimals) MockSourceAdapter(decimals) BaseController() BaseDestinationAdapter() {} } -contract OvalUnlockLatestValue is CommonTest { +contract BaseControllerTest is CommonTest { uint256 lastUnlockTime = 1690000000; TestBaseController baseController; 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); + } +} diff --git a/test/unit/ImmutableController.sol b/test/unit/ImmutableController.sol index bd13b73..4bacf79 100644 --- a/test/unit/ImmutableController.sol +++ b/test/unit/ImmutableController.sol @@ -14,7 +14,7 @@ contract TestImmutableController is ImmutableController, MockSourceAdapter, Base {} } -contract OvalUnlockLatestValue is CommonTest { +contract ImmutableControllerTest is CommonTest { uint8 decimals = 8; uint256 lockWindow = 60; uint256 maxTraversal = 10; @@ -47,28 +47,4 @@ contract OvalUnlockLatestValue is CommonTest { function testMaxTraversalSetCorrectly() public { assertTrue(immutableController.maxTraversal() == maxTraversal); } - - function testCannotSetUnlocker() public { - bytes4 selector = bytes4(keccak256("setUnlocker(address,bool)")); - bytes memory data = abi.encodeWithSelector(selector, random, true); - vm.prank(owner); - (bool success,) = address(immutableController).call(data); - assertFalse(success); - } - - function testCannotSetLockWindow() public { - bytes4 selector = bytes4(keccak256("setLockWindow(uint256)")); - bytes memory data = abi.encodeWithSelector(selector, lockWindow + 1); - vm.prank(owner); - (bool success,) = address(immutableController).call(data); - assertFalse(success); - } - - function testCannotSetMaxTraversal() public { - bytes4 selector = bytes4(keccak256("setMaxTraversal(uint256)")); - bytes memory data = abi.encodeWithSelector(selector, maxTraversal + 1); - vm.prank(owner); - (bool success,) = address(immutableController).call(data); - assertFalse(success); - } } diff --git a/test/unit/MutableUnlockersController.sol b/test/unit/MutableUnlockersController.sol new file mode 100644 index 0000000..5abd105 --- /dev/null +++ b/test/unit/MutableUnlockersController.sol @@ -0,0 +1,57 @@ +pragma solidity 0.8.17; + +import {CommonTest} from "../Common.sol"; +import {MutableUnlockersController} from "../../src/controllers/MutableUnlockersController.sol"; +import {MockSourceAdapter} from "../mocks/MockSourceAdapter.sol"; +import {BaseDestinationAdapter} from "../../src/adapters/destination-adapters/BaseDestinationAdapter.sol"; + +contract TestMutableUnlockersController is MutableUnlockersController, MockSourceAdapter, BaseDestinationAdapter { + constructor(address[] memory _unlockers) + MutableUnlockersController(300, 15, _unlockers) + MockSourceAdapter(18) // Assuming 18 decimals for the mock source adapter + BaseDestinationAdapter() + {} +} + +contract MutableUnlockersControllerTest is CommonTest { + TestMutableUnlockersController mutableController; + address[] initialUnlockers; + + function setUp() public { + initialUnlockers.push(permissionedUnlocker); + vm.prank(owner); + mutableController = new TestMutableUnlockersController(initialUnlockers); + } + + function testInitialUnlockersCanUnlock() public { + assertTrue(mutableController.canUnlock(initialUnlockers[0], 0)); + } + + function testNonInitialUnlockerCannotUnlock() public { + assertFalse(mutableController.canUnlock(random, 0)); + } + + function testOwnerCanAddUnlocker() public { + vm.prank(owner); + mutableController.setUnlocker(random, true); + assertTrue(mutableController.canUnlock(random, 0)); + } + + function testOwnerCanRemoveUnlocker() public { + vm.prank(owner); + mutableController.setUnlocker(permissionedUnlocker, false); + assertFalse(mutableController.canUnlock(permissionedUnlocker, 0)); + } + + function testNonOwnerCannotAddUnlocker() public { + vm.prank(random); + vm.expectRevert("Ownable: caller is not the owner"); + mutableController.setUnlocker(random, true); + } + + function testNonOwnerCannotRemoveUnlocker() public { + vm.prank(random); + vm.expectRevert("Ownable: caller is not the owner"); + mutableController.setUnlocker(permissionedUnlocker, false); + } +} 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/PermissionProxy.sol b/test/unit/PermissionProxy.sol new file mode 100644 index 0000000..fac8ca6 --- /dev/null +++ b/test/unit/PermissionProxy.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {CommonTest} from "../Common.sol"; +import {PermissionProxy} from "../../src/factories/PermissionProxy.sol"; + +contract PermissionProxyTest is CommonTest { + PermissionProxy permissionProxy; + address mockAddress = address(0xdeadbeef); + bytes testCallData = abi.encodeWithSignature("foo()"); + uint256 testValue = 1; + bytes returnData = abi.encode(uint256(7)); + + function setUp() public { + permissionProxy = new PermissionProxy(); + permissionProxy.setSender(account1, true); + } + + function testSenderPermissions() public { + vm.prank(account2); + vm.expectRevert(abi.encodeWithSelector(PermissionProxy.SenderNotApproved.selector, account2)); + permissionProxy.execute(mockAddress, testValue, testCallData); + + vm.prank(account1); + vm.mockCall(mockAddress, testValue, testCallData, abi.encode(uint256(7))); + vm.expectCall(mockAddress, testValue, testCallData); + bytes memory actualReturnValue = permissionProxy.execute(mockAddress, testValue, testCallData); + assertEq0(actualReturnValue, returnData); + } + + function testCallFailed() public { + vm.prank(account1); + vm.mockCallRevert(mockAddress, testValue, testCallData, ""); + vm.expectRevert( + abi.encodeWithSelector(PermissionProxy.CallFailed.selector, mockAddress, testValue, testCallData) + ); + permissionProxy.execute(mockAddress, testValue, testCallData); + } + + function testSetSender() public { + permissionProxy.transferOwnership(owner); + + vm.startPrank(owner); + permissionProxy.setSender(account2, true); + permissionProxy.setSender(account1, false); + vm.stopPrank(); + + assertTrue(!permissionProxy.senders(account1)); + assertTrue(permissionProxy.senders(account2)); + } +} diff --git a/test/unit/StandardChainlinkFactory.sol b/test/unit/StandardChainlinkFactory.sol new file mode 100644 index 0000000..c24cf72 --- /dev/null +++ b/test/unit/StandardChainlinkFactory.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {StandardChainlinkFactory} from "../../src/factories/StandardChainlinkFactory.sol"; +import {OvalChainlink} from "../../src/factories/StandardChainlinkFactory.sol"; +import {IAggregatorV3Source} from "../../src/interfaces/chainlink/IAggregatorV3Source.sol"; +import {MockChainlinkV3Aggregator} from "../mocks/MockChainlinkV3Aggregator.sol"; +import {CommonTest} from "../Common.sol"; + +contract StandardChainlinkFactoryTest is CommonTest { + StandardChainlinkFactory factory; + MockChainlinkV3Aggregator mockSource; + address[] unlockers; + uint256 lockWindow = 300; + uint256 maxTraversal = 15; + + function setUp() public { + mockSource = new MockChainlinkV3Aggregator(8, 420); + unlockers.push(address(0x123)); + factory = new StandardChainlinkFactory(maxTraversal, unlockers); + } + + function testCreateMutableUnlockerOvalChainlink() public { + address created = factory.create(IAggregatorV3Source(address(mockSource)), lockWindow); + + assertTrue(created != address(0)); // Check if the address is set, non-zero. + + OvalChainlink instance = OvalChainlink(created); + assertTrue(instance.lockWindow() == lockWindow); + assertTrue(instance.maxTraversal() == maxTraversal); + + // Check if the unlockers are set correctly + for (uint256 i = 0; i < unlockers.length; i++) { + assertTrue(instance.canUnlock(unlockers[i], 0)); + } + assertFalse(instance.canUnlock(address(0x456), 0)); // Check if a random address cannot unlock + } + + function testOwnerCanChangeUnlockers() public { + address created = factory.create(IAggregatorV3Source(address(mockSource)), lockWindow); + OvalChainlink instance = OvalChainlink(created); + + address newUnlocker = address(0x789); + instance.setUnlocker(newUnlocker, true); // Correct method to add unlockers + assertTrue(instance.canUnlock(newUnlocker, 0)); + + instance.setUnlocker(address(0x123), false); // Correct method to remove unlockers + assertFalse(instance.canUnlock(address(0x123), 0)); + } +} diff --git a/test/unit/StandardChronicleFactory.sol b/test/unit/StandardChronicleFactory.sol new file mode 100644 index 0000000..6c21a47 --- /dev/null +++ b/test/unit/StandardChronicleFactory.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {StandardChronicleFactory} from "../../src/factories/StandardChronicleFactory.sol"; +import {OvalChronicle} from "../../src/factories/StandardChronicleFactory.sol"; +import {IMedian} from "../../src/interfaces/chronicle/IMedian.sol"; +import {CommonTest} from "../Common.sol"; + +contract StandardChronicleFactoryTest is CommonTest { + StandardChronicleFactory factory; + IMedian mockSource; + address[] unlockers; + uint256 lockWindow = 300; + uint256 maxTraversal = 15; + + function setUp() public { + mockSource = IMedian(address(0x456)); + unlockers.push(address(0x123)); + factory = new StandardChronicleFactory(maxTraversal, unlockers); + } + + function testCreateMutableUnlockerOvalChronicle() public { + address created = factory.create(mockSource, lockWindow); + + assertTrue(created != address(0)); // Check if the address is set, non-zero. + + OvalChronicle instance = OvalChronicle(created); + assertTrue(instance.lockWindow() == lockWindow); + assertTrue(instance.maxTraversal() == maxTraversal); + + // Check if the unlockers are set correctly + for (uint256 i = 0; i < unlockers.length; i++) { + assertTrue(instance.canUnlock(unlockers[i], 0)); + } + assertFalse(instance.canUnlock(address(0x456), 0)); // Check if a random address cannot unlock + } + + function testOwnerCanChangeUnlockers() public { + address created = factory.create(mockSource, lockWindow); + OvalChronicle instance = OvalChronicle(created); + + address newUnlocker = address(0x789); + instance.setUnlocker(newUnlocker, true); // Correct method to add unlockers + assertTrue(instance.canUnlock(newUnlocker, 0)); + + instance.setUnlocker(address(0x123), false); // Correct method to remove unlockers + assertFalse(instance.canUnlock(address(0x123), 0)); + } +} diff --git a/test/unit/StandardCoinbaseFactory.sol b/test/unit/StandardCoinbaseFactory.sol new file mode 100644 index 0000000..91cac2c --- /dev/null +++ b/test/unit/StandardCoinbaseFactory.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {StandardCoinbaseFactory} from "../../src/factories/StandardCoinbaseFactory.sol"; +import {OvalCoinbase} from "../../src/factories/StandardCoinbaseFactory.sol"; +import {IAggregatorV3SourceCoinbase} from "../../src/interfaces/coinbase/IAggregatorV3SourceCoinbase.sol"; +import {MockChainlinkV3Aggregator} from "../mocks/MockChainlinkV3Aggregator.sol"; +import {CommonTest} from "../Common.sol"; + +contract StandardCoinbaseFactoryTest is CommonTest { + StandardCoinbaseFactory factory; + IAggregatorV3SourceCoinbase mockSource; + address[] unlockers; + uint256 lockWindow = 300; + uint256 maxTraversal = 15; + string ticker = "test ticker"; + + function setUp() public { + mockSource = IAggregatorV3SourceCoinbase(address(new MockChainlinkV3Aggregator(8, 420))); + unlockers.push(address(0x123)); + factory = new StandardCoinbaseFactory(mockSource, maxTraversal, unlockers); + } + + function testCreateMutableUnlockerOvalCoinbase() public { + address created = factory.create(ticker, lockWindow); + + assertTrue(created != address(0)); // Check if the address is set, non-zero. + + OvalCoinbase instance = OvalCoinbase(created); + assertTrue(instance.lockWindow() == lockWindow); + assertTrue(instance.maxTraversal() == maxTraversal); + + // Check if the unlockers are set correctly + for (uint256 i = 0; i < unlockers.length; i++) { + assertTrue(instance.canUnlock(unlockers[i], 0)); + } + assertFalse(instance.canUnlock(address(0x456), 0)); // Check if a random address cannot unlock + } + + function testOwnerCanChangeUnlockers() public { + address created = factory.create(ticker, lockWindow); + OvalCoinbase instance = OvalCoinbase(created); + + address newUnlocker = address(0x789); + instance.setUnlocker(newUnlocker, true); // Correct method to add unlockers + assertTrue(instance.canUnlock(newUnlocker, 0)); + + instance.setUnlocker(address(0x123), false); // Correct method to remove unlockers + assertFalse(instance.canUnlock(address(0x123), 0)); + } +} diff --git a/test/unit/StandardPythFactory.sol b/test/unit/StandardPythFactory.sol new file mode 100644 index 0000000..de80a60 --- /dev/null +++ b/test/unit/StandardPythFactory.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {StandardPythFactory} from "../../src/factories/StandardPythFactory.sol"; +import {OvalPyth} from "../../src/factories/StandardPythFactory.sol"; +import {IPyth} from "../../src/interfaces/pyth/IPyth.sol"; +import {CommonTest} from "../Common.sol"; + +contract StandardPythFactoryTest is CommonTest { + StandardPythFactory factory; + IPyth mockSource; + address[] unlockers; + uint256 lockWindow = 300; + uint256 maxTraversal = 15; + + function setUp() public { + mockSource = IPyth(address(0x456)); + unlockers.push(address(0x123)); + factory = new StandardPythFactory(mockSource, maxTraversal, unlockers); + } + + function testCreateMutableUnlockerOvalPyth() public { + address created = factory.create(bytes32(uint256(0x789)), lockWindow); + + assertTrue(created != address(0)); // Check if the address is set, non-zero. + + OvalPyth instance = OvalPyth(created); + assertTrue(instance.lockWindow() == lockWindow); + assertTrue(instance.maxTraversal() == maxTraversal); + + // Check if the unlockers are set correctly + for (uint256 i = 0; i < unlockers.length; i++) { + assertTrue(instance.canUnlock(unlockers[i], 0)); + } + assertFalse(instance.canUnlock(address(0x456), 0)); // Check if a random address cannot unlock + } + + function testOwnerCanChangeUnlockers() public { + address created = factory.create(bytes32(uint256(0x789)), lockWindow); + OvalPyth instance = OvalPyth(created); + + address newUnlocker = address(0x789); + instance.setUnlocker(newUnlocker, true); // Correct method to add unlockers + assertTrue(instance.canUnlock(newUnlocker, 0)); + + instance.setUnlocker(address(0x123), false); // Correct method to remove unlockers + assertFalse(instance.canUnlock(address(0x123), 0)); + } +} 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) {}