From 05b2ae5982c916bebe99d8073e4d69356d1293c1 Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Thu, 16 May 2024 09:51:05 +0000 Subject: [PATCH 1/8] test: bounded union source adapter not flipping backward Signed-off-by: Reinis Martinsons --- .../adapters/BoundedUnionSourceAdapter.sol | 86 +++++++++++++++++++ test/mocks/MockChronicleMedianSource.sol | 29 +++++++ test/mocks/MockPyth.sol | 26 ++++++ 3 files changed, 141 insertions(+) create mode 100644 test/fork/adapters/BoundedUnionSourceAdapter.sol create mode 100644 test/mocks/MockChronicleMedianSource.sol create mode 100644 test/mocks/MockPyth.sol diff --git a/test/fork/adapters/BoundedUnionSourceAdapter.sol b/test/fork/adapters/BoundedUnionSourceAdapter.sol new file mode 100644 index 0000000..cb6a678 --- /dev/null +++ b/test/fork/adapters/BoundedUnionSourceAdapter.sol @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {CommonTest} from "../../Common.sol"; +import {BoundedUnionSourceAdapter} from "../../../src/adapters/source-adapters/BoundedUnionSourceAdapter.sol"; +import {IAggregatorV3Source} from "../../../src/interfaces/chainlink/IAggregatorV3Source.sol"; +import {IMedian} from "../../../src/interfaces/chronicle/IMedian.sol"; +import {IPyth} from "../../../src/interfaces/pyth/IPyth.sol"; +import {MockPyth} from "../../mocks/MockPyth.sol"; +import {MockChronicleMedianSource} from "../../mocks/MockChronicleMedianSource.sol"; + +contract TestedSourceAdapter is BoundedUnionSourceAdapter { + constructor( + IAggregatorV3Source chainlink, + IMedian chronicle, + IPyth pyth, + bytes32 pythPriceId, + uint256 boundingTolerance + ) BoundedUnionSourceAdapter(chainlink, chronicle, pyth, pythPriceId, boundingTolerance) {} + + 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 BoundedUnionSourceAdapterTest is CommonTest { + uint256 targetBlock = 18419040; + + IAggregatorV3Source chainlink = IAggregatorV3Source(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419); + MockChronicleMedianSource chronicle; + MockPyth pyth; + bytes32 pythPriceId = 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace; + uint256 boundingTolerance = 0.1e18; + + uint256 lockWindow = 60; + uint256 maxTraversal = 10; + + TestedSourceAdapter sourceAdapter; + + function setUp() public { + vm.createSelectFork("mainnet", targetBlock); + chronicle = new MockChronicleMedianSource(); + pyth = new MockPyth(); + sourceAdapter = new TestedSourceAdapter(chainlink, chronicle, pyth, pythPriceId, boundingTolerance); + vm.makePersistent(address(sourceAdapter)); + } + + function testLookbackDoesNotFlipBackward() public { + // Set initial Pyth price 1% above current Chainlink price at current timestamp. + (, int256 chainlinkPrice,, uint256 chainlinkTime,) = chainlink.latestRoundData(); + int64 pythPrice = int64(chainlinkPrice) * 101 / 100; + pyth.setLatestPrice(pythPrice, 0, -8, block.timestamp); + + // Check that the locked price (lockWindow ago) is the same as the latest Chainlink price. + (int256 lockedAnswer, uint256 lockedTimestamp) = + sourceAdapter.tryLatestDataAt(block.timestamp - lockWindow, maxTraversal); + int256 standardizedChainlinkAnswer = chainlinkPrice * 10 ** (18 - 8); + assertTrue(lockedAnswer == standardizedChainlinkAnswer); + assertTrue(lockedTimestamp == chainlinkTime); + + // Simulate unlock by snapshotting the current data and checking the price matches the latest Pyth price. + sourceAdapter.snapshotData(); // In Oval this should get automatically called via unlockLatestValue. + (int256 unlockedAnswer, uint256 unlockedTimestamp) = + sourceAdapter.tryLatestDataAt(block.timestamp, maxTraversal); + int256 standardizedPythAnswer = int256(pythPrice) * 10 ** (18 - 8); + assertTrue(unlockedAnswer == standardizedPythAnswer); + assertTrue(unlockedTimestamp == block.timestamp); + + // Update Pyth price by additional 1% after 10 minutes. + skip(600); + int64 nextPythPrice = pythPrice * 101 / 100; + pyth.setLatestPrice(nextPythPrice, 0, -8, block.timestamp); + + // Check that the locked price (lockWindow ago) is the same as the prior Pyth price and not flipping back to the + // old Chainlink price. + (int256 nextLockedAnswer, uint256 nextLockedTimestamp) = + sourceAdapter.tryLatestDataAt(block.timestamp - lockWindow, maxTraversal); + assertTrue(nextLockedAnswer == standardizedPythAnswer); + assertTrue(nextLockedTimestamp == unlockedTimestamp); + assertTrue(nextLockedTimestamp > chainlinkTime); + } +} diff --git a/test/mocks/MockChronicleMedianSource.sol b/test/mocks/MockChronicleMedianSource.sol new file mode 100644 index 0000000..ddd156a --- /dev/null +++ b/test/mocks/MockChronicleMedianSource.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {IMedian} from "../../src/interfaces/chronicle/IMedian.sol"; +import {Ownable} from "openzeppelin-contracts/contracts/access/Ownable.sol"; + +contract MockChronicleMedianSource is IMedian, Ownable { + uint256 public value; + uint32 public ageValue; + + function age() external view returns (uint32) { + return ageValue; + } + + function read() external view returns (uint256) { + return value; + } + + function peek() external view returns (uint256, bool) { + return (value, true); + } + + function setLatestSourceData(uint256 _value, uint32 _age) public onlyOwner { + value = _value; + ageValue = _age; + } + + function kiss(address) external override {} +} diff --git a/test/mocks/MockPyth.sol b/test/mocks/MockPyth.sol new file mode 100644 index 0000000..ff7deba --- /dev/null +++ b/test/mocks/MockPyth.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.17; + +import {IPyth} from "../../src/interfaces/pyth/IPyth.sol"; + +contract MockPyth is IPyth { + int64 public price; + uint64 public conf; + int32 public expo; + uint256 public publishTime; + + function setLatestPrice(int64 _price, uint64 _conf, int32 _expo, uint256 _publishTime) public { + price = _price; + conf = _conf; + expo = _expo; + publishTime = _publishTime; + } + + function getPrice(bytes32 id) external view returns (IPyth.Price memory) { + return getPriceUnsafe(id); + } + + function getPriceUnsafe(bytes32 /* id */ ) public view returns (IPyth.Price memory) { + return IPyth.Price({price: price, conf: conf, expo: expo, publishTime: publishTime}); + } +} From c19424b054b7e52a670f5519baca566b84ad2992 Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Thu, 16 May 2024 10:28:50 +0000 Subject: [PATCH 2/8] fix: convert snapshot source to lib Signed-off-by: Reinis Martinsons --- .../SnapshotSourceLib.sol} | 38 +++++++++++-------- .../BoundedUnionSourceAdapter.sol | 3 +- .../ChronicleMedianSourceAdapter.sol | 22 +++++++++-- .../source-adapters/OSMSourceAdapter.sol | 21 ++++++++-- .../source-adapters/PythSourceAdapter.sol | 21 ++++++++-- .../source-adapters/UnionSourceAdapter.sol | 6 +-- .../UniswapAnchoredViewSourceAdapter.sol | 22 +++++++++-- test/mocks/MockSnapshotSourceAdapter.sol | 21 ++++++++-- test/unit/SnapshotSource.SnapshotData.sol | 15 ++++---- 9 files changed, 123 insertions(+), 46 deletions(-) rename src/adapters/{source-adapters/SnapshotSource.sol => lib/SnapshotSourceLib.sol} (68%) diff --git a/src/adapters/source-adapters/SnapshotSource.sol b/src/adapters/lib/SnapshotSourceLib.sol similarity index 68% rename from src/adapters/source-adapters/SnapshotSource.sol rename to src/adapters/lib/SnapshotSourceLib.sol index 52a00be..2ed6328 100644 --- a/src/adapters/source-adapters/SnapshotSource.sol +++ b/src/adapters/lib/SnapshotSourceLib.sol @@ -1,37 +1,36 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.17; -import {DiamondRootOval} from "../../DiamondRootOval.sol"; - /** - * @title SnapshotSource contract to be used in conjunction with a source adapter that needs to snapshot historic data. + * @title SnapshotSourceLib library to be used by a source adapter that needs to snapshot historic data. */ -abstract contract SnapshotSource is DiamondRootOval { +library SnapshotSourceLib { // Snapshot records the historical answer at a specific timestamp. struct Snapshot { int256 answer; uint256 timestamp; } - Snapshot[] public snapshots; // Historical answer and timestamp snapshots. - event SnapshotTaken(uint256 snapshotIndex, uint256 indexed timestamp, int256 indexed answer); /** * @notice Returns the latest snapshot data. + * @param snapshots Pointer to source adapter's snapshots array. * @return Snapshot The latest snapshot data. */ - function latestSnapshotData() public view returns (Snapshot memory) { + function latestSnapshotData(Snapshot[] storage snapshots) internal view returns (Snapshot memory) { if (snapshots.length > 0) return snapshots[snapshots.length - 1]; return Snapshot(0, 0); } /** * @notice Snapshot the current source data. + * @param snapshots Pointer to source adapter's snapshots array. + * @param latestAnswer The latest answer from the source. + * @param latestTimestamp The timestamp of the latest answer from the source. */ - function snapshotData() public virtual override { - (int256 answer, uint256 timestamp) = getLatestSourceData(); - Snapshot memory snapshot = Snapshot(answer, timestamp); + function snapshotData(Snapshot[] storage snapshots, int256 latestAnswer, uint256 latestTimestamp) internal { + Snapshot memory snapshot = Snapshot(latestAnswer, latestTimestamp); if (snapshot.timestamp == 0) return; // Should not store invalid data. // We expect source timestamps to be increasing over time, but there is little we can do to recover if source @@ -45,15 +44,20 @@ abstract contract SnapshotSource is DiamondRootOval { emit SnapshotTaken(snapshotIndex, snapshot.timestamp, snapshot.answer); } - function _tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) internal view returns (Snapshot memory) { - (int256 answer, uint256 _timestamp) = getLatestSourceData(); - Snapshot memory latestData = Snapshot(answer, _timestamp); + function _tryLatestDataAt( + Snapshot[] storage snapshots, + int256 latestAnswer, + uint256 latestTimestamp, + uint256 timestamp, + uint256 maxTraversal + ) internal view returns (Snapshot memory) { + Snapshot memory latestData = Snapshot(latestAnswer, latestTimestamp); // In the happy path there have been no source updates since requested time, so we can return the latest data. // We can use timestamp property as it matches the block timestamp of the latest source update. if (latestData.timestamp <= timestamp) return latestData; // Attempt traversing historical snapshot data. This might still be newer or uninitialized. - Snapshot memory historicalData = _searchSnapshotAt(timestamp, maxTraversal); + Snapshot memory historicalData = _searchSnapshotAt(snapshots, timestamp, maxTraversal); // Validate returned data. If it is uninitialized we fallback to returning the current latest round data. if (historicalData.timestamp > 0) return historicalData; @@ -62,7 +66,11 @@ abstract contract SnapshotSource is DiamondRootOval { // Tries finding latest snapshotted data not newer than requested timestamp. Might still return newer data than // requested if exceeded traversal or hold uninitialized data that should be handled by the caller. - function _searchSnapshotAt(uint256 timestamp, uint256 maxTraversal) internal view returns (Snapshot memory) { + function _searchSnapshotAt(Snapshot[] storage snapshots, uint256 timestamp, uint256 maxTraversal) + internal + view + returns (Snapshot memory) + { Snapshot memory snapshot; uint256 traversedSnapshots = 0; uint256 snapshotId = snapshots.length; // Will decrement when entering loop. diff --git a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol index 7497c0b..6a245d3 100644 --- a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol +++ b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol @@ -9,7 +9,6 @@ import {IPyth} from "../../interfaces/pyth/IPyth.sol"; import {ChainlinkSourceAdapter} from "./ChainlinkSourceAdapter.sol"; import {ChronicleMedianSourceAdapter} from "./ChronicleMedianSourceAdapter.sol"; import {PythSourceAdapter} from "./PythSourceAdapter.sol"; -import {SnapshotSource} from "./SnapshotSource.sol"; /** * @title BoundedUnionSourceAdapter contract to read data from multiple sources and return the newest, contingent on it @@ -58,7 +57,7 @@ abstract contract BoundedUnionSourceAdapter is /** * @notice Snapshots is a no-op for this adapter as its never used. */ - function snapshotData() public override(ChainlinkSourceAdapter, SnapshotSource) {} + function snapshotData() public override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) {} /** * @notice Tries getting latest data as of requested timestamp. Note that for all historic lookups we simply return diff --git a/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol b/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol index c4049ed..f0bc7c3 100644 --- a/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol +++ b/src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol @@ -1,16 +1,19 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.17; -import {SnapshotSource} from "./SnapshotSource.sol"; +import {DiamondRootOval} from "../../DiamondRootOval.sol"; +import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol"; import {IMedian} from "../../interfaces/chronicle/IMedian.sol"; import {SafeCast} from "openzeppelin-contracts/contracts/utils/math/SafeCast.sol"; /** * @title ChronicleMedianSourceAdapter contract to read data from Chronicle and standardize it for Oval. */ -abstract contract ChronicleMedianSourceAdapter is SnapshotSource { +abstract contract ChronicleMedianSourceAdapter is DiamondRootOval { IMedian public immutable CHRONICLE_SOURCE; + SnapshotSourceLib.Snapshot[] public chronicleMedianSnapshots; // Historical answer and timestamp snapshots. + event SourceSet(address indexed sourceOracle); constructor(IMedian _chronicleSource) { @@ -19,6 +22,14 @@ abstract contract ChronicleMedianSourceAdapter is SnapshotSource { emit SourceSet(address(_chronicleSource)); } + /** + * @notice Snapshot the current source data. + */ + function snapshotData() public virtual override { + (int256 latestAnswer, uint256 latestTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.snapshotData(chronicleMedianSnapshots, latestAnswer, latestTimestamp); + } + /** * @notice Returns the latest data from the source. * @dev The standard chronicle implementation will revert if the latest answer is not valid when calling the read @@ -33,7 +44,7 @@ abstract contract ChronicleMedianSourceAdapter is SnapshotSource { /** * @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data * available past the requested timestamp within provided traversal limitations. - * @dev Chronicle does not support historical lookups so this uses SnapshotSource to get historic data. + * @dev Chronicle does not support historical lookups so this uses SnapshotSourceLib to get historic data. * @param timestamp The timestamp to try getting latest data at. * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. @@ -46,7 +57,10 @@ abstract contract ChronicleMedianSourceAdapter is SnapshotSource { override returns (int256, uint256) { - Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt( + chronicleMedianSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal + ); return (snapshot.answer, snapshot.timestamp); } } diff --git a/src/adapters/source-adapters/OSMSourceAdapter.sol b/src/adapters/source-adapters/OSMSourceAdapter.sol index f53d73e..dcff97c 100644 --- a/src/adapters/source-adapters/OSMSourceAdapter.sol +++ b/src/adapters/source-adapters/OSMSourceAdapter.sol @@ -1,19 +1,22 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.17; -import {SnapshotSource} from "./SnapshotSource.sol"; +import {DiamondRootOval} from "../../DiamondRootOval.sol"; +import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol"; import {IOSM} from "../../interfaces/makerdao/IOSM.sol"; /** * @title OSMSourceAdapter contract to read data from MakerDAO OSM and standardize it for Oval. */ -abstract contract OSMSourceAdapter is SnapshotSource { +abstract contract OSMSourceAdapter is DiamondRootOval { IOSM public immutable osmSource; // MakerDAO performs decimal conversion in collateral adapter contracts, so all oracle prices are expected to have // 18 decimals and we can skip decimal conversion. uint8 public constant decimals = 18; + SnapshotSourceLib.Snapshot[] public osmSnapshots; // Historical answer and timestamp snapshots. + event SourceSet(address indexed sourceOracle); constructor(IOSM source) { @@ -22,6 +25,14 @@ abstract contract OSMSourceAdapter is SnapshotSource { emit SourceSet(address(source)); } + /** + * @notice Snapshot the current source data. + */ + function snapshotData() public virtual override { + (int256 latestAnswer, uint256 latestTimestamp) = OSMSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.snapshotData(osmSnapshots, latestAnswer, latestTimestamp); + } + /** * @notice Returns the latest data from the source. * @return answer The latest answer in 18 decimals. @@ -34,14 +45,16 @@ abstract contract OSMSourceAdapter is SnapshotSource { /** * @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data * available past the requested timestamp within provided traversal limitations. - * @dev OSM does not support historical lookups so this uses SnapshotSource to get historic data. + * @dev OSM does not support historical lookups so this uses SnapshotSourceLib to get historic data. * @param timestamp The timestamp to try getting latest data at. * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. * @return updatedAt The timestamp of the answer. */ function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view override returns (int256, uint256) { - Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = OSMSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory snapshot = + SnapshotSourceLib._tryLatestDataAt(osmSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal); return (snapshot.answer, snapshot.timestamp); } } diff --git a/src/adapters/source-adapters/PythSourceAdapter.sol b/src/adapters/source-adapters/PythSourceAdapter.sol index c9e0f2d..a8012dc 100644 --- a/src/adapters/source-adapters/PythSourceAdapter.sol +++ b/src/adapters/source-adapters/PythSourceAdapter.sol @@ -2,16 +2,19 @@ pragma solidity 0.8.17; import {IPyth} from "../../interfaces/pyth/IPyth.sol"; -import {SnapshotSource} from "./SnapshotSource.sol"; +import {DiamondRootOval} from "../../DiamondRootOval.sol"; +import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol"; import {DecimalLib} from "../lib/DecimalLib.sol"; /** * @title PythSourceAdapter contract to read data from Pyth and standardize it for Oval. */ -abstract contract PythSourceAdapter is SnapshotSource { +abstract contract PythSourceAdapter is DiamondRootOval { IPyth public immutable PYTH_SOURCE; bytes32 public immutable PYTH_PRICE_ID; + SnapshotSourceLib.Snapshot[] public pythSnapshots; // Historical answer and timestamp snapshots. + event SourceSet(address indexed sourceOracle, bytes32 indexed pythPriceId); constructor(IPyth _pyth, bytes32 _pythPriceId) { @@ -21,6 +24,14 @@ abstract contract PythSourceAdapter is SnapshotSource { emit SourceSet(address(_pyth), _pythPriceId); } + /** + * @notice Snapshot the current source data. + */ + function snapshotData() public virtual override { + (int256 latestAnswer, uint256 latestTimestamp) = PythSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.snapshotData(pythSnapshots, latestAnswer, latestTimestamp); + } + /** * @notice Returns the latest data from the source. * @return answer The latest answer in 18 decimals. @@ -34,7 +45,7 @@ abstract contract PythSourceAdapter is SnapshotSource { /** * @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data * available past the requested timestamp within provided traversal limitations. - * @dev Pyth does not support historical lookups so this uses SnapshotSource to get historic data. + * @dev Pyth does not support historical lookups so this uses SnapshotSourceLib to get historic data. * @param timestamp The timestamp to try getting latest data at. * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. @@ -47,7 +58,9 @@ abstract contract PythSourceAdapter is SnapshotSource { override returns (int256, uint256) { - Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = PythSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory snapshot = + SnapshotSourceLib._tryLatestDataAt(pythSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal); return (snapshot.answer, snapshot.timestamp); } diff --git a/src/adapters/source-adapters/UnionSourceAdapter.sol b/src/adapters/source-adapters/UnionSourceAdapter.sol index 76a25d0..2b7513d 100644 --- a/src/adapters/source-adapters/UnionSourceAdapter.sol +++ b/src/adapters/source-adapters/UnionSourceAdapter.sol @@ -7,7 +7,6 @@ import {IPyth} from "../../interfaces/pyth/IPyth.sol"; import {ChainlinkSourceAdapter} from "./ChainlinkSourceAdapter.sol"; import {ChronicleMedianSourceAdapter} from "./ChronicleMedianSourceAdapter.sol"; import {PythSourceAdapter} from "./PythSourceAdapter.sol"; -import {SnapshotSource} from "./SnapshotSource.sol"; /** * @title UnionSourceAdapter contract to read data from multiple sources and return the newest. @@ -45,8 +44,9 @@ abstract contract UnionSourceAdapter is ChainlinkSourceAdapter, ChronicleMedianS /** * @notice Snapshots data from all sources that require it. */ - function snapshotData() public override(ChainlinkSourceAdapter, SnapshotSource) { - SnapshotSource.snapshotData(); + function snapshotData() public override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) { + ChronicleMedianSourceAdapter.snapshotData(); + PythSourceAdapter.snapshotData(); } /** diff --git a/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol b/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol index 0ebe586..0d41b64 100644 --- a/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol +++ b/src/adapters/source-adapters/UniswapAnchoredViewSourceAdapter.sol @@ -1,8 +1,9 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.17; +import {DiamondRootOval} from "../../DiamondRootOval.sol"; import {DecimalLib} from "../lib/DecimalLib.sol"; -import {SnapshotSource} from "./SnapshotSource.sol"; +import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol"; import {IAggregatorV3Source} from "../../interfaces/chainlink/IAggregatorV3Source.sol"; import {IUniswapAnchoredView} from "../../interfaces/compound/IUniswapAnchoredView.sol"; import {IValidatorProxy} from "../../interfaces/compound/IValidatorProxy.sol"; @@ -11,13 +12,15 @@ import {IValidatorProxy} from "../../interfaces/compound/IValidatorProxy.sol"; * @title UniswapAnchoredViewSourceAdapter contract to read data from UniswapAnchoredView and standardize it for Oval. * */ -abstract contract UniswapAnchoredViewSourceAdapter is SnapshotSource { +abstract contract UniswapAnchoredViewSourceAdapter is DiamondRootOval { IUniswapAnchoredView public immutable UNISWAP_ANCHORED_VIEW; address public immutable C_TOKEN; uint8 public immutable SOURCE_DECIMALS; IAggregatorV3Source public aggregator; + SnapshotSourceLib.Snapshot[] public uniswapAnchoredViewSnapshots; // Historical answer and timestamp snapshots. + event SourceSet(address indexed sourceOracle, address indexed cToken, uint8 indexed sourceDecimals); event AggregatorSet(address indexed aggregator); @@ -51,6 +54,14 @@ abstract contract UniswapAnchoredViewSourceAdapter is SnapshotSource { emit AggregatorSet(current); } + /** + * @notice Snapshot the current source data. + */ + function snapshotData() public virtual override { + (int256 latestAnswer, uint256 latestTimestamp) = UniswapAnchoredViewSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.snapshotData(uniswapAnchoredViewSnapshots, latestAnswer, latestTimestamp); + } + /** * @notice Returns the latest data from the source. * @return answer The latest answer in 18 decimals. @@ -65,14 +76,17 @@ abstract contract UniswapAnchoredViewSourceAdapter is SnapshotSource { /** * @notice Tries getting latest data as of requested timestamp. If this is not possible, returns the earliest data * available past the requested timestamp within provided traversal limitations. - * @dev UniswapAnchoredView does not support historical lookups so this uses SnapshotSource to get historic data. + * @dev UniswapAnchoredView does not support historical lookups so this uses SnapshotSourceLib to get historic data. * @param timestamp The timestamp to try getting latest data at. * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. * @return updatedAt The timestamp of the answer. */ function tryLatestDataAt(uint256 timestamp, uint256 maxTraversal) public view override returns (int256, uint256) { - Snapshot memory snapshot = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = UniswapAnchoredViewSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt( + uniswapAnchoredViewSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal + ); return (snapshot.answer, snapshot.timestamp); } } diff --git a/test/mocks/MockSnapshotSourceAdapter.sol b/test/mocks/MockSnapshotSourceAdapter.sol index 76b2413..d699fdb 100644 --- a/test/mocks/MockSnapshotSourceAdapter.sol +++ b/test/mocks/MockSnapshotSourceAdapter.sol @@ -1,9 +1,10 @@ // SPDX-License-Identifier: BUSL-1.1 pragma solidity 0.8.17; -import {SnapshotSource} from "../../src/adapters/source-adapters/SnapshotSource.sol"; +import {SnapshotSourceLib} from "../../src/adapters/lib/SnapshotSourceLib.sol"; +import {DiamondRootOval} from "../../src/DiamondRootOval.sol"; -abstract contract MockSnapshotSourceAdapter is SnapshotSource { +abstract contract MockSnapshotSourceAdapter is DiamondRootOval { struct SourceData { int256 answer; uint256 timestamp; @@ -11,10 +12,17 @@ abstract contract MockSnapshotSourceAdapter is SnapshotSource { SourceData[] public sourceRounds; + SnapshotSourceLib.Snapshot[] public mockSnapshots; + function publishSourceData(int256 answer, uint256 timestamp) public { sourceRounds.push(SourceData(answer, timestamp)); } + function snapshotData() public virtual override { + (int256 latestAnswer, uint256 latestTimestamp) = MockSnapshotSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.snapshotData(mockSnapshots, latestAnswer, latestTimestamp); + } + function getLatestSourceData() public view virtual override returns (int256, uint256) { SourceData memory latestData = _latestSourceData(); return (latestData.answer, latestData.timestamp); @@ -27,10 +35,17 @@ abstract contract MockSnapshotSourceAdapter is SnapshotSource { override returns (int256, uint256) { - SnapshotSource.Snapshot memory latestData = _tryLatestDataAt(timestamp, maxTraversal); + (int256 latestAnswer, uint256 latestTimestamp) = MockSnapshotSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.Snapshot memory latestData = + SnapshotSourceLib._tryLatestDataAt(mockSnapshots, latestAnswer, latestTimestamp, timestamp, maxTraversal); return (latestData.answer, latestData.timestamp); } + function latestSnapshotData() public view returns (SnapshotSourceLib.Snapshot memory) { + if (mockSnapshots.length > 0) return mockSnapshots[mockSnapshots.length - 1]; + return SnapshotSourceLib.Snapshot(0, 0); + } + function _latestSourceData() internal view returns (SourceData memory) { if (sourceRounds.length > 0) return sourceRounds[sourceRounds.length - 1]; return SourceData(0, 0); diff --git a/test/unit/SnapshotSource.SnapshotData.sol b/test/unit/SnapshotSource.SnapshotData.sol index 1e5cdbf..a87fab6 100644 --- a/test/unit/SnapshotSource.SnapshotData.sol +++ b/test/unit/SnapshotSource.SnapshotData.sol @@ -5,6 +5,7 @@ import {CommonTest} from "../Common.sol"; import {MockSnapshotSourceAdapter} from "../mocks/MockSnapshotSourceAdapter.sol"; import {Oval} from "../../src/Oval.sol"; import {BaseController} from "../../src/controllers/BaseController.sol"; +import {SnapshotSourceLib} from "../../src/adapters/lib/SnapshotSourceLib.sol"; contract TestSnapshotSource is MockSnapshotSourceAdapter, Oval, BaseController {} @@ -21,10 +22,10 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { // Verify that the snapshotting did not store any data (snapshots array is empty). vm.expectRevert(); - snapshotSource.snapshots(0); + snapshotSource.mockSnapshots(0); // latestSnapshotData should return uninitialized data. - MockSnapshotSourceAdapter.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); + SnapshotSourceLib.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); assertTrue(snapshot.answer == 0 && snapshot.timestamp == 0); } @@ -34,7 +35,7 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { snapshotSource.snapshotData(); // Verify snapshotted data. - MockSnapshotSourceAdapter.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); + SnapshotSourceLib.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); assertTrue(snapshot.answer == 100 && snapshot.timestamp == 1000); } @@ -45,7 +46,7 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { snapshotSource.snapshotData(); // Verify the latest data got snapshotted. - MockSnapshotSourceAdapter.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); + SnapshotSourceLib.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); assertTrue(snapshot.answer == 200 && snapshot.timestamp == 2000); } @@ -55,11 +56,11 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { snapshotSource.snapshotData(); // Verify snapshotted data. - MockSnapshotSourceAdapter.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); + SnapshotSourceLib.Snapshot memory snapshot = snapshotSource.latestSnapshotData(); assertTrue(snapshot.answer == 100 && snapshot.timestamp == 1000); // The first snapshots element should match the latest snapshot data. - (int256 snapshotAnswer, uint256 snapshotTimestamp) = snapshotSource.snapshots(0); + (int256 snapshotAnswer, uint256 snapshotTimestamp) = snapshotSource.mockSnapshots(0); assertTrue(snapshotAnswer == 100 && snapshotTimestamp == 1000); // Publish and snapshot the same source data again. @@ -68,7 +69,7 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { // Verify that the snapshotting did not store any new data (snapshots array still holds one element). vm.expectRevert(); - snapshotSource.snapshots(1); + snapshotSource.mockSnapshots(1); // latestSnapshotData should return the same data. snapshot = snapshotSource.latestSnapshotData(); From 8c2485b50fc264f0a353103c5a61e09800c7657b Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Thu, 16 May 2024 10:31:58 +0000 Subject: [PATCH 3/8] fix: use snapshotting in bounded union source adapter Signed-off-by: Reinis Martinsons --- .../BoundedUnionSourceAdapter.sol | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol index 6a245d3..3a9b5a3 100644 --- a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol +++ b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol @@ -55,9 +55,12 @@ abstract contract BoundedUnionSourceAdapter is } /** - * @notice Snapshots is a no-op for this adapter as its never used. + * @notice Snapshots data from all sources that require it. */ - function snapshotData() public override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) {} + function snapshotData() public override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) { + ChronicleMedianSourceAdapter.snapshotData(); + PythSourceAdapter.snapshotData(); + } /** * @notice Tries getting latest data as of requested timestamp. Note that for all historic lookups we simply return @@ -73,12 +76,14 @@ abstract contract BoundedUnionSourceAdapter is override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) returns (int256, uint256) { - // Chainlink has price history, so use tryLatestDataAt to pull the most recent price that satisfies the timestamp constraint. + // Chainlink has native 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(); + // For Chronicle and Pyth, tryLatestDataAt would attempt to get price from snapshots, but we can drop them if + // they don't satisfy the timestamp constraint. + (int256 crAnswer, uint256 crTimestamp) = ChronicleMedianSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); + (int256 pyAnswer, uint256 pyTimestamp) = PythSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); // 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. From 978a9e129f75e1db854380336c23f39cb5b1c48a Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Thu, 16 May 2024 16:12:47 +0000 Subject: [PATCH 4/8] fix: snapshot aggregated union data Signed-off-by: Reinis Martinsons --- .../BoundedUnionSourceAdapter.sol | 78 ++++++++++++++----- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol index 3a9b5a3..144fb7c 100644 --- a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol +++ b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol @@ -6,6 +6,7 @@ import {SignedMath} from "openzeppelin-contracts/contracts/utils/math/SignedMath import {IAggregatorV3Source} from "../../interfaces/chainlink/IAggregatorV3Source.sol"; import {IMedian} from "../../interfaces/chronicle/IMedian.sol"; import {IPyth} from "../../interfaces/pyth/IPyth.sol"; +import {SnapshotSourceLib} from "../lib/SnapshotSourceLib.sol"; import {ChainlinkSourceAdapter} from "./ChainlinkSourceAdapter.sol"; import {ChronicleMedianSourceAdapter} from "./ChronicleMedianSourceAdapter.sol"; import {PythSourceAdapter} from "./PythSourceAdapter.sol"; @@ -24,8 +25,20 @@ abstract contract BoundedUnionSourceAdapter is ChronicleMedianSourceAdapter, PythSourceAdapter { + // Pack all source data into a struct to avoid stack too deep errors. + struct AllSourceData { + int256 clAnswer; + uint256 clTimestamp; + int256 crAnswer; + uint256 crTimestamp; + int256 pyAnswer; + uint256 pyTimestamp; + } + uint256 public immutable BOUNDING_TOLERANCE; + SnapshotSourceLib.Snapshot[] public boundedUnionSnapshots; // Historical answer and timestamp snapshots. + constructor( IAggregatorV3Source chainlink, IMedian chronicle, @@ -47,24 +60,22 @@ abstract contract BoundedUnionSourceAdapter is override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) returns (int256 answer, uint256 timestamp) { - (int256 clAnswer, uint256 clTimestamp) = ChainlinkSourceAdapter.getLatestSourceData(); - (int256 crAnswer, uint256 crTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData(); - (int256 pyAnswer, uint256 pyTimestamp) = PythSourceAdapter.getLatestSourceData(); - - return _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp); + AllSourceData memory data = _getAllLatestSourceData(); + return _selectBoundedPrice( + data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp + ); } /** - * @notice Snapshots data from all sources that require it. + * @notice Snapshot the current bounded union source data. */ function snapshotData() public override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) { - ChronicleMedianSourceAdapter.snapshotData(); - PythSourceAdapter.snapshotData(); + (int256 latestAnswer, uint256 latestTimestamp) = BoundedUnionSourceAdapter.getLatestSourceData(); + SnapshotSourceLib.snapshotData(boundedUnionSnapshots, latestAnswer, latestTimestamp); } /** - * @notice Tries getting latest data as of requested timestamp. Note that for all historic lookups we simply return - * the Chainlink data as this is the only supported source that has historical data. + * @notice Tries getting latest data as of requested timestamp. * @param timestamp The timestamp to try getting latest data at. * @param maxTraversal The maximum number of rounds to traverse when looking for historical data. * @return answer The answer as of requested timestamp, or earliest available data if not available, in 18 decimals. @@ -76,21 +87,46 @@ abstract contract BoundedUnionSourceAdapter is override(ChainlinkSourceAdapter, ChronicleMedianSourceAdapter, PythSourceAdapter) returns (int256, uint256) { - // Chainlink has native price history, so use tryLatestDataAt to pull the most recent price that satisfies the - // timestamp constraint. - (int256 clAnswer, uint256 clTimestamp) = ChainlinkSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); + // In the happy path there have been no source updates since requested time, so we can return the latest data. + AllSourceData memory data = _getAllLatestSourceData(); + (int256 boundedAnswer, uint256 boundedTimestamp) = _selectBoundedPrice( + data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp + ); + if (boundedTimestamp <= timestamp) return (boundedAnswer, boundedTimestamp); - // For Chronicle and Pyth, tryLatestDataAt would attempt to get price from snapshots, but we can drop them if - // they don't satisfy the timestamp constraint. - (int256 crAnswer, uint256 crTimestamp) = ChronicleMedianSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); - (int256 pyAnswer, uint256 pyTimestamp) = PythSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); + // Chainlink has price history, so use tryLatestDataAt to pull the most recent price that satisfies the timestamp constraint. + (data.clAnswer, data.clTimestamp) = ChainlinkSourceAdapter.tryLatestDataAt(timestamp, maxTraversal); - // To "drop" Chronicle and Pyth, we set their timestamps to 0 (as old as possible) if they are too recent. + // "Drop" Chronicle and/or Pyth by setting their timestamps to 0 (as old as possible) if they are too recent. // This means that they will never be used if either or both are 0. - if (crTimestamp > timestamp) crTimestamp = 0; - if (pyTimestamp > timestamp) pyTimestamp = 0; + if (data.crTimestamp > timestamp) data.crTimestamp = 0; + if (data.pyTimestamp > timestamp) data.pyTimestamp = 0; + + // Bounded union prices could have been captured at snapshot that satisfies time constraint. + SnapshotSourceLib.Snapshot memory snapshot = SnapshotSourceLib._tryLatestDataAt( + boundedUnionSnapshots, boundedAnswer, boundedTimestamp, timestamp, maxTraversal + ); + + // Update bounded data with constrained source data. + (boundedAnswer, boundedTimestamp) = _selectBoundedPrice( + data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp + ); + + // Return bounded data unless there is a newer snapshotted data that still satisfies time constraint. + if (boundedTimestamp >= snapshot.timestamp || snapshot.timestamp > timestamp) { + return (boundedAnswer, boundedTimestamp); + } + return (snapshot.answer, snapshot.timestamp); + } + + // Internal helper to get the latest data from all sources. + function _getAllLatestSourceData() internal view returns (AllSourceData memory) { + AllSourceData memory data; + (data.clAnswer, data.clTimestamp) = ChainlinkSourceAdapter.getLatestSourceData(); + (data.crAnswer, data.crTimestamp) = ChronicleMedianSourceAdapter.getLatestSourceData(); + (data.pyAnswer, data.pyTimestamp) = PythSourceAdapter.getLatestSourceData(); - return _selectBoundedPrice(clAnswer, clTimestamp, crAnswer, crTimestamp, pyAnswer, pyTimestamp); + return data; } // Selects the appropriate price from the three sources based on the bounding tolerance and logic. From 89bad8b0dfdfab1c3352985642437fb743ca8f3b Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Mon, 20 May 2024 06:12:59 +0000 Subject: [PATCH 5/8] fix: round id in tests Signed-off-by: Reinis Martinsons --- test/fork/adapters/BoundedUnionSourceAdapter.sol | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/fork/adapters/BoundedUnionSourceAdapter.sol b/test/fork/adapters/BoundedUnionSourceAdapter.sol index cb6a678..662846e 100644 --- a/test/fork/adapters/BoundedUnionSourceAdapter.sol +++ b/test/fork/adapters/BoundedUnionSourceAdapter.sol @@ -18,7 +18,9 @@ contract TestedSourceAdapter is BoundedUnionSourceAdapter { uint256 boundingTolerance ) BoundedUnionSourceAdapter(chainlink, chronicle, pyth, pythPriceId, boundingTolerance) {} - 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) {} @@ -56,19 +58,21 @@ contract BoundedUnionSourceAdapterTest is CommonTest { pyth.setLatestPrice(pythPrice, 0, -8, block.timestamp); // Check that the locked price (lockWindow ago) is the same as the latest Chainlink price. - (int256 lockedAnswer, uint256 lockedTimestamp) = + (int256 lockedAnswer, uint256 lockedTimestamp, uint256 lockedRoundId) = sourceAdapter.tryLatestDataAt(block.timestamp - lockWindow, maxTraversal); int256 standardizedChainlinkAnswer = chainlinkPrice * 10 ** (18 - 8); assertTrue(lockedAnswer == standardizedChainlinkAnswer); assertTrue(lockedTimestamp == chainlinkTime); + assertTrue(lockedRoundId == 1); // roundId not supported, hardcoded to 1. // Simulate unlock by snapshotting the current data and checking the price matches the latest Pyth price. sourceAdapter.snapshotData(); // In Oval this should get automatically called via unlockLatestValue. - (int256 unlockedAnswer, uint256 unlockedTimestamp) = + (int256 unlockedAnswer, uint256 unlockedTimestamp, uint256 unlockedRoundId) = sourceAdapter.tryLatestDataAt(block.timestamp, maxTraversal); int256 standardizedPythAnswer = int256(pythPrice) * 10 ** (18 - 8); assertTrue(unlockedAnswer == standardizedPythAnswer); assertTrue(unlockedTimestamp == block.timestamp); + assertTrue(unlockedRoundId == 1); // roundId not supported, hardcoded to 1. // Update Pyth price by additional 1% after 10 minutes. skip(600); @@ -77,10 +81,11 @@ contract BoundedUnionSourceAdapterTest is CommonTest { // Check that the locked price (lockWindow ago) is the same as the prior Pyth price and not flipping back to the // old Chainlink price. - (int256 nextLockedAnswer, uint256 nextLockedTimestamp) = + (int256 nextLockedAnswer, uint256 nextLockedTimestamp, uint256 nextLockedRoundId) = sourceAdapter.tryLatestDataAt(block.timestamp - lockWindow, maxTraversal); assertTrue(nextLockedAnswer == standardizedPythAnswer); assertTrue(nextLockedTimestamp == unlockedTimestamp); assertTrue(nextLockedTimestamp > chainlinkTime); + assertTrue(nextLockedRoundId == 1); // roundId not supported, hardcoded to 1. } } From 77b162c121ef48fd8090dfd319440d2c1adff069 Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Mon, 20 May 2024 16:06:08 +0000 Subject: [PATCH 6/8] fix: pass data struct in internal call Signed-off-by: Reinis Martinsons --- .../BoundedUnionSourceAdapter.sol | 22 +++++++------------ .../BoundedUnionSource.SelectBoundedPrice.sol | 10 ++++++++- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol index 6ca6a56..113ed43 100644 --- a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol +++ b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol @@ -61,9 +61,7 @@ abstract contract BoundedUnionSourceAdapter is returns (int256 answer, uint256 timestamp) { AllSourceData memory data = _getAllLatestSourceData(); - return _selectBoundedPrice( - data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp - ); + return _selectBoundedPrice(data); } /** @@ -106,9 +104,7 @@ abstract contract BoundedUnionSourceAdapter is { // In the happy path there have been no source updates since requested time, so we can return the latest data. AllSourceData memory data = _getAllLatestSourceData(); - (int256 boundedAnswer, uint256 boundedTimestamp) = _selectBoundedPrice( - data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp - ); + (int256 boundedAnswer, uint256 boundedTimestamp) = _selectBoundedPrice(data); if (boundedTimestamp <= timestamp) return (boundedAnswer, boundedTimestamp, 1); // Chainlink has price history, so use tryLatestDataAt to pull the most recent price that satisfies the timestamp constraint. @@ -125,9 +121,7 @@ abstract contract BoundedUnionSourceAdapter is ); // Update bounded data with constrained source data. - (boundedAnswer, boundedTimestamp) = _selectBoundedPrice( - data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp - ); + (boundedAnswer, boundedTimestamp) = _selectBoundedPrice(data); // Return bounded data unless there is a newer snapshotted data that still satisfies time constraint. if (boundedTimestamp >= snapshot.timestamp || snapshot.timestamp > timestamp) { @@ -147,14 +141,14 @@ abstract contract BoundedUnionSourceAdapter is } // Selects the appropriate price from the three sources based on the bounding tolerance and logic. - function _selectBoundedPrice(int256 cl, uint256 clT, int256 cr, uint256 crT, int256 py, uint256 pyT) - internal - view - returns (int256, uint256) - { + function _selectBoundedPrice(AllSourceData memory data) internal view returns (int256, uint256) { int256 newestVal = 0; uint256 newestT = 0; + // Unpack the data to short named variables for better code readability below. + (int256 cl, uint256 clT, int256 cr, uint256 crT, int256 py, uint256 pyT) = + (data.clAnswer, data.clTimestamp, data.crAnswer, data.crTimestamp, data.pyAnswer, data.pyTimestamp); + // For each price, check if it is within tolerance of the other two. If so, check if it is the newest. if (pyT > newestT && (_withinTolerance(py, cr) || _withinTolerance(py, cl))) (newestVal, newestT) = (py, pyT); if (crT > newestT && (_withinTolerance(cr, py) || _withinTolerance(cr, cl))) (newestVal, newestT) = (cr, crT); diff --git a/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol index 521c941..9941d23 100644 --- a/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol +++ b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol @@ -24,7 +24,15 @@ contract TestBoundedUnionSource is BoundedUnionSourceAdapter { view returns (int256, uint256) { - return _selectBoundedPrice(cl, clT, cr, crT, py, pyT); + AllSourceData memory data = AllSourceData({ + clAnswer: cl, + clTimestamp: clT, + crAnswer: cr, + crTimestamp: crT, + pyAnswer: py, + pyTimestamp: pyT + }); + return _selectBoundedPrice(data); } function withinTolerance(int256 a, int256 b) public view returns (bool) { From d4252848c74f1fbff69d9171cee463cb89d3342d Mon Sep 17 00:00:00 2001 From: Reinis Martinsons Date: Mon, 20 May 2024 16:08:00 +0000 Subject: [PATCH 7/8] fix: give preference to the snapshot data for same timestamp Signed-off-by: Reinis Martinsons --- src/adapters/source-adapters/BoundedUnionSourceAdapter.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol index 113ed43..d2142df 100644 --- a/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol +++ b/src/adapters/source-adapters/BoundedUnionSourceAdapter.sol @@ -124,7 +124,7 @@ abstract contract BoundedUnionSourceAdapter is (boundedAnswer, boundedTimestamp) = _selectBoundedPrice(data); // Return bounded data unless there is a newer snapshotted data that still satisfies time constraint. - if (boundedTimestamp >= snapshot.timestamp || snapshot.timestamp > timestamp) { + if (boundedTimestamp > snapshot.timestamp || snapshot.timestamp > timestamp) { return (boundedAnswer, boundedTimestamp, 1); } return (snapshot.answer, snapshot.timestamp, 1); From 424d94ecf234fa4fac5adb459ee7c1f2ffbb2a93 Mon Sep 17 00:00:00 2001 From: Matt Rice Date: Mon, 20 May 2024 14:59:51 -0400 Subject: [PATCH 8/8] feat: add maxAge parameter to limit the staleness of a historical data point (#14) Signed-off-by: Matt Rice Signed-off-by: chrismaree Co-authored-by: chrismaree --- src/DiamondRootOval.sol | 7 +++- .../ChainlinkSourceAdapter.sol | 4 +- .../source-adapters/SnapshotSource.sol | 4 +- src/controllers/BaseController.sol | 18 +++++++++ src/controllers/ImmutableController.sol | 12 +++++- src/interfaces/IBaseController.sol | 1 + test/fork/aave/AaveV2.Liquidation.sol | 2 +- test/fork/aave/AaveV3.Liquidation.sol | 2 +- test/fork/adapters/ChainlinkSourceAdapter.sol | 39 +++++++++++++------ .../adapters/ChronicleMedianSourceAdapter.sol | 8 +--- test/fork/adapters/OSMSourceAdapter.sol | 8 +--- test/fork/adapters/PythSourceAdapter.sol | 13 +------ test/fork/adapters/UnionSourceAdapter.sol | 15 ++----- .../UniswapAnchoredViewSourceAdapter.sol | 8 +--- test/fork/compound/CompoundV2.Liquidation.sol | 2 +- test/unit/BaseController.sol | 13 +++++++ test/unit/ImmutableController.sol | 13 +++++-- test/unit/SnapshotSource.SnapshotData.sol | 38 ++++++++++++++++++ .../BoundedUnionSource.SelectBoundedPrice.sol | 1 + 19 files changed, 145 insertions(+), 63 deletions(-) diff --git a/src/DiamondRootOval.sol b/src/DiamondRootOval.sol index c8223eb..1d0f294 100644 --- a/src/DiamondRootOval.sol +++ b/src/DiamondRootOval.sol @@ -75,13 +75,16 @@ abstract contract DiamondRootOval is IBaseController, IOval, IBaseOracleAdapter * @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 virtual returns (uint256); /** * @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 virtual returns (uint256); + + /** + * @notice Max age of a historical price that can be used instead of the current price. + */ + function maxAge() public view virtual returns (uint256); } diff --git a/src/adapters/source-adapters/ChainlinkSourceAdapter.sol b/src/adapters/source-adapters/ChainlinkSourceAdapter.sol index 7fb3fae..fe3dbf9 100644 --- a/src/adapters/source-adapters/ChainlinkSourceAdapter.sol +++ b/src/adapters/source-adapters/ChainlinkSourceAdapter.sol @@ -94,7 +94,9 @@ abstract contract ChainlinkSourceAdapter is DiamondRootOval { _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, historicalRoundId); + if (historicalUpdatedAt > block.timestamp - maxAge()) { + return (historicalAnswer, historicalUpdatedAt, historicalRoundId); + } return (answer, updatedAt, roundId); } diff --git a/src/adapters/source-adapters/SnapshotSource.sol b/src/adapters/source-adapters/SnapshotSource.sol index 52a00be..358743e 100644 --- a/src/adapters/source-adapters/SnapshotSource.sol +++ b/src/adapters/source-adapters/SnapshotSource.sol @@ -55,8 +55,8 @@ abstract contract SnapshotSource is DiamondRootOval { // Attempt traversing historical snapshot data. This might still be newer or uninitialized. Snapshot memory historicalData = _searchSnapshotAt(timestamp, maxTraversal); - // Validate returned data. If it is uninitialized we fallback to returning the current latest round data. - if (historicalData.timestamp > 0) return historicalData; + // Validate returned data. If it is uninitialized or too old we fallback to returning the current latest round data. + if (historicalData.timestamp >= block.timestamp - maxAge()) return historicalData; return latestData; } diff --git a/src/controllers/BaseController.sol b/src/controllers/BaseController.sol index b4b7504..36a30dd 100644 --- a/src/controllers/BaseController.sol +++ b/src/controllers/BaseController.sol @@ -12,6 +12,7 @@ abstract contract BaseController is Ownable, Oval { // these don't need to be public since they can be accessed via the accessor functions below. uint256 private lockWindow_ = 60; // The lockWindow in seconds. uint256 private maxTraversal_ = 10; // The maximum number of rounds to traverse when looking for historical data. + uint256 private maxAge_ = 1 days; // Default 1 day. mapping(address => bool) public unlockers; @@ -66,6 +67,16 @@ abstract contract BaseController is Ownable, Oval { emit MaxTraversalSet(newMaxTraversal); } + /** + * @notice Enables the owner to set the maxAge. + * @param newMaxAge The maxAge to set + */ + function setMaxAge(uint256 newMaxAge) public onlyOwner { + maxAge_ = newMaxAge; + + emit MaxAgeSet(newMaxAge); + } + /** * @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 @@ -83,4 +94,11 @@ abstract contract BaseController is Ownable, Oval { function maxTraversal() public view override returns (uint256) { return maxTraversal_; } + + /** + * @notice Max age of a historical price that can be used instead of the current price. + */ + function maxAge() public view override returns (uint256) { + return maxAge_; + } } diff --git a/src/controllers/ImmutableController.sol b/src/controllers/ImmutableController.sol index cadbe06..0b48665 100644 --- a/src/controllers/ImmutableController.sol +++ b/src/controllers/ImmutableController.sol @@ -13,12 +13,14 @@ import {Oval} from "../Oval.sol"; abstract contract ImmutableController is Oval { 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. + uint256 private immutable MAX_AGE; mapping(address => bool) public unlockers; - constructor(uint256 _lockWindow, uint256 _maxTraversal, address[] memory _unlockers) { + constructor(uint256 _lockWindow, uint256 _maxTraversal, address[] memory _unlockers, uint256 _maxAge) { LOCK_WINDOW = _lockWindow; MAX_TRAVERSAL = _maxTraversal; + MAX_AGE = _maxAge; for (uint256 i = 0; i < _unlockers.length; i++) { unlockers[_unlockers[i]] = true; @@ -27,6 +29,7 @@ abstract contract ImmutableController is Oval { emit LockWindowSet(_lockWindow); emit MaxTraversalSet(_maxTraversal); + emit MaxAgeSet(_maxAge); } /** @@ -57,4 +60,11 @@ abstract contract ImmutableController is Oval { function maxTraversal() public view override returns (uint256) { return MAX_TRAVERSAL; } + + /** + * @notice Max age of a historical price that can be used instead of the current price. + */ + function maxAge() public view override returns (uint256) { + return MAX_AGE; + } } diff --git a/src/interfaces/IBaseController.sol b/src/interfaces/IBaseController.sol index 8137608..57e6793 100644 --- a/src/interfaces/IBaseController.sol +++ b/src/interfaces/IBaseController.sol @@ -5,6 +5,7 @@ interface IBaseController { event LockWindowSet(uint256 indexed lockWindow); event MaxTraversalSet(uint256 indexed maxTraversal); event UnlockerSet(address indexed unlocker, bool indexed allowed); + event MaxAgeSet(uint256 indexed newMaxAge); function canUnlock(address caller, uint256 cachedLatestTimestamp) external view returns (bool); } diff --git a/test/fork/aave/AaveV2.Liquidation.sol b/test/fork/aave/AaveV2.Liquidation.sol index e1ed042..2101af0 100644 --- a/test/fork/aave/AaveV2.Liquidation.sol +++ b/test/fork/aave/AaveV2.Liquidation.sol @@ -20,7 +20,7 @@ interface Usdc is IERC20 { contract TestedOval is ImmutableController, ChainlinkSourceAdapter, ChainlinkDestinationAdapter { constructor(IAggregatorV3Source source, uint8 decimals, address[] memory unlockers) ChainlinkSourceAdapter(source) - ImmutableController(60, 10, unlockers) + ImmutableController(60, 10, unlockers, 86400) ChainlinkDestinationAdapter(decimals) {} } diff --git a/test/fork/aave/AaveV3.Liquidation.sol b/test/fork/aave/AaveV3.Liquidation.sol index 4788c47..2e2dcfd 100644 --- a/test/fork/aave/AaveV3.Liquidation.sol +++ b/test/fork/aave/AaveV3.Liquidation.sol @@ -20,7 +20,7 @@ interface Usdc is IERC20 { contract TestedOval is ImmutableController, ChainlinkSourceAdapter, ChainlinkDestinationAdapter { constructor(IAggregatorV3Source source, uint8 decimals, address[] memory unlockers) ChainlinkSourceAdapter(source) - ImmutableController(60, 10, unlockers) + ImmutableController(60, 10, unlockers, 86400) ChainlinkDestinationAdapter(decimals) {} } diff --git a/test/fork/adapters/ChainlinkSourceAdapter.sol b/test/fork/adapters/ChainlinkSourceAdapter.sol index 6ec2c96..d24e3f2 100644 --- a/test/fork/adapters/ChainlinkSourceAdapter.sol +++ b/test/fork/adapters/ChainlinkSourceAdapter.sol @@ -8,18 +8,8 @@ import {ChainlinkSourceAdapter} from "../../../src/adapters/source-adapters/Chai import {DecimalLib} from "../../../src/adapters/lib/DecimalLib.sol"; import {IAggregatorV3Source} from "../../../src/interfaces/chainlink/IAggregatorV3Source.sol"; -contract TestedSourceAdapter is ChainlinkSourceAdapter { +contract TestedSourceAdapter is ChainlinkSourceAdapter, BaseController { constructor(IAggregatorV3Source source) ChainlinkSourceAdapter(source) {} - - 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) {} } contract ChainlinkSourceAdapterTest is CommonTest { @@ -107,6 +97,33 @@ contract ChainlinkSourceAdapterTest is CommonTest { assertTrue(uint256(roundId) == lookBackRoundId); } + function testCorrectlyBoundsMaxLooBackByMaxAge() public { + // Value returned at 2 days should be the same as the value returned at 1 day as the max age is 1 day. + assertTrue(sourceAdapter.maxAge() == 1 days); + (int256 lookBackPricePastWindow, uint256 lookBackTimestampPastWindow, uint256 lookBackRoundIdPastWindow) = + sourceAdapter.tryLatestDataAt(block.timestamp - 2 days, 50); + + (int256 lookBackPriceAtLimit, uint256 lookBackTimestampAtLimit, uint256 lookBackRoundIdAtLimit) = + sourceAdapter.tryLatestDataAt(block.timestamp - 1 days, 50); + + assertTrue(lookBackPricePastWindow == lookBackPriceAtLimit); + assertTrue(lookBackTimestampPastWindow == lookBackTimestampAtLimit); + assertTrue(lookBackRoundIdPastWindow == lookBackRoundIdAtLimit); + } + + function testExtendingMaxAgeCorrectlyExtendsWindowOfReturnedValue() public { + sourceAdapter.setMaxAge(2 days); + (int256 lookBackPricePastWindow, uint256 lookBackTimestampPastWindow, uint256 lookBackRoundIdPastWindow) = + sourceAdapter.tryLatestDataAt(block.timestamp - 3 days, 50); + + (int256 lookBackPriceAtLimit, uint256 lookBackTimestampAtLimit, uint256 lookBackRoundIdAtLimit) = + sourceAdapter.tryLatestDataAt(block.timestamp - 2 days, 50); + + assertTrue(lookBackPricePastWindow == lookBackPriceAtLimit); + assertTrue(lookBackTimestampPastWindow == lookBackTimestampAtLimit); + assertTrue(lookBackRoundIdPastWindow == lookBackRoundIdAtLimit); + } + function testNonHistoricalData() public { uint256 targetTime = block.timestamp - 1 hours; diff --git a/test/fork/adapters/ChronicleMedianSourceAdapter.sol b/test/fork/adapters/ChronicleMedianSourceAdapter.sol index 1c184f7..1420591 100644 --- a/test/fork/adapters/ChronicleMedianSourceAdapter.sol +++ b/test/fork/adapters/ChronicleMedianSourceAdapter.sol @@ -3,16 +3,12 @@ pragma solidity 0.8.17; import {CommonTest} from "../../Common.sol"; +import {BaseController} from "../../../src/controllers/BaseController.sol"; import {ChronicleMedianSourceAdapter} from "../../../src/adapters/source-adapters/ChronicleMedianSourceAdapter.sol"; import {IMedian} from "../../../src/interfaces/chronicle/IMedian.sol"; -contract TestedSourceAdapter is ChronicleMedianSourceAdapter { +contract TestedSourceAdapter is ChronicleMedianSourceAdapter, BaseController { constructor(IMedian source) ChronicleMedianSourceAdapter(source) {} - 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) {} } contract ChronicleMedianSourceAdapterTest is CommonTest { diff --git a/test/fork/adapters/OSMSourceAdapter.sol b/test/fork/adapters/OSMSourceAdapter.sol index b9d34e6..bf818dd 100644 --- a/test/fork/adapters/OSMSourceAdapter.sol +++ b/test/fork/adapters/OSMSourceAdapter.sol @@ -3,17 +3,13 @@ pragma solidity 0.8.17; import {CommonTest} from "../../Common.sol"; +import {BaseController} from "../../../src/controllers/BaseController.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 { +contract TestedSourceAdapter is OSMSourceAdapter, BaseController { constructor(IOSM source) OSMSourceAdapter(source) {} - 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) {} } contract OSMSourceAdapterTest is CommonTest { diff --git a/test/fork/adapters/PythSourceAdapter.sol b/test/fork/adapters/PythSourceAdapter.sol index 55c4f0d..7268829 100644 --- a/test/fork/adapters/PythSourceAdapter.sol +++ b/test/fork/adapters/PythSourceAdapter.sol @@ -3,21 +3,12 @@ pragma solidity 0.8.17; import {CommonTest} from "../../Common.sol"; +import {BaseController} from "../../../src/controllers/BaseController.sol"; import {PythSourceAdapter} from "../../../src/adapters/source-adapters/PythSourceAdapter.sol"; import {IPyth} from "../../../src/interfaces/pyth/IPyth.sol"; -contract TestedSourceAdapter is PythSourceAdapter { +contract TestedSourceAdapter is PythSourceAdapter, BaseController { constructor(IPyth source, bytes32 priceId) PythSourceAdapter(source, priceId) {} - - 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) {} } contract PythSourceAdapterTest is CommonTest { diff --git a/test/fork/adapters/UnionSourceAdapter.sol b/test/fork/adapters/UnionSourceAdapter.sol index 262e9c4..ca0e14b 100644 --- a/test/fork/adapters/UnionSourceAdapter.sol +++ b/test/fork/adapters/UnionSourceAdapter.sol @@ -2,26 +2,18 @@ pragma solidity 0.8.17; import {CommonTest} from "../../Common.sol"; + +import {BaseController} from "../../../src/controllers/BaseController.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 { +contract TestedSourceAdapter is UnionSourceAdapter, BaseController { constructor(IAggregatorV3Source chainlink, IMedian chronicle, IPyth pyth, bytes32 pythPriceId) UnionSourceAdapter(chainlink, chronicle, pyth, pythPriceId) {} - - 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) {} } contract UnionSourceAdapterTest is CommonTest { @@ -174,6 +166,7 @@ contract UnionSourceAdapterTest is CommonTest { // Fork to a block where chronicle was the newest. vm.createSelectFork("mainnet", targetChronicleBlock); uint256 targetTimestamp = block.timestamp; + sourceAdapter.setMaxAge(2 days); // Set max age to 2 days to disable this logic for the test. _whitelistOnChronicle(); // Snapshotting union adapter should not affect historical lookups, but we do it just to prove it does not interfere. diff --git a/test/fork/adapters/UniswapAnchoredViewSourceAdapter.sol b/test/fork/adapters/UniswapAnchoredViewSourceAdapter.sol index d04c3e7..0567ef2 100644 --- a/test/fork/adapters/UniswapAnchoredViewSourceAdapter.sol +++ b/test/fork/adapters/UniswapAnchoredViewSourceAdapter.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.17; import {CommonTest} from "../../Common.sol"; +import {BaseController} from "../../../src/controllers/BaseController.sol"; import {IValidatorProxyTest} from "../interfaces/compoundV2/IValidatorProxy.sol"; import {MockChainlinkV3Aggregator} from "../../mocks/MockChainlinkV3Aggregator.sol"; import {UniswapAnchoredViewSourceAdapter} from @@ -10,13 +11,8 @@ import {UniswapAnchoredViewSourceAdapter} from import {IAccessControlledAggregatorV3} from "../../../src/interfaces/chainlink/IAccessControlledAggregatorV3.sol"; import {IUniswapAnchoredView} from "../../../src/interfaces/compound/IUniswapAnchoredView.sol"; -contract TestedSourceAdapter is UniswapAnchoredViewSourceAdapter { +contract TestedSourceAdapter is UniswapAnchoredViewSourceAdapter, BaseController { constructor(IUniswapAnchoredView source, address cToken) UniswapAnchoredViewSourceAdapter(source, cToken) {} - 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) {} } contract UniswapAnchoredViewSourceAdapterTest is CommonTest { diff --git a/test/fork/compound/CompoundV2.Liquidation.sol b/test/fork/compound/CompoundV2.Liquidation.sol index 5b617dd..d8d9df4 100644 --- a/test/fork/compound/CompoundV2.Liquidation.sol +++ b/test/fork/compound/CompoundV2.Liquidation.sol @@ -27,7 +27,7 @@ interface Usdc is IERC20 { contract TestedOval is ImmutableController, UniswapAnchoredViewSourceAdapter, BaseDestinationAdapter { constructor(IUniswapAnchoredView source, address cToken, address[] memory unlockers) UniswapAnchoredViewSourceAdapter(source, cToken) - ImmutableController(60, 10, unlockers) + ImmutableController(60, 10, unlockers, 86400) BaseDestinationAdapter() {} } diff --git a/test/unit/BaseController.sol b/test/unit/BaseController.sol index 8caffc0..7a2083c 100644 --- a/test/unit/BaseController.sol +++ b/test/unit/BaseController.sol @@ -65,4 +65,17 @@ contract BaseControllerTest is CommonTest { vm.expectRevert("Ownable: caller is not the owner"); baseController.setMaxTraversal(100); } + + function testOwnerCanSetMaxAge() public { + uint256 newMaxAge = 7200; // 2 hours in seconds, different from default 1 day + vm.prank(owner); + baseController.setMaxAge(newMaxAge); + assertTrue(baseController.maxAge() == newMaxAge); + } + + function testNonOwnerCannotSetMaxAge() public { + vm.prank(random); + vm.expectRevert("Ownable: caller is not the owner"); + baseController.setMaxAge(7200); + } } diff --git a/test/unit/ImmutableController.sol b/test/unit/ImmutableController.sol index 4bacf79..ee86a47 100644 --- a/test/unit/ImmutableController.sol +++ b/test/unit/ImmutableController.sol @@ -7,9 +7,15 @@ import {BaseDestinationAdapter} from "../../src/adapters/destination-adapters/Ba import {MockSourceAdapter} from "../mocks/MockSourceAdapter.sol"; contract TestImmutableController is ImmutableController, MockSourceAdapter, BaseDestinationAdapter { - constructor(uint8 decimals, uint256 _lockWindow, uint256 _maxTraversal, address[] memory _unlockers) + constructor( + uint8 decimals, + uint256 _lockWindow, + uint256 _maxTraversal, + address[] memory _unlockers, + uint256 _maxAge + ) MockSourceAdapter(decimals) - ImmutableController(_lockWindow, _maxTraversal, _unlockers) + ImmutableController(_lockWindow, _maxTraversal, _unlockers, _maxAge) BaseDestinationAdapter() {} } @@ -19,6 +25,7 @@ contract ImmutableControllerTest is CommonTest { uint256 lockWindow = 60; uint256 maxTraversal = 10; address[] unlockers; + uint256 maxAge = 86400; uint256 lastUnlockTime = 1690000000; @@ -28,7 +35,7 @@ contract ImmutableControllerTest is CommonTest { unlockers.push(permissionedUnlocker); vm.startPrank(owner); - immutableController = new TestImmutableController(decimals, lockWindow, maxTraversal, unlockers); + immutableController = new TestImmutableController(decimals, lockWindow, maxTraversal, unlockers, maxAge); vm.stopPrank(); } diff --git a/test/unit/SnapshotSource.SnapshotData.sol b/test/unit/SnapshotSource.SnapshotData.sol index 1e5cdbf..f5b501f 100644 --- a/test/unit/SnapshotSource.SnapshotData.sol +++ b/test/unit/SnapshotSource.SnapshotData.sol @@ -5,6 +5,7 @@ import {CommonTest} from "../Common.sol"; import {MockSnapshotSourceAdapter} from "../mocks/MockSnapshotSourceAdapter.sol"; import {Oval} from "../../src/Oval.sol"; import {BaseController} from "../../src/controllers/BaseController.sol"; +import "forge-std/console.sol"; contract TestSnapshotSource is MockSnapshotSourceAdapter, Oval, BaseController {} @@ -74,4 +75,41 @@ contract SnapshotSourceSnapshotDataTest is CommonTest { snapshot = snapshotSource.latestSnapshotData(); assertTrue(snapshot.answer == 100 && snapshot.timestamp == 1000); } + + function testMaxAgeIsRespected() public { + // Set maxAge to 2000 for testing + snapshotSource.setMaxAge(2000); + + // Publish data at different timestamps + vm.warp(1000); + snapshotSource.publishSourceData(100, 1000); + snapshotSource.snapshotData(); + + vm.warp(2000); + snapshotSource.publishSourceData(200, 2000); + snapshotSource.snapshotData(); + + vm.warp(3000); + snapshotSource.publishSourceData(300, 3000); + snapshotSource.snapshotData(); + + vm.warp(4000); + snapshotSource.publishSourceData(400, 4000); + snapshotSource.snapshotData(); + + // Verify behavior when requesting data within the maxAge limit + (int256 answerAt4000, uint256 timestampAt4000,) = snapshotSource.tryLatestDataAt(4000, 10); + assertTrue(answerAt4000 == 400 && timestampAt4000 == 4000); + + (int256 answerAt3000, uint256 timestampAt3000,) = snapshotSource.tryLatestDataAt(3000, 10); + assertTrue(answerAt3000 == 300 && timestampAt3000 == 3000); + + // Request data at the limit of maxAge should still work. + (int256 answerAt2000, uint256 timestampAt2000,) = snapshotSource.tryLatestDataAt(2000, 10); + assertTrue(answerAt2000 == 200 && timestampAt2000 == 2000); + + // Request data older than maxAge (1000), should get the latest available data at 4000. + (int256 answerAt1000, uint256 timestampAt1000,) = snapshotSource.tryLatestDataAt(1000, 10); + assertTrue(answerAt1000 == 400 && timestampAt1000 == 4000); + } } diff --git a/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol index 521c941..2b4c88d 100644 --- a/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol +++ b/test/unit/adapters/BoundedUnionSource.SelectBoundedPrice.sol @@ -39,6 +39,7 @@ contract TestBoundedUnionSource is BoundedUnionSourceAdapter { function lockWindow() public view virtual override returns (uint256) {} function maxTraversal() public view virtual override returns (uint256) {} + function maxAge() public view virtual override returns (uint256) {} } contract MinimalChainlinkAdapter {