diff --git a/src/adapters/destination-adapters/ChronicleMedianDestinationAdapter.sol b/src/adapters/destination-adapters/ChronicleMedianDestinationAdapter.sol new file mode 100644 index 0000000..3e31042 --- /dev/null +++ b/src/adapters/destination-adapters/ChronicleMedianDestinationAdapter.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {IMedian} from "../../interfaces/chronicle/IMedian.sol"; +import {DiamondRootOval} from "../../DiamondRootOval.sol"; + +/** + * @notice ChronicleMedianDestinationAdapter contract to expose Oval data via the standard Chronicle interface. + */ + +abstract contract ChronicleMedianDestinationAdapter is IMedian, DiamondRootOval { + constructor(address _sourceAdapter) {} + + uint8 public constant decimals = 18; // Chronicle price feeds have always have 18 decimals. + + /** + * @notice Returns the latest data from the source. + * @dev The standard chronicle implementation will revert if the latest answer is not valid when calling the read + * function. This implementation will only revert if the latest answer is negative. + * @return answer The latest answer in 18 decimals. + */ + function read() public view override returns (uint256) { + (int256 answer,) = internalLatestData(); + require(answer > 0, "Median/invalid-price-feed"); + return uint256(answer); + } + + /** + * @notice Returns the latest data from the source and a bool indicating if the value is valid. + * @return answer The latest answer in 18 decimals. + * @return valid True if the value returned is valid. + */ + function peek() public view override returns (uint256, bool) { + (int256 answer,) = internalLatestData(); + return (uint256(answer), answer > 0); + } + + /** + * @notice Returns the timestamp of the most recently updated data. + * @return timestamp The timestamp of the most recent update. + */ + function age() public view override returns (uint32) { + (, uint256 timestamp) = internalLatestData(); + return uint32(timestamp); + } +} diff --git a/src/adapters/destination-adapters/OSMDestinationAdapter.sol b/src/adapters/destination-adapters/OSMDestinationAdapter.sol new file mode 100644 index 0000000..96ec15f --- /dev/null +++ b/src/adapters/destination-adapters/OSMDestinationAdapter.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {IOSM} from "../../interfaces/makerdao/IOSM.sol"; +import {DiamondRootOval} from "../../DiamondRootOval.sol"; + +/** + * @title OSMDestinationAdapter contract to expose Oval data via the standard MakerDAO OSM interface. + */ + +abstract contract OSMDestinationAdapter is IOSM, DiamondRootOval { + constructor() {} + + /** + * @notice Returns the latest data from the source, formatted for the OSM interface as a bytes32. + * @dev The standard OSM implementation will revert if the latest answer is not valid when calling the read function. + * This implementation will only revert if the latest answer is negative. + * @return answer The latest answer as a bytes32. + */ + 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(); + return bytes32(uint256(answer)); + } + + /** + * @notice Returns the latest data from the source and a bool indicating if the value is valid. + * @return answer The latest answer as a bytes32. + * @return valid True if the value returned is valid. + */ + function peek() public view override returns (bytes32, bool) { + (int256 answer,) = internalLatestData(); + // This might be required for MakerDAO when voiding Oracle sources. + return (bytes32(uint256(answer)), answer > 0); + } + + /** + * @notice Returns the timestamp of the most recently updated data. + * @return timestamp The timestamp of the most recent update. + */ + function zzz() public view override returns (uint64) { + (, uint256 timestamp) = internalLatestData(); + return uint64(timestamp); + } +} diff --git a/src/adapters/destination-adapters/PythDestinationAdapter.sol b/src/adapters/destination-adapters/PythDestinationAdapter.sol new file mode 100644 index 0000000..3569a58 --- /dev/null +++ b/src/adapters/destination-adapters/PythDestinationAdapter.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; +import {SafeCast} from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; + +import {IPyth} from "../../interfaces/pyth/IPyth.sol"; +import {IOval} from "../../interfaces/IOval.sol"; +import {DecimalLib} from "../lib/DecimalLib.sol"; + +/** + * @notice PythDestinationAdapter contract to expose Oval data via the standard pyth interface. + */ +contract PythDestinationAdapter is Ownable, IPyth { + mapping(bytes32 => IOval) public idToOval; + mapping(bytes32 => uint8) public idToDecimal; + mapping(bytes32 => uint256) public idToValidTimePeriod; + + IPyth public immutable basePythProvider; + + event BaseSourceSet(address indexed sourceOracle); + event OvalSet(bytes32 indexed id, uint8 indexed decimals, uint256 validTimePeriod, address indexed oval); + + constructor(IPyth _basePythProvider) { + basePythProvider = _basePythProvider; + + emit BaseSourceSet(address(_basePythProvider)); + } + + /** + * @notice Enables the owner to set mapping between pyth identifiers and Ovals. Done for each identifier. + * @param id The pyth identifier to set the Oval for. + * @param decimals The number of decimals for the identifier. + * @param validTimePeriod The number of seconds that a price is valid for. + * @param oval The Oval to set for the identifier. + */ + function setOval(bytes32 id, uint8 decimals, uint256 validTimePeriod, IOval oval) public onlyOwner { + idToOval[id] = oval; + idToDecimal[id] = decimals; + idToValidTimePeriod[id] = validTimePeriod; + + emit OvalSet(id, decimals, validTimePeriod, address(oval)); + } + + /** + * @notice Returns the price for the given identifier. This function does not care if the price is too old. + * @param id The pyth identifier to get the price for. + * @return price the standard pyth price struct. + */ + function getPriceUnsafe(bytes32 id) public view returns (Price memory) { + if (address(idToOval[id]) == address(0)) { + return basePythProvider.getPriceUnsafe(id); + } + (int256 answer, uint256 timestamp) = idToOval[id].internalLatestData(); + return Price({ + price: SafeCast.toInt64(DecimalLib.convertDecimals(answer, 18, idToDecimal[id])), + conf: 0, + expo: -int32(uint32(idToDecimal[id])), + publishTime: timestamp + }); + } + + /** + * @notice Function to get price. + * @dev in pyth, this function reverts if the returned price isn't older than a configurable number of seconds. + * idToValidTimePeriod[id] is that number of seconds in this contract. + * @param id The pyth identifier to get the price for. + * @return price the standard pyth price struct. + */ + function getPrice(bytes32 id) external view returns (Price memory) { + if (address(idToOval[id]) == address(0)) { + return basePythProvider.getPrice(id); + } + Price memory price = getPriceUnsafe(id); + require(_diff(block.timestamp, price.publishTime) <= idToValidTimePeriod[id], "Not within valid window"); + return price; + } + + // Internal function to get absolute difference between two numbers. This implementation replicates diff function + // logic from AbstractPyth contract used in Pyth oracle: + // https://github.com/pyth-network/pyth-sdk-solidity/blob/c24b3e0173a5715c875ae035c20e063cb900f481/AbstractPyth.sol#L79 + function _diff(uint256 x, uint256 y) internal pure returns (uint256) { + if (x > y) return x - y; + return y - x; + } +} diff --git a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol new file mode 100644 index 0000000..793eb52 --- /dev/null +++ b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {SignedMath} from "openzeppelin-contracts/contracts/utils/math/SignedMath.sol"; + +import {IAggregatorV3Source} from "../../interfaces/chainlink/IAggregatorV3Source.sol"; +import {IMedian} from "../../interfaces/chronicle/IMedian.sol"; +import {IPyth} from "../../interfaces/pyth/IPyth.sol"; +import {ChainlinkSourceAdapter} from "./ChainlinkSourceAdapter.sol"; +import {ChronicleMedianSourceAdapter} from "./ChronicleMedianSourceAdapter.sol"; +import {PythSourceAdapter} from "./PythSourceAdapter.sol"; +import {SnapshotSource} from "./SnapshotSource.sol"; + +/** + * @title BoundedUnionSourceAdapter contract to read data from multiple sources and return the newest, contingent on it + * being within a certain tolerance of the other sources. The return logic operates as follows: + * a) Return the most recent price if it's within tolerance of at least one of the other two. + * b) If not, return the second most recent price if it's within tolerance of at least one of the other two. + * c) If neither a) nor b) is met, return the chainlink price. + * @dev This adapter only works with Chainlink, Chronicle and Pyth adapters. If alternative adapter configs are desired + * then a new adapter should be created. + */ + +abstract contract BoundedUnionSourceAdapter is + ChainlinkSourceAdapter, + ChronicleMedianSourceAdapter, + PythSourceAdapter +{ + uint256 public immutable BOUNDING_TOLERANCE; + + constructor( + IAggregatorV3Source chainlink, + IMedian chronicle, + IPyth pyth, + bytes32 pythPriceId, + uint256 boundingTolerance + ) ChainlinkSourceAdapter(chainlink) ChronicleMedianSourceAdapter(chronicle) PythSourceAdapter(pyth, pythPriceId) { + BOUNDING_TOLERANCE = boundingTolerance; + } + + /** + * @notice Returns the latest data from the source, contingent on it being within a tolerance of the other sources. + * @return answer The latest answer in 18 decimals. + * @return timestamp The timestamp of the answer. + */ + function getLatestSourceData() + public + view + override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) + returns (int256 answer, uint256 timestamp) + { + (int256 clAnswer, uint256 clTimestamp) = ChainlinkSourceAdapter.getLatestSourceData(); + (int256 crAnswer, uint256 crTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData(); + (int256 pyAnswer, uint256 pyTimestamp) = PythSourceAdapter.getLatestSourceData(); + + return _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp); + } + + /** + * @notice Snapshots is a no-op for this adapter as its never used. + */ + function snapshotData() public override(ChainlinkSourceAdapter, SnapshotSource) {} + + /** + * @notice Tries getting latest data as of requested timestamp. Note that for all historic lookups we simply return + * the Chainlink data as this is the only supported source that has historical data. + * @param timestamp The timestamp to try getting latest data at. + * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. + * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) + public + view + override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) + returns (int256, uint256) + { + // Chainlink has price history, so use tryLatestDataAt to pull the most recent price that satisfies the timestamp constraint. + (int256 clAnswer, uint256 clTimestamp) = ChainlinkSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); + + // For Chronicle and Pyth, just pull the most recent prices and drop them if they don't satisfy the constraint. + (int256 crAnswer, uint256 crTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData(); + (int256 pyAnswer, uint256 pyTimestamp) = PythSourceAdapter.getLatestSourceData(); + + // To "drop" Chronicle and Pyth, we set their timestamps to 0 (as old as possible) if they are too recent. + // This means that they will never be used if either or both are 0. + if (crTimestamp > timestamp) crTimestamp = 0; + if (pyTimestamp > timestamp) pyTimestamp = 0; + + return _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp); + } + + // Selects the appropriate price from the three sources based on the bounding tolerance and logic. + function _selectBoundedPrice(int256 cl, uint256 clT, int256 cr, uint256 crT, int256 py, uint256 pyT) + internal + view + returns (int256, uint256) + { + int256 newestVal = 0; + uint256 newestT = 0; + + // For each price, check if it is within tolerance of the other two. If so, check if it is the newest. + if (pyT > newestT && (_withinTolerance(py, cr) || _withinTolerance(py, cl))) (newestVal, newestT) = (py, pyT); + if (crT > newestT && (_withinTolerance(cr, py) || _withinTolerance(cr, cl))) (newestVal, newestT) = (cr, crT); + if (clT > newestT && (_withinTolerance(cl, py) || _withinTolerance(cl, cr))) (newestVal, newestT) = (cl, clT); + + if (newestT == 0) return (cl, clT); // If no valid price was found, default to returning chainlink. + + return (newestVal, newestT); + } + + // Checks if value a is within tolerance of value b. + function _withinTolerance(int256 a, int256 b) internal view returns (bool) { + uint256 diff = SignedMath.abs(a - b); + uint256 maxDiff = SignedMath.abs(b) * BOUNDING_TOLERANCE / 1e18; + return diff <= maxDiff; + } +} diff --git a/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol b/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol new file mode 100644 index 0000000..b58e900 --- /dev/null +++ b/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {SnapshotSource} from "./SnapshotSource.sol"; +import {IMedian} from "../../interfaces/chronicle/IMedian.sol"; +import {SafeCast} from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; + +/** + * @title ChronicleMedianSourceAdapter contract to read data from Chronicle and standardize it for Oval. + */ + +abstract contract ChronicleMedianSourceAdapter is SnapshotSource { + IMedian public immutable CHRONICLE_SOURCE; + + event SourceSet(address indexed sourceOracle); + + constructor(IMedian _chronicleSource) { + CHRONICLE_SOURCE = _chronicleSource; + + emit SourceSet(address(_chronicleSource)); + } + + /** + * @notice Returns the latest data from the source. + * @dev The standard chronicle implementation will revert if the latest answer is not valid when calling the read + * function. Additionally, chronicle returns the answer in 18 decimals, so no conversion is needed. + * @return answer The latest answer in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function getLatestSourceData() public view virtual override returns (int256, uint256) { + return (SafeCast.toInt256(CHRONICLE_SOURCE.read()), CHRONICLE_SOURCE.age()); + } + + /** + * @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data + * available past the requested timestamp within provided traversal limitations. + * @dev Chronicle does not support historical lookups so this uses SnapshotSource to get historic data. + * @param timestamp The timestamp to try getting latest data at. + * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. + * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) + public + view + virtual + override + returns (int256, uint256) + { + Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + return (snapshot.answer, snapshot.timestamp); + } +} diff --git a/src/adapters/source-adapters/OSMSourceAdapter.sol b/src/adapters/source-adapters/OSMSourceAdapter.sol new file mode 100644 index 0000000..07b7c3d --- /dev/null +++ b/src/adapters/source-adapters/OSMSourceAdapter.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {SnapshotSource} from "./SnapshotSource.sol"; +import {IOSM} from "../../interfaces/makerdao/IOSM.sol"; + +/** + * @title OSMSourceAdapter contract to read data from MakerDAO OSM and standardize it for Oval. + */ + +abstract contract OSMSourceAdapter is SnapshotSource { + IOSM public immutable osmSource; + + // MakerDAO performs decimal conversion in collateral adapter contracts, so all oracle prices are expected to have + // 18 decimals and we can skip decimal conversion. + uint8 public constant decimals = 18; + + event SourceSet(address indexed sourceOracle); + + constructor(IOSM source) { + osmSource = source; + + emit SourceSet(address(source)); + } + + /** + * @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 override returns (int256, uint256) { + return (int256(uint256(osmSource.read())), osmSource.zzz()); + } + + /** + * @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data + * available past the requested timestamp within provided traversal limitations. + * @dev OSM does not support historical lookups so this uses SnapshotSource to get historic data. + * @param timestamp The timestamp to try getting latest data at. + * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. + * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view override returns (int256, uint256) { + Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + return (snapshot.answer, snapshot.timestamp); + } +} diff --git a/src/adapters/source-adapters/PythSourceAdapter.sol b/src/adapters/source-adapters/PythSourceAdapter.sol new file mode 100644 index 0000000..adb762d --- /dev/null +++ b/src/adapters/source-adapters/PythSourceAdapter.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {IPyth} from "../../interfaces/pyth/IPyth.sol"; +import {SnapshotSource} from "./SnapshotSource.sol"; +import {DecimalLib} from "../lib/DecimalLib.sol"; + +/** + * @title PythSourceAdapter contract to read data from Pyth and standardize it for Oval. + */ + +abstract contract PythSourceAdapter is SnapshotSource { + IPyth public immutable PYTH_SOURCE; + bytes32 public immutable PYTH_PRICE_ID; + + event SourceSet(address indexed sourceOracle, bytes32 indexed pythPriceId); + + constructor(IPyth _pyth, bytes32 _pythPriceId) { + PYTH_SOURCE = _pyth; + PYTH_PRICE_ID = _pythPriceId; + + emit SourceSet(address(_pyth), _pythPriceId); + } + + /** + * @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) { + IPyth.Price memory pythPrice = PYTH_SOURCE.getPriceUnsafe(PYTH_PRICE_ID); + return (_convertDecimalsWithExponent(pythPrice.price, pythPrice.expo), pythPrice.publishTime); + } + + /** + * @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data + * available past the requested timestamp within provided traversal limitations. + * @dev Pyth does not support historical lookups so this uses SnapshotSource to get historic data. + * @param timestamp The timestamp to try getting latest data at. + * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. + * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) + public + view + virtual + override + returns (int256, uint256) + { + Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + return (snapshot.answer, snapshot.timestamp); + } + + // Handle a per-price "expo" (decimal) value from pyth. + function _convertDecimalsWithExponent(int256 answer, int32 expo) internal pure returns (int256) { + // Expo is pyth's way of expressing decimals. -18 is equivalent to 18 decimals. -5 is equivalent to 5. + if (expo <= 0) return DecimalLib.convertDecimals(answer, uint8(uint32(-expo)), 18); + // Add the _decimals and expo in the case that expo is positive since it means that the fixed point number is + // _smaller_ than the true value. This case may never be hit, it seems preferable to reverting. + else return DecimalLib.convertDecimals(answer, 0, 18 + uint8(uint32(expo))); + } +} diff --git a/src/adapters/source-adapters/UnionSourceAdapter.sol b/src/adapters/source-adapters/UnionSourceAdapter.sol new file mode 100644 index 0000000..8fc8852 --- /dev/null +++ b/src/adapters/source-adapters/UnionSourceAdapter.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {IAggregatorV3Source} from "../../interfaces/chainlink/IAggregatorV3Source.sol"; +import {IMedian} from "../../interfaces/chronicle/IMedian.sol"; +import {IPyth} from "../../interfaces/pyth/IPyth.sol"; +import {ChainlinkSourceAdapter} from "./ChainlinkSourceAdapter.sol"; +import {ChronicleMedianSourceAdapter} from "./ChronicleMedianSourceAdapter.sol"; +import {PythSourceAdapter} from "./PythSourceAdapter.sol"; +import {SnapshotSource} from "./SnapshotSource.sol"; + +/** + * @title UnionSourceAdapter contract to read data from multiple sources and return the newest. + * @dev This adapter only works with Chainlink, Chronicle and Pyth adapters. If alternative adapter configs are desired + * then a new adapter should be created. + */ + +abstract contract UnionSourceAdapter is ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter { + constructor(IAggregatorV3Source chainlink, IMedian chronicle, IPyth pyth, bytes32 pythPriceId) + ChainlinkSourceAdapter(chainlink) + ChronicleMedianSourceAdapter(chronicle) + PythSourceAdapter(pyth, pythPriceId) + {} + + /** + * @notice Returns the latest data from the source. As this source is the union of multiple sources, it will return + * the most recent data from the set of sources. + * @return answer The latest answer in 18 decimals. + * @return timestamp The timestamp of the answer. + */ + function getLatestSourceData() + public + view + override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) + returns (int256, uint256) + { + (int256 clAnswer, uint256 clTimestamp) = ChainlinkSourceAdapter.getLatestSourceData(); + (int256 crAnswer, uint256 crTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData(); + (int256 pyAnswer, uint256 pyTimestamp) = PythSourceAdapter.getLatestSourceData(); + + if (clTimestamp >= crTimestamp && clTimestamp >= pyTimestamp) return (clAnswer, clTimestamp); + else if (crTimestamp >= pyTimestamp) return (crAnswer, crTimestamp); + else return (pyAnswer, pyTimestamp); + } + + /** + * @notice Snapshots data from all sources that require it. + */ + function snapshotData() public override(ChainlinkSourceAdapter, SnapshotSource) { + SnapshotSource.snapshotData(); + } + + /** + * @notice Tries getting latest data as of requested timestamp. Note that for all historic lookups we simply return + * the chainlink data as this is the only supported source that has historical data. + * @param timestamp The timestamp to try getting latest data at. + * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. + * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. + * @return updatedAt The timestamp of the answer. + */ + function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) + public + view + override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) + returns (int256, 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); + + // For Chronicle and Pyth, just pull the most recent prices and drop them if they don't satisfy the constraint. + (int256 crAnswer, uint256 crTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData(); + (int256 pyAnswer, uint256 pyTimestamp) = PythSourceAdapter.getLatestSourceData(); + + // To "drop" Chronicle and Pyth, we set their timestamps to 0 (as old as possible) if they are too recent. + // This means that they will never be used if either or both are 0. + if (crTimestamp > timestamp) crTimestamp = 0; + if (pyTimestamp > timestamp) pyTimestamp = 0; + + // 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); + } +} diff --git a/src/interfaces/chronicle/IMedian.sol b/src/interfaces/chronicle/IMedian.sol new file mode 100644 index 0000000..05a7dec --- /dev/null +++ b/src/interfaces/chronicle/IMedian.sol @@ -0,0 +1,48 @@ +pragma solidity 0.8.17; + +interface IMedian { + function age() external view returns (uint32); // Last update timestamp + + function read() external view returns (uint256); // Latest price feed value (reverted if not valid) + + function peek() external view returns (uint256, bool); // Latest price feed value and validity + + // Other Median functions we don't need. + // function wards(address) external view returns (uint256); // Authorized owners + + // function rely(address) external; // Add authorized owner + + // function deny(address) external; // Remove authorized owner + + // function wat() external view returns (bytes32); // Price feed identifier + + // function bar() external view returns (uint256); // Minimum number of oracles + + // function orcl(address) external view returns (uint256); // Authorized oracles + + // function bud(address) external view returns (uint256); // Whitelisted contracts to read price feed + + // function slot(uint8) external view returns (address); // Mapping for at most 256 oracles + + // function poke( + // uint256[] calldata, + // uint256[] calldata, + // uint8[] calldata, + // bytes32[] calldata, + // bytes32[] calldata + // ) external; // Update price feed values + + // function lift(address[] calldata) external; // Add oracles + + // function drop(address[] calldata) external; // Remove oracles + + // function setBar(uint256) external; // Set minimum number of oracles + + function kiss(address) external; // Add contract to whitelist + + // function diss(address) external; // Remove contract from whitelist + + // function kiss(address[] calldata) external; // Add contracts to whitelist + + // function diss(address[] calldata) external; // Remove contracts from whitelist +} diff --git a/src/interfaces/makerdao/IOSM.sol b/src/interfaces/makerdao/IOSM.sol new file mode 100644 index 0000000..9e6ee35 --- /dev/null +++ b/src/interfaces/makerdao/IOSM.sol @@ -0,0 +1,36 @@ +pragma solidity 0.8.17; + +interface IOSM { + // function wards(address) external view returns (uint256); // Auth addresses + // function rely(address usr) external; // Add auth (auth) + // function deny(address usr) external; // Remove auth (auth) + + // function stopped() external view returns (uint256); // Determines if OSM can be poked. + + // function src() external view returns (address); // Address of source oracle. + + // function hop() external view returns (uint16); // Oracle delay in seconds. + function zzz() external view returns (uint64); // Time of last update (rounded down to nearest multiple of hop). + + // function bud(address _addr) external view returns (uint256); // Whitelisted contracts, set by an auth + + // function stop() external; // Stop Oracle updates (auth) + // function start() external; // Resume Oracle updates (auth) + + // function change(address src_) external; // Change source oracle (auth) + // function step(uint16 ts) external; // Change hop (auth) + + // function void() external; // Reset price feed to invalid and stop updates (auth) + + // function pass() external view returns (bool); // Check if oracle update period has passed. + // function poke() external; // Poke OSM for a new price (can be called by anyone) + + function peek() external view returns (bytes32, bool); // Return current price and if valid (whitelisted) + // function peep() external view returns (bytes32, bool); // Return the next price and if valid (whitelisted) + function read() external view returns (bytes32); // Return current price, only if valid (whitelisted) + + // function kiss(address a) external; // Add address to whitelist (auth) + // function diss(address a) external; // Remove address from whitelist (auth) + // function kiss(address[] calldata a) external; // Add addresses to whitelist (auth) + // function diss(address[] calldata a) external; // Remove addresses from whitelist (auth) +} diff --git a/src/interfaces/pyth/IPyth.sol b/src/interfaces/pyth/IPyth.sol new file mode 100644 index 0000000..462ae37 --- /dev/null +++ b/src/interfaces/pyth/IPyth.sol @@ -0,0 +1,13 @@ +pragma solidity ^0.8.17; + +interface IPyth { + struct Price { + int64 price; // Price + uint64 conf; // Confidence interval around the price + int32 expo; // Price exponent + uint256 publishTime; // Unix timestamp describing when the price was published + } + + function getPriceUnsafe(bytes32 id) external view returns (Price memory price); + function getPrice(bytes32 id) external view returns (Price memory price); +} diff --git a/test/fork/adapters/ChronicleMedianSourceAdapter.sol b/test/fork/adapters/ChronicleMedianSourceAdapter.sol new file mode 100644 index 0000000..52fc800 --- /dev/null +++ b/test/fork/adapters/ChronicleMedianSourceAdapter.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {CommonTest} from "../../Common.sol"; + +import {ChronicleMedianSourceAdapter} from "../../../src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol"; +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 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) {} +} + +contract ChronicleMedianSourceAdapterTest is CommonTest { + uint256 targetBlock = 18141580; + + uint256[] pokeBlocks = [18141853, 18142932, 18144016]; // Known blocks where Chronicle was poked. + + IMedian chronicle; + TestedSourceAdapter sourceAdapter; + + function setUp() public { + vm.createSelectFork("mainnet", targetBlock); + chronicle = IMedian(0x64DE91F5A373Cd4c28de3600cB34C7C6cE410C85); // Chronicle MedianETHUSD + sourceAdapter = new TestedSourceAdapter(chronicle); + + _whitelistOnChronicle(); + } + + function testCorrectlyReturnsLatestSourceData() public { + uint256 latestChronicleAnswer = chronicle.read(); + uint256 latestChronicleTimestamp = chronicle.age(); + (int256 latestSourceAnswer, uint256 latestSourceTimestamp) = sourceAdapter.getLatestSourceData(); + assertTrue(int256(latestChronicleAnswer) == latestSourceAnswer); + assertTrue(latestSourceTimestamp == latestChronicleTimestamp); + } + + function testReturnsLatestSourceDataNoSnapshot() public { + uint256 targetTime = block.timestamp; + + // Fork ~24 hours (7200 blocks on mainnet) forward with persistent source adapter. + vm.makePersistent(address(sourceAdapter)); + vm.createSelectFork("mainnet", targetBlock + 7200); + _whitelistOnChronicle(); // Re-whitelist on new fork. + + // Chronicle should have updated in the meantime. + uint256 latestChronicleAnswer = chronicle.read(); + uint256 latestChronicleTimestamp = chronicle.age(); + 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); + assertTrue(int256(latestChronicleAnswer) == lookBackPrice); + assertTrue(latestChronicleTimestamp == lookBackTimestamp); + } + + function testCorrectlyLooksBackThroughSnapshots() public { + (uint256[] memory snapshotAnswers, uint256[] memory snapshotTimestamps) = _snapshotOnPokeBlocks(); + + 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); + assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); + assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + + // 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); + assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); + assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + + // 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); + if (i > 0) { + assertTrue(int256(snapshotAnswers[i - 1]) == lookBackPrice); + assertTrue(snapshotTimestamps[i - 1] == lookBackTimestamp); + } else { + assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); + assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + } + } + } + + function testCorrectlyBoundsMaxLookBack() public { + _snapshotOnPokeBlocks(); + + // 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); + uint256 latestChronicleAnswer = chronicle.read(); + uint256 latestChronicleTimestamp = chronicle.age(); + assertTrue(int256(latestChronicleAnswer) == lookBackPrice); + assertTrue(latestChronicleTimestamp == lookBackTimestamp); + } + + function _whitelistOnChronicle() internal { + vm.startPrank(0xBE8E3e3618f7474F8cB1d074A26afFef007E98FB); // DSPause that is a ward (can add kiss to chronicle) + chronicle.kiss(address(sourceAdapter)); + chronicle.kiss(address(this)); // So that we can read Chronicle directly. + vm.stopPrank(); + } + + function _snapshotOnPokeBlocks() internal returns (uint256[] memory, uint256[] memory) { + uint256[] memory snapshotAnswers = new uint256[](pokeBlocks.length); + uint256[] memory snapshotTimestamps = new uint256[](pokeBlocks.length); + + // Fork forward with persistent source adapter and snapshot data at each poke block. + vm.makePersistent(address(sourceAdapter)); + for (uint256 i = 0; i < pokeBlocks.length; i++) { + vm.createSelectFork("mainnet", pokeBlocks[i]); + _whitelistOnChronicle(); // Re-whitelist on new fork. + snapshotAnswers[i] = chronicle.read(); + snapshotTimestamps[i] = chronicle.age(); + sourceAdapter.snapshotData(); + + // Check that source oracle was updated on each poke block. + if (i > 0) assertTrue(snapshotTimestamps[i] > snapshotTimestamps[i - 1]); + } + + return (snapshotAnswers, snapshotTimestamps); + } +} diff --git a/test/fork/adapters/OSMSourceAdapter.sol b/test/fork/adapters/OSMSourceAdapter.sol new file mode 100644 index 0000000..b34937c --- /dev/null +++ b/test/fork/adapters/OSMSourceAdapter.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {CommonTest} from "../../Common.sol"; + +import {OSMSourceAdapter} from "../../../src/adapters/source-adapters/OSMSourceAdapter.sol"; +import {IOSM} from "../../../src/interfaces/makerdao/IOSM.sol"; +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 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) {} +} + +contract OSMSourceAdapterTest is CommonTest { + uint256 targetBlock = 18141580; + + uint256[] pokeBlocks = [18141778, 18142073, 18142367]; // Known blocks where OSM was poked. + + IOSM osm; + TestedSourceAdapter sourceAdapter; + + function setUp() public { + vm.createSelectFork("mainnet", targetBlock); + osm = IOSM(0x81FE72B5A8d1A857d176C3E7d5Bd2679A9B85763); // MakerDAO ETH-A OSM. + sourceAdapter = new TestedSourceAdapter(osm); + + _whitelistOnOSM(); + } + + function testCorrectlyReturnsLatestSourceData() public { + bytes32 latestOSMAnswer = osm.read(); + uint64 latestOSMTimestamp = osm.zzz(); + (int256 latestSourceAnswer, uint256 latestSourceTimestamp) = sourceAdapter.getLatestSourceData(); + assertTrue(int256(uint256(latestOSMAnswer)) == latestSourceAnswer); + assertTrue(latestOSMTimestamp == latestSourceTimestamp); + } + + function testReturnsLatestSourceDataNoSnapshot() public { + uint256 targetTime = block.timestamp; + + // Fork ~24 hours (7200 blocks on mainnet) forward with persistent source adapter. + vm.makePersistent(address(sourceAdapter)); + vm.createSelectFork("mainnet", targetBlock + 7200); + _whitelistOnOSM(); // Re-whitelist on new fork. + + // OSM should have updated in the meantime. + bytes32 latestOSMAnswer = osm.read(); + uint64 latestOSMTimestamp = osm.zzz(); + 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); + assertTrue(int256(uint256(latestOSMAnswer)) == lookBackPrice); + assertTrue(latestOSMTimestamp == lookBackTimestamp); + } + + function testCorrectlyLooksBackThroughSnapshots() public { + (uint256[] memory snapshotAnswers, uint256[] memory snapshotTimestamps) = _snapshotOnPokeBlocks(); + + 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); + assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); + assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + + // Source updates were ~1 hour apart, so lookback 10 minutes later should return the same answer. + (lookBackPrice, lookBackTimestamp) = sourceAdapter.tryLatestDataAt(snapshotTimestamps[i] + 600, 10); + assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); + assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + + // 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); + if (i > 0) { + assertTrue(int256(snapshotAnswers[i - 1]) == lookBackPrice); + assertTrue(snapshotTimestamps[i - 1] == lookBackTimestamp); + } else { + assertTrue(int256(snapshotAnswers[i]) == lookBackPrice); + assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + } + } + } + + function testCorrectlyBoundsMaxLookBack() public { + _snapshotOnPokeBlocks(); + + // 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); + bytes32 latestOSMAnswer = osm.read(); + uint64 latestOSMTimestamp = osm.zzz(); + assertTrue(int256(uint256(latestOSMAnswer)) == lookBackPrice); + assertTrue(latestOSMTimestamp == lookBackTimestamp); + } + + function _whitelistOnOSM() internal { + vm.startPrank(0xBE8E3e3618f7474F8cB1d074A26afFef007E98FB); // DSPause that is a ward (can add kiss to OSM) + IMedian(address(osm)).kiss(address(sourceAdapter)); + IMedian(address(osm)).kiss(address(this)); // So that we can read OSM directly. + vm.stopPrank(); + } + + function _snapshotOnPokeBlocks() internal returns (uint256[] memory, uint256[] memory) { + uint256[] memory snapshotAnswers = new uint256[](pokeBlocks.length); + uint256[] memory snapshotTimestamps = new uint256[](pokeBlocks.length); + + // Fork forward with persistent source adapter and snapshot data at each poke block. + vm.makePersistent(address(sourceAdapter)); + for (uint256 i = 0; i < pokeBlocks.length; i++) { + vm.createSelectFork("mainnet", pokeBlocks[i]); + _whitelistOnOSM(); // Re-whitelist on new fork. + snapshotAnswers[i] = uint256(osm.read()); + snapshotTimestamps[i] = osm.zzz(); + sourceAdapter.snapshotData(); + + // Check that source oracle was updated on each poke block. + if (i > 0) assertTrue(snapshotTimestamps[i] > snapshotTimestamps[i - 1]); + } + + return (snapshotAnswers, snapshotTimestamps); + } +} diff --git a/test/fork/adapters/PythSourceAdapter.sol b/test/fork/adapters/PythSourceAdapter.sol new file mode 100644 index 0000000..23b660c --- /dev/null +++ b/test/fork/adapters/PythSourceAdapter.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {CommonTest} from "../../Common.sol"; + +import {PythSourceAdapter} from "../../../src/adapters/source-adapters/PythSourceAdapter.sol"; +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 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) {} +} + +contract PythSourceAdapterTest is CommonTest { + uint256 targetBlock = 16125730; // Pyth ETH/USD received updates both before and after this block. + + uint256[] updateBlocks = [16125712, 16125717, 16125741]; // Known blocks where Pyth ETH/USD was updated. + + IPyth pyth; + TestedSourceAdapter sourceAdapter; + bytes32 priceId = 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace; // ETH/USD + + function setUp() public { + vm.createSelectFork("mainnet", targetBlock); + pyth = IPyth(0x4305FB66699C3B2702D4d05CF36551390A4c69C6); + sourceAdapter = new TestedSourceAdapter(pyth, priceId); + } + + function testCorrectlyStandardizesOutputs() public { + IPyth.Price memory pythPrice = pyth.getPriceUnsafe(priceId); + (int256 latestSourceAnswer, uint256 latestSourceTimestamp) = sourceAdapter.getLatestSourceData(); + assertTrue(_scalePythTo18(pythPrice) == latestSourceAnswer); + assertTrue(pythPrice.publishTime == latestSourceTimestamp); + } + + function testReturnsLatestSourceDataNoSnapshot() public { + uint256 targetTime = block.timestamp; // This should be bit before the latest known source update. + + // Fork 1 block past last known source update with persistent source adapter. + vm.makePersistent(address(sourceAdapter)); + vm.createSelectFork("mainnet", updateBlocks[updateBlocks.length - 1] + 1); + + // Pyth should have updated in the meantime. + IPyth.Price memory latestPythPrice = pyth.getPriceUnsafe(priceId); + 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); + assertTrue(_scalePythTo18(latestPythPrice) == lookBackPrice); + assertTrue(latestPythPrice.publishTime == lookBackTimestamp); + } + + function testCorrectlyLooksBackThroughSnapshots() public { + (int256[] memory snapshotAnswers, uint256[] memory snapshotTimestamps) = _snapshotOnUpdateBlocks(); + + 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); + assertTrue(snapshotAnswers[i] == lookBackPrice); + assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + + // 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); + assertTrue(snapshotAnswers[i] == lookBackPrice); + assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + + // 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); + if (i > 0) { + assertTrue(snapshotAnswers[i - 1] == lookBackPrice); + assertTrue(snapshotTimestamps[i - 1] == lookBackTimestamp); + } else { + assertTrue(snapshotAnswers[i] == lookBackPrice); + assertTrue(snapshotTimestamps[i] == lookBackTimestamp); + } + } + } + + function testCorrectlyBoundsMaxLookBack() public { + _snapshotOnUpdateBlocks(); + + // 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); + IPyth.Price memory latestPythPrice = pyth.getPriceUnsafe(priceId); + assertTrue(_scalePythTo18(latestPythPrice) == lookBackPrice); + assertTrue(latestPythPrice.publishTime == lookBackTimestamp); + } + + function testPositiveExpo() public { + _snapshotOnUpdateBlocks(); + + IPyth.Price memory latestPythPrice = pyth.getPriceUnsafe(priceId); + latestPythPrice.expo = 1; + latestPythPrice.price = 1; + + vm.mockCall( + 0x4305FB66699C3B2702D4d05CF36551390A4c69C6, + abi.encodeWithSelector(IPyth.getPriceUnsafe.selector, priceId), + abi.encode(latestPythPrice) + ); + + (int256 latestSourceAnswer, uint256 latestSourceTimestamp) = sourceAdapter.getLatestSourceData(); + + assertTrue(latestSourceAnswer == 10 ** 19); + assertTrue(latestPythPrice.publishTime == latestSourceTimestamp); + } + + function _scalePythTo18(IPyth.Price memory pythPrice) internal returns (int256) { + assertTrue(pythPrice.expo <= 0); + if (pythPrice.expo >= -18) { + return pythPrice.price * int256(10 ** uint32(18 + pythPrice.expo)); + } else { + return pythPrice.price / int256(10 ** uint32(-pythPrice.expo - 18)); + } + } + + function _snapshotOnUpdateBlocks() internal returns (int256[] memory, uint256[] memory) { + int256[] memory snapshotAnswers = new int256[](updateBlocks.length); + uint256[] memory snapshotTimestamps = new uint256[](updateBlocks.length); + + // Fork forward with persistent source adapter and snapshot data at each update block. + vm.makePersistent(address(sourceAdapter)); + for (uint256 i = 0; i < updateBlocks.length; i++) { + vm.createSelectFork("mainnet", updateBlocks[i]); + IPyth.Price memory pythPrice = pyth.getPriceUnsafe(priceId); + snapshotAnswers[i] = _scalePythTo18(pythPrice); + snapshotTimestamps[i] = pythPrice.publishTime; + sourceAdapter.snapshotData(); + + // Check that source oracle was updated on each update block. + if (i > 0) { + assertTrue(snapshotTimestamps[i] > snapshotTimestamps[i - 1]); + } + } + + return (snapshotAnswers, snapshotTimestamps); + } +} diff --git a/test/fork/adapters/UnionSourceAdapter.sol b/test/fork/adapters/UnionSourceAdapter.sol new file mode 100644 index 0000000..0d75b7b --- /dev/null +++ b/test/fork/adapters/UnionSourceAdapter.sol @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {CommonTest} from "../../Common.sol"; +import {UnionSourceAdapter} from "../../../src/adapters/source-adapters/UnionSourceAdapter.sol"; +import {IAggregatorV3Source} from "../../../src/interfaces/chainlink/IAggregatorV3Source.sol"; +import {IMedian} from "../../../src/interfaces/chronicle/IMedian.sol"; +import {IPyth} from "../../../src/interfaces/pyth/IPyth.sol"; +import {DecimalLib} from "../../../src/adapters/lib/DecimalLib.sol"; + +contract TestedSourceAdapter is UnionSourceAdapter { + constructor(IAggregatorV3Source chainlink, IMedian chronicle, IPyth pyth, bytes32 pythPriceId) + UnionSourceAdapter(chainlink, chronicle, pyth, pythPriceId) + {} + + function internalLatestData() 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) {} +} + +contract UnionSourceAdapterTest is CommonTest { + struct OracleData { + int256 answer; + uint256 timestamp; + } + + struct SourceData { + OracleData chainlink; + OracleData chronicle; + OracleData pyth; + OracleData union; + } + + uint256 targetChainlinkBlock = 18141580; // Known block where Chainlink was the newest. + uint256 targetChronicleBlock = 18153212; // Known block where Chronicle was the newest. + uint256 targetPythBlock = 16125718; // Known block where Pyth was the newest. + + uint256 lastPythUpdateBlock = 16125741; // Known block where Pyth was last updated. + + IAggregatorV3Source chainlink = IAggregatorV3Source(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419); + IMedian chronicle = IMedian(0x64DE91F5A373Cd4c28de3600cB34C7C6cE410C85); + IPyth pyth = IPyth(0x4305FB66699C3B2702D4d05CF36551390A4c69C6); + bytes32 pythPriceId = 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace; + + TestedSourceAdapter sourceAdapter; + + function setUp() public { + vm.createSelectFork("mainnet", targetChainlinkBlock); + sourceAdapter = new TestedSourceAdapter(chainlink, chronicle, pyth, pythPriceId); + vm.makePersistent(address(sourceAdapter)); + } + + function testGetLatestSourceDataChainlink() public { + vm.createSelectFork("mainnet", targetChainlinkBlock + 1); + _whitelistOnChronicle(); + + // Latest answer should be the newest of the three sources. At this known block number chainlink was the newest. + // We can verify this to be the case and ensure that the Union adapter returned this value. + + SourceData memory latest = _getLatestData(); + + assertTrue(latest.union.answer == latest.chainlink.answer); + assertTrue(latest.union.timestamp == latest.chainlink.timestamp); + assertTrue(latest.union.answer != latest.chronicle.answer); + assertTrue(latest.union.answer != latest.pyth.answer); + assertTrue(latest.chainlink.timestamp > latest.chronicle.timestamp); // Verify chainlink was indeed the newest. + assertTrue(latest.chainlink.timestamp > latest.pyth.timestamp); + } + + function testGetLatestSourceDataChronicle() public { + vm.createSelectFork("mainnet", targetChronicleBlock); + _whitelistOnChronicle(); + + // Latest answer should be the newest of the three sources. At this known block number chronicle was the newest. + // We can verify this to be the case and ensure that the Union adapter returned this value. + + SourceData memory latest = _getLatestData(); + + assertTrue(latest.union.answer == latest.chronicle.answer); + assertTrue(latest.union.timestamp == latest.chronicle.timestamp); + assertTrue(latest.union.answer != latest.chainlink.answer); + assertTrue(latest.union.answer != latest.pyth.answer); + assertTrue(latest.chronicle.timestamp > latest.chainlink.timestamp); // Verify chronicle was indeed the newest. + assertTrue(latest.chronicle.timestamp > latest.pyth.timestamp); + } + + function testGetLatestSourceDataPyth() public { + vm.createSelectFork("mainnet", targetPythBlock); + _whitelistOnChronicle(); + + // Latest answer should be the newest of the three sources. At this known block number pyth was the newest. + // We can verify this to be the case and ensure that the Union adapter returned this value. + + SourceData memory latest = _getLatestData(); + + assertTrue(latest.union.answer == latest.pyth.answer); + assertTrue(latest.union.timestamp == latest.pyth.timestamp); + assertTrue(latest.union.answer != latest.chainlink.answer); + assertTrue(latest.union.answer != latest.chronicle.answer); + assertTrue(latest.pyth.timestamp > latest.chainlink.timestamp); // Verify pyth was indeed the newest. + assertTrue(latest.pyth.timestamp > latest.chronicle.timestamp); + } + + function testLookbackChainlink() public { + vm.createSelectFork("mainnet", targetChainlinkBlock + 1); + uint256 targetTimestamp = block.timestamp; + _whitelistOnChronicle(); + + // Snapshotting union adapter should not affect historical lookups, but we do it just to prove it does not interfere. + sourceAdapter.snapshotData(); + + // Grab the latest data as of target block and check that chainlink was the newest. + SourceData memory historic = _getLatestData(); + assertTrue(historic.chainlink.timestamp > historic.chronicle.timestamp); + assertTrue(historic.chainlink.timestamp > historic.pyth.timestamp); + + // Move ~1 minute forward for the lookback test. + vm.createSelectFork("mainnet", targetChainlinkBlock + 5); + _whitelistOnChronicle(); + + // We don't expect any of sources updated in the last minute. + SourceData memory latest = _getLatestData(); + assertTrue(latest.chainlink.timestamp == historic.chainlink.timestamp); + assertTrue(latest.chronicle.timestamp == historic.chronicle.timestamp); + assertTrue(latest.pyth.timestamp == historic.pyth.timestamp); + + // As no sources had updated we still expect historic union to match chainlink. + (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp) = + sourceAdapter.tryLatestDataAt(targetTimestamp, 10); + assertTrue(lookbackUnionAnswer == historic.chainlink.answer); + assertTrue(lookbackUnionTimestamp == historic.chainlink.timestamp); + } + + function testLookbackChronicle() public { + // Fork to a block where chronicle was the newest. + vm.createSelectFork("mainnet", targetChronicleBlock); + uint256 targetTimestamp = block.timestamp; + _whitelistOnChronicle(); + + // Snapshotting union adapter should not affect historical lookups, but we do it just to prove it does not interfere. + sourceAdapter.snapshotData(); + + // Grab the latest data as of target block and check that chronicle was the newest. + SourceData memory historic = _getLatestData(); + assertTrue(historic.chronicle.timestamp > historic.chainlink.timestamp); + assertTrue(historic.chronicle.timestamp > historic.pyth.timestamp); + + // Move ~1 minute forward for the lookback test. + vm.createSelectFork("mainnet", targetChronicleBlock + 5); + _whitelistOnChronicle(); + + // We don't expect any of sources updated in the last minute. + SourceData memory latest = _getLatestData(); + assertTrue(latest.chainlink.timestamp == historic.chainlink.timestamp); + assertTrue(latest.chronicle.timestamp == historic.chronicle.timestamp); + assertTrue(latest.pyth.timestamp == historic.pyth.timestamp); + + // As no sources had updated we still expect historic union to match chronicle. + (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp) = + sourceAdapter.tryLatestDataAt(targetTimestamp, 10); + assertTrue(lookbackUnionAnswer == historic.chronicle.answer); + assertTrue(lookbackUnionTimestamp == historic.chronicle.timestamp); + } + + function testLookbackDropChronicle() public { + // Fork to a block where chronicle was the newest. + vm.createSelectFork("mainnet", targetChronicleBlock); + uint256 targetTimestamp = block.timestamp; + _whitelistOnChronicle(); + + // Snapshotting union adapter should not affect historical lookups, but we do it just to prove it does not interfere. + sourceAdapter.snapshotData(); + + // Grab the latest data as of target block and check that chronicle was the newest. + SourceData memory historic = _getLatestData(); + assertTrue(historic.chronicle.timestamp > historic.chainlink.timestamp); + assertTrue(historic.chronicle.timestamp > historic.pyth.timestamp); + + // Move ~24 hours forward for the lookback test. + vm.createSelectFork("mainnet", targetChronicleBlock + 7200); + _whitelistOnChronicle(); + + // Chronicle should have updated in the meantime. + SourceData memory latest = _getLatestData(); + assertTrue(latest.chronicle.timestamp > historic.chronicle.timestamp); + + // 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) = + sourceAdapter.tryLatestDataAt(targetTimestamp, 100); + assertTrue(lookbackUnionAnswer == historic.chainlink.answer); + assertTrue(lookbackUnionTimestamp == historic.chainlink.timestamp); + } + + function testLookbackPyth() public { + // Fork to a block where pyth was the newest. + vm.createSelectFork("mainnet", targetPythBlock); + uint256 targetTimestamp = block.timestamp; + _whitelistOnChronicle(); + + // Snapshotting union adapter should not affect historical lookups, but we do it just to prove it does not interfere. + sourceAdapter.snapshotData(); + + // Grab the latest data as of target block and check that pyth was the newest. + SourceData memory historic = _getLatestData(); + assertTrue(historic.pyth.timestamp > historic.chainlink.timestamp); + assertTrue(historic.pyth.timestamp > historic.chronicle.timestamp); + + // Move ~1 minute forward for the lookback test. + vm.createSelectFork("mainnet", targetPythBlock + 5); + _whitelistOnChronicle(); + + // We don't expect any of sources updated in the last minute. + SourceData memory latest = _getLatestData(); + assertTrue(latest.chainlink.timestamp == historic.chainlink.timestamp); + assertTrue(latest.chronicle.timestamp == historic.chronicle.timestamp); + assertTrue(latest.pyth.timestamp == historic.pyth.timestamp); + + // As no sources had updated we still expect historic union to match pyth. + (int256 lookbackUnionAnswer, uint256 lookbackUnionTimestamp) = + sourceAdapter.tryLatestDataAt(targetTimestamp, 10); + assertTrue(lookbackUnionAnswer == historic.pyth.answer); + assertTrue(lookbackUnionTimestamp == historic.pyth.timestamp); + } + + function testLookbackDropPyth() public { + // Fork to a block where pyth was the newest. + vm.createSelectFork("mainnet", targetPythBlock); + uint256 targetTimestamp = block.timestamp; + _whitelistOnChronicle(); + + // Snapshotting union adapter should not affect historical lookups, but we do it just to prove it does not interfere. + sourceAdapter.snapshotData(); + + // Grab the latest data as of target block and check that pyth was the newest. + SourceData memory historic = _getLatestData(); + assertTrue(historic.pyth.timestamp > historic.chainlink.timestamp); + assertTrue(historic.pyth.timestamp > historic.chronicle.timestamp); + + // Move forward after the last known pyth update for the lookback test. + vm.createSelectFork("mainnet", lastPythUpdateBlock); + _whitelistOnChronicle(); + + // Pyth should have updated. + SourceData memory latest = _getLatestData(); + assertTrue(latest.pyth.timestamp > historic.pyth.timestamp); + + // 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) = + sourceAdapter.tryLatestDataAt(targetTimestamp, 100); + assertTrue(lookbackUnionAnswer == historic.chainlink.answer); + assertTrue(lookbackUnionTimestamp == historic.chainlink.timestamp); + } + + function _convertDecimalsWithExponent(int256 answer, int32 expo) internal pure returns (int256) { + if (expo <= 0) { + return DecimalLib.convertDecimals(answer, uint8(uint32(-expo)), 18); + } else { + return DecimalLib.convertDecimals(answer, 0, 18 + uint8(uint32(expo))); + } + } + + function _whitelistOnChronicle() internal { + vm.startPrank(0xBE8E3e3618f7474F8cB1d074A26afFef007E98FB); // DSPause that is a ward (can add kiss to chronicle) + chronicle.kiss(address(sourceAdapter)); + chronicle.kiss(address(this)); // So that we can read Chronicle directly. + vm.stopPrank(); + } + + function _getLatestData() internal view returns (SourceData memory) { + SourceData memory latest; + (, int256 latestAnswer,, uint256 latestTimestamp,) = chainlink.latestRoundData(); + latest.chainlink.answer = DecimalLib.convertDecimals(latestAnswer, 8, 18); + latest.chainlink.timestamp = latestTimestamp; + + latest.chronicle.answer = int256(chronicle.read()); + latest.chronicle.timestamp = chronicle.age(); + + IPyth.Price memory pythPrice = pyth.getPriceUnsafe(pythPriceId); + latest.pyth.answer = _convertDecimalsWithExponent(pythPrice.price, pythPrice.expo); + latest.pyth.timestamp = pythPrice.publishTime; + + (latest.union.answer, latest.union.timestamp) = sourceAdapter.getLatestSourceData(); + + return latest; + } +} diff --git a/test/unit/Oval.ChronicleMedianDestinationAdapter.sol b/test/unit/Oval.ChronicleMedianDestinationAdapter.sol new file mode 100644 index 0000000..50b118c --- /dev/null +++ b/test/unit/Oval.ChronicleMedianDestinationAdapter.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {Oval} from "../../src/Oval.sol"; +import {BaseDestinationAdapter} from "../../src/adapters/destination-adapters/BaseDestinationAdapter.sol"; +import {ChronicleMedianDestinationAdapter} from + "../../src/adapters/destination-adapters/ChronicleMedianDestinationAdapter.sol"; +import {BaseController} from "../../src/controllers/BaseController.sol"; +import {CommonTest} from "../Common.sol"; +import {MockSourceAdapter} from "../mocks/MockSourceAdapter.sol"; + +contract TestOval is BaseController, MockSourceAdapter, ChronicleMedianDestinationAdapter { + constructor(uint8 decimals, address _sourceAdapter) + BaseController() + MockSourceAdapter(decimals) + ChronicleMedianDestinationAdapter(_sourceAdapter) + {} + + function kiss(address) external override {} +} + +contract OvalChronicleMedianDestinationAdapter is CommonTest { + int256 initialPrice = 1895 * 1e18; + uint256 initialTimestamp = 1690000000; + + int256 newAnswer = 1900 * 1e18; + uint256 newTimestamp = initialTimestamp + 1; + + int256 internalDecimalsToSourceDecimals = 1; + + TestOval oval; + + function setUp() public { + vm.warp(initialTimestamp); + + vm.startPrank(owner); + oval = new TestOval(18, address(0)); + oval.setUnlocker(permissionedUnlocker, true); + vm.stopPrank(); + + oval.publishRoundData(initialPrice, initialTimestamp); + } + + function verifyOvalOracleMatchesOvalOracle() public { + (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + + (, bool sourceValid) = oval.peek(); + assertTrue(sourceValid); + assertTrue(uint256(latestAnswer) == oval.read() && latestTimestamp == oval.age()); + } + + function syncOvalOracleWithOvalOracle() public { + assertTrue(oval.canUnlock(permissionedUnlocker, oval.age())); + vm.prank(permissionedUnlocker); + oval.unlockLatestValue(); + verifyOvalOracleMatchesOvalOracle(); + } + + function testUpdatesWithinLockWindow() public { + // Publish an update to the mock source adapter. + oval.publishRoundData(newAnswer, newTimestamp); + + syncOvalOracleWithOvalOracle(); + assertTrue(oval.lastUnlockTime() == block.timestamp); + + // Apply an update with no diff in source adapter. + uint256 updateTimestamp = block.timestamp + 1 minutes; + vm.warp(updateTimestamp); + vm.prank(permissionedUnlocker); + oval.unlockLatestValue(); + + // Check that the update timestamp was updated and that the answer and timestamp are unchanged. + assertTrue(oval.lastUnlockTime() == updateTimestamp); + verifyOvalOracleMatchesOvalOracle(); + } + + function testInvalidPrice() public { + // Publish invalid price to the mock source adapter. + vm.warp(newTimestamp); + oval.publishRoundData(0, newTimestamp); + + // Unlock the invalid value. + vm.prank(permissionedUnlocker); + oval.unlockLatestValue(); + + // Verify that the latest data is invalid. + (, bool sourceValid) = oval.peek(); + assertFalse(sourceValid); + + // Verify that the read reverts. + vm.expectRevert(); + oval.read(); + } +} diff --git a/test/unit/Oval.OSMDestinationAdapter.sol b/test/unit/Oval.OSMDestinationAdapter.sol new file mode 100644 index 0000000..cd5e2aa --- /dev/null +++ b/test/unit/Oval.OSMDestinationAdapter.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {Oval} from "../../src/Oval.sol"; +import {BaseDestinationAdapter} from "../../src/adapters/destination-adapters/BaseDestinationAdapter.sol"; +import {OSMDestinationAdapter} from "../../src/adapters/destination-adapters/OSMDestinationAdapter.sol"; +import {BaseController} from "../../src/controllers/BaseController.sol"; +import {CommonTest} from "../Common.sol"; +import {MockSourceAdapter} from "../mocks/MockSourceAdapter.sol"; + +contract TestOval is BaseController, MockSourceAdapter, OSMDestinationAdapter { + constructor(uint8 decimals) BaseController() MockSourceAdapter(decimals) OSMDestinationAdapter() {} +} + +contract OvalChronicleMedianDestinationAdapter is CommonTest { + int256 initialPrice = 1895 * 1e18; + uint256 initialTimestamp = 1690000000; + + int256 newAnswer = 1900 * 1e18; + uint256 newTimestamp = initialTimestamp + 1; + + TestOval oval; + + function setUp() public { + vm.warp(initialTimestamp); + + vm.startPrank(owner); + oval = new TestOval(18); + oval.setUnlocker(permissionedUnlocker, true); + vm.stopPrank(); + + oval.publishRoundData(initialPrice, initialTimestamp); + } + + function verifyOvalOracleMatchesOvalOracle() public { + (int256 latestAnswer, uint256 latestTimestamp) = oval.internalLatestData(); + + (, bool sourceValid) = oval.peek(); + assertTrue(sourceValid); + assertTrue(bytes32(uint256(latestAnswer)) == oval.read() && uint64(latestTimestamp) == oval.zzz()); + } + + function syncOvalOracleWithOvalOracle() public { + assertTrue(oval.canUnlock(permissionedUnlocker, oval.zzz())); + vm.prank(permissionedUnlocker); + oval.unlockLatestValue(); + verifyOvalOracleMatchesOvalOracle(); + } + + function testUpdatesWithinLockWindow() public { + // Publish an update to the mock source adapter. + oval.publishRoundData(newAnswer, newTimestamp); + + syncOvalOracleWithOvalOracle(); + assertTrue(oval.lastUnlockTime() == block.timestamp); + + // Apply an update with no diff in source adapter. + uint256 updateTimestamp = block.timestamp + 1 minutes; + vm.warp(updateTimestamp); + vm.prank(permissionedUnlocker); + oval.unlockLatestValue(); + + // Check that the update timestamp was updated and that the answer and timestamp are unchanged. + assertTrue(oval.lastUnlockTime() == updateTimestamp); + verifyOvalOracleMatchesOvalOracle(); + } +} diff --git a/test/unit/Oval.PythDestinationAdapter.sol b/test/unit/Oval.PythDestinationAdapter.sol new file mode 100644 index 0000000..a398be7 --- /dev/null +++ b/test/unit/Oval.PythDestinationAdapter.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {Oval} from "../../src/Oval.sol"; +import {BaseDestinationAdapter} from "../../src/adapters/destination-adapters/BaseDestinationAdapter.sol"; +import {PythDestinationAdapter} from "../../src/adapters/destination-adapters/PythDestinationAdapter.sol"; +import {IPyth} from "../../src/interfaces/pyth/IPyth.sol"; +import {IOval} from "../../src/interfaces/IOval.sol"; +import {BaseController} from "../../src/controllers/BaseController.sol"; +import {CommonTest} from "../Common.sol"; +import {MockSourceAdapter} from "../mocks/MockSourceAdapter.sol"; + +contract TestOval is BaseController, MockSourceAdapter, PythDestinationAdapter { + constructor(uint8 decimals, IPyth _basePythProvider) + BaseController() + MockSourceAdapter(decimals) + PythDestinationAdapter(_basePythProvider) + {} +} + +contract OvalChronicleMedianDestinationAdapter is CommonTest { + int256 initialPrice = 1895 * 1e18; + uint256 initialTimestamp = 1690000000; + + int256 newAnswer = 1900 * 1e18; + uint256 newTimestamp = initialTimestamp + 1; + + bytes32 testId = keccak256("testId"); + uint8 testDecimals = 8; + uint256 testValidTimePeriod = 3600; + + address OvalAddress = makeAddr("OvalAddress"); + address basePythProviderAddress = makeAddr("basePythProviderAddress"); + + PythDestinationAdapter destinationAdapter; + + function setUp() public { + vm.clearMockedCalls(); + destinationAdapter = new PythDestinationAdapter(IPyth(basePythProviderAddress)); + } + + function testSetOval() public { + destinationAdapter.setOval(testId, testDecimals, testValidTimePeriod, IOval(OvalAddress)); + assertEq(address(destinationAdapter.idToOval(testId)), address(OvalAddress)); + assertEq(destinationAdapter.idToDecimal(testId), testDecimals); + assertEq(destinationAdapter.idToValidTimePeriod(testId), testValidTimePeriod); + } + + function testGetPriceUnsafe() public { + destinationAdapter.setOval(testId, testDecimals, testValidTimePeriod, IOval(OvalAddress)); + vm.mockCall( + OvalAddress, abi.encodeWithSelector(IOval.internalLatestData.selector), abi.encode(newAnswer, newTimestamp) + ); + + IPyth.Price memory price = destinationAdapter.getPriceUnsafe(testId); + + assertEq(price.price, newAnswer / 10 ** 10); + assertEq(price.expo, -int32(uint32(testDecimals))); + assertEq(price.publishTime, newTimestamp); + } + + function testGetPrice() public { + destinationAdapter.setOval(testId, testDecimals, testValidTimePeriod, IOval(OvalAddress)); + uint256 timestamp = block.timestamp; + vm.mockCall( + OvalAddress, abi.encodeWithSelector(IOval.internalLatestData.selector), abi.encode(newAnswer, timestamp) + ); + + IPyth.Price memory price = destinationAdapter.getPrice(testId); + + assertEq(price.price, newAnswer / 10 ** 10); + assertEq(price.expo, -int32(uint32(testDecimals))); + assertEq(price.publishTime, timestamp); + } + + function testNotWithinValidWindow() public { + destinationAdapter.setOval(testId, testDecimals, testValidTimePeriod, IOval(OvalAddress)); + vm.warp(newTimestamp + testValidTimePeriod + 1); // Warp to after the valid time period. + + vm.mockCall( + OvalAddress, abi.encodeWithSelector(IOval.internalLatestData.selector), abi.encode(newAnswer, newTimestamp) + ); + + vm.expectRevert("Not within valid window"); + destinationAdapter.getPrice(testId); + } + + function testFutureTimestamp() public { + destinationAdapter.setOval(testId, testDecimals, testValidTimePeriod, IOval(OvalAddress)); + vm.warp(newTimestamp - 1); // Warp to before publish time. + + vm.mockCall( + OvalAddress, abi.encodeWithSelector(IOval.internalLatestData.selector), abi.encode(newAnswer, newTimestamp) + ); + + IPyth.Price memory price = destinationAdapter.getPrice(testId); + + assertEq(price.price, newAnswer / 10 ** 10); + assertEq(price.expo, -int32(uint32(testDecimals))); + assertEq(price.publishTime, newTimestamp); + } + + function testUnsupportedIdentifier() public { + // We don't set an Oval for testId, so it should return the price from the source. + assert(address(destinationAdapter.idToOval(testId)) == address(0)); + + vm.mockCall( + basePythProviderAddress, + abi.encodeWithSelector(IPyth.getPriceUnsafe.selector, testId), + abi.encode(IPyth.Price({price: 1, conf: 0, expo: -int32(uint32(testDecimals)), publishTime: 1000})) + ); + + IPyth.Price memory price = destinationAdapter.getPriceUnsafe(testId); + + assertEq(price.price, 1); + assertEq(price.expo, -int32(uint32(testDecimals))); + assertEq(price.publishTime, 1000); + } +} diff --git a/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol new file mode 100644 index 0000000..b2446fb --- /dev/null +++ b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: AGPL-3.0-only-only +pragma solidity 0.8.17; + +import {IAggregatorV3Source} from "../../../src/interfaces/chainlink/IAggregatorV3Source.sol"; +import {IMedian} from "../../../src/interfaces/chronicle/IMedian.sol"; +import {IPyth} from "../../../src/interfaces/pyth/IPyth.sol"; + +import {BoundedUnionSourceAdapter} from "../../../src/adapters/source-adapters/BoundedUnionSourceAdapter.sol"; +import {CommonTest} from "../../Common.sol"; + +contract TestBoundedUnionSource is BoundedUnionSourceAdapter { + constructor(address chainlink) + BoundedUnionSourceAdapter( + IAggregatorV3Source(chainlink), + IMedian(address(0)), + IPyth(address(0)), + bytes32(0), + 0.1e18 // boundingTolerance + ) + {} + + function selectBoundedPrice(int256 cl, uint256 clT, int256 cr, uint256 crT, int256 py, uint256 pyT) + public + view + returns (int256, uint256) + { + return _selectBoundedPrice(cl, clT, cr, crT, py, pyT); + } + + function withinTolerance(int256 a, int256 b) public view returns (bool) { + return _withinTolerance(a, b); + } + + function internalLatestData() 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) {} +} + +contract MinimalChainlinkAdapter { + function decimals() public view returns (uint8) { + return 8; + } +} + +contract BoundedUnionSourceTest is CommonTest { + TestBoundedUnionSource source; + + function setUp() public { + source = new TestBoundedUnionSource(address(new MinimalChainlinkAdapter())); + } + + // Fuzz test for _selectBoundedPrice function + function ztestFuzzSelectBoundedPrice(int128 cl, uint128 clT, int128 cr, uint128 crT, int128 py, uint128 pyT) + public + { + // Call the function with fuzzed inputs. + (int256 selected, uint256 selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + + // Check if the selected price is the newest price. + bool isMostRecent = (selected == py && selectedT == pyT && pyT >= crT && pyT >= clT) + || (selected == cr && selectedT == crT && crT >= pyT && crT >= clT) + || (selected == cl && selectedT == clT && clT >= pyT && clT >= crT); + + // Check if the selected price is the second most recent price. This means it must be newer than one, but older + // than another. + bool isSecondMostRecent = ( + selected == py && selectedT == pyT && (pyT <= crT && pyT >= clT || pyT <= clT && pyT >= crT) + ) || (selected == cr && selectedT == crT && (crT <= pyT && crT >= clT || crT <= clT && crT >= pyT)) + || (selected == cl && selectedT == clT && (clT <= pyT && clT >= crT || clT <= crT && clT >= pyT)); + + // Check if prices are within tolerance of each other. i.e compare the selected price to the other two. + bool isWithinTolerance = (selected == py && (source.withinTolerance(py, cr) || source.withinTolerance(py, cl))) + || (selected == cr && (source.withinTolerance(cr, py) || source.withinTolerance(cr, cl))) + || (selected == cl && (source.withinTolerance(cl, py) || source.withinTolerance(cl, cr))); + + // Check if the selected price and time follow the logic of the function given the definition of: + // a) Return the most recent price if it's within tolerance of at least one of the other two. + // b) If not, return the second most recent price if it's within tolerance of at least one of the other two. + // c) If neither a) nor b) is met, return chainlink. Here we ensure this by checking that the sources diverged. + bool isValidPriceTimePair = (isMostRecent && isWithinTolerance) + || (!isMostRecent && isSecondMostRecent && isWithinTolerance) + || (selected == cl && selectedT == clT && !source.withinTolerance(cr, py)); + + assertTrue(isValidPriceTimePair, "Invalid price-time pair selected"); + } + + function testHappyPathNoDivergence() public { + int256 cl = 100; + uint256 clT = 100001; + int256 cr = 100; + uint256 crT = 100002; + int256 py = 100; + uint256 pyT = 100003; + + (int256 selected, uint256 selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + assertTrue(selected == py && selectedT == pyT); + + // now, make the newest be cr. we should now get cr. + crT = 100003; + pyT = 100002; + (selected, selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + assertTrue(selected == cr && selectedT == crT); + + // now, make the newest be cl. we should now get cl. + clT = 100003; + crT = 100001; + (selected, selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + assertTrue(selected == cl && selectedT == clT); + } + + function testUnhappyPathOldestDiverged() public { + int256 cl = 0; // consider chainlink is broken and the oldest. + uint256 clT = 100001; + int256 cr = 100; + uint256 crT = 100002; + int256 py = 100; + uint256 pyT = 100003; + + (int256 selected, uint256 selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + assertTrue(selected == py && selectedT == pyT); + + // now, make the oldest be cr which has diverged. we should still get py. + cl = 100; + cr = 0; + clT = 100002; // cl is now the second oldest. + crT = 100001; // cr is now the oldest. + (selected, selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + assertTrue(selected == py && selectedT == pyT); + } + + function testUnhappyPathSecondOldestDiverged() public { + int256 cl = 100; + uint256 clT = 100001; + int256 cr = 0; // consider chronicle is broken and the second oldest. + uint256 crT = 100002; + int256 py = 100; + uint256 pyT = 100003; + + (int256 selected, uint256 selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + assertTrue(selected == py && selectedT == pyT); + } + + function testUnhappyPathNewestDiverged() public { + int256 cl = 100; + uint256 clT = 100001; + int256 cr = 100; + uint256 crT = 100002; + int256 py = 0; // Pyth is both the newest and diverged. + uint256 pyT = 100003; + + // In this case we should return cr. + (int256 selected, uint256 selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + assertTrue(selected == cr && selectedT == crT); + + // Now, make cl the second newest and cr the oldest. keep py diverged. Should see cl now returned. + clT = 100002; + crT = 100001; + (selected, selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + assertTrue(selected == cl && selectedT == clT); + } + + function testUnhappyPathAllDiverged() public { + // all values diverged. should get back chainlink. + int256 cl = 0; + uint256 clT = 100001; + int256 cr = 50; + uint256 crT = 100002; + int256 py = 100; + uint256 pyT = 100003; + + (int256 selected, uint256 selectedT) = source.selectBoundedPrice(cl, clT, cr, crT, py, pyT); + assertTrue(selected == cl && selectedT == clT); + } +}