From eb7532229361d1511579c01585dfd1b33bddd074 Mon Sep 17 00:00:00 2001 From: jar-o Date: Tue, 28 Nov 2023 11:46:57 -0700 Subject: [PATCH] Aggor.v2 --- script/Aggor.s.sol | 141 --------- src/Aggor.sol | 472 +++++++++++++---------------- src/IAggor.sol | 199 +++++-------- src/libs/LibCalc.sol | 4 +- test/AggorTest.t.sol | 420 ++++++++++++++++++++++++++ test/IAggorTest.sol | 540 ---------------------------------- test/MainnetIntegration.t.sol | 187 ------------ test/Runner.t.sol | 45 --- 8 files changed, 692 insertions(+), 1316 deletions(-) delete mode 100644 script/Aggor.s.sol create mode 100644 test/AggorTest.t.sol delete mode 100644 test/IAggorTest.sol delete mode 100644 test/MainnetIntegration.t.sol delete mode 100644 test/Runner.t.sol diff --git a/script/Aggor.s.sol b/script/Aggor.s.sol deleted file mode 100644 index e2f82c6..0000000 --- a/script/Aggor.s.sol +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.16; - -import {Script} from "forge-std/Script.sol"; -import {console2} from "forge-std/console2.sol"; - -import {IAuth} from "chronicle-std/auth/IAuth.sol"; -import {IToll} from "chronicle-std/toll/IToll.sol"; - -import {IGreenhouse} from "greenhouse/IGreenhouse.sol"; - -import {IAggor} from "src/IAggor.sol"; -import {Aggor_COUNTER as Aggor} from "src/Aggor.sol"; -// @todo ^^^^^^^ Adjust name of Aggor instance. - -/** - * @notice Aggor Management Script - */ -contract AggorScript is Script { - /// @dev Deploys a new Aggor instance via Greenhouse instance `greenhouse` - /// and salt `salt` with `initialAuthed` being the address initially - /// authed. - /// - /// The other arguments are Aggor's additional constructor arguments. - function deploy( - address greenhouse, - bytes32 salt, - address initialAuthed, - address chronicle, - address chainlink, - address uniPool, - bool uniUseToken0AsBase - ) public { - // Create creation code with constructor arguments. - bytes memory creationCode = abi.encodePacked( - type(Aggor).creationCode, - abi.encode( - initialAuthed, chronicle, chainlink, uniPool, uniUseToken0AsBase - ) - ); - - // Ensure salt not yet used. - address deployed = IGreenhouse(greenhouse).addressOf(salt); - require(deployed.code.length == 0, "Salt already used"); - - vm.startBroadcast(); - IGreenhouse(greenhouse).plant(salt, creationCode); - vm.stopBroadcast(); - - console2.log("Deployed at", deployed); - } - - // -- IAggor Functions -- - - /// @dev Pokes Aggor. - function poke(address self) public { - vm.startBroadcast(); - IAggor(self).poke(); - vm.stopBroadcast(); - - console2.log("Poked"); - } - - /// @dev Sets staleness threshold to `stalenessThreshold`. - function setStalenessThreshold(address self, uint32 stalenessThreshold) - public - { - vm.startBroadcast(); - IAggor(self).setStalenessThreshold(stalenessThreshold); - vm.stopBroadcast(); - - console2.log("Staleness Threshold set to", stalenessThreshold); - } - - /// @dev Sets spread to `spread`. - function setSpread(address self, uint16 spread) public { - vm.startBroadcast(); - IAggor(self).setSpread(spread); - vm.stopBroadcast(); - - console2.log("Spread set to", spread); - } - - /// @dev Sets whether to use Uniswap's TWAP oracle or not. - function useUniswap(address self, bool select) public { - vm.startBroadcast(); - IAggor(self).useUniswap(select); - vm.stopBroadcast(); - - console2.log("Use Uniswap set to", select); - } - - /// @dev Sets Uniswap TWAP's oracle lookback time in seconds. - function setUniSecondsAgo(address self, uint32 uniSecondsAgo) public { - vm.startBroadcast(); - IAggor(self).setUniSecondsAgo(uniSecondsAgo); - vm.stopBroadcast(); - - console2.log("Uniswap Seconds Ago set to", uniSecondsAgo); - } - - // -- IAuth Functions -- - - /// @dev Grants auth to address `who`. - function rely(address self, address who) public { - vm.startBroadcast(); - IAuth(self).rely(who); - vm.stopBroadcast(); - - console2.log("Relied", who); - } - - /// @dev Renounces auth from address `who`. - function deny(address self, address who) public { - vm.startBroadcast(); - IAuth(self).deny(who); - vm.stopBroadcast(); - - console2.log("Denied", who); - } - - // -- IToll Functions -- - - /// @dev Grants toll to address `who`. - function kiss(address self, address who) public { - vm.startBroadcast(); - IToll(self).kiss(who); - vm.stopBroadcast(); - - console2.log("Kissed", who); - } - - /// @dev Renounces toll from address `who`. - function diss(address self, address who) public { - vm.startBroadcast(); - IToll(self).diss(who); - vm.stopBroadcast(); - - console2.log("Dissed", who); - } -} diff --git a/src/Aggor.sol b/src/Aggor.sol index 4520957..87c0e17 100644 --- a/src/Aggor.sol +++ b/src/Aggor.sol @@ -28,208 +28,169 @@ contract Aggor is IAggor, Auth, Toll { /// @dev Percentage scale is in basis points (BPS). uint16 internal constant _pscale = 10_000; - /// @inheritdoc IAggor - uint32 public constant minUniSecondsAgo = 5 minutes; - /// @inheritdoc IAggor uint8 public constant decimals = 18; - /// @inheritdoc IChronicle - bytes32 public immutable wat; - - /// @inheritdoc IAggor - address public immutable chronicle; - - /// @inheritdoc IAggor - address public immutable chainlink; - /// @inheritdoc IAggor - address public immutable uniPool; + uint public agreementDistance; /// @inheritdoc IAggor - address public immutable uniBasePair; + bool public isPeggedAsset; - /// @inheritdoc IAggor - address public immutable uniQuotePair; - - /// @inheritdoc IAggor - uint8 public immutable uniBaseDec; + /// @notice The set of Oracle from which we will query price. + address[] private _oracles; /// @inheritdoc IAggor - uint8 public immutable uniQuoteDec; + address public twap; /// @inheritdoc IAggor - uint32 public uniSecondsAgo; + uint public acceptableAgeThreshold; - /// @inheritdoc IAggor - uint32 public stalenessThreshold; + /// @inheritdoc IChronicle + bytes32 public immutable wat; - /// @inheritdoc IAggor - uint16 public spread; + struct PriceData { + uint val; + uint age; + } - /// @inheritdoc IAggor - bool public uniswapSelected; - - // This is the last agreed upon mean price. - uint128 private _val; - uint32 private _age; - - /// @notice You only get once chance per deploy to setup Uniswap. If it - /// will not be used, just pass in address(0) for uniPool_. - /// @param initialAuthed Address to be initially auth'ed - /// @param chronicle_ Address of Chronicle oracle - /// @param chainlink_ Address of Chainlink oracle - /// @param uniPool_ Address of Uniswap oracle (optional) - /// @param uniUseToken0AsBase If true, selects Pool.token0 as base pair, if not, - // it uses Pool.token1 as the base pair. constructor( address initialAuthed, - address chronicle_, - address chainlink_, - address uniPool_, - bool uniUseToken0AsBase + bytes32 wat_, + address[] memory oracles_, + address twap_, + uint acceptableAgeThreshold_, + bool isPeggedAsset_ ) Auth(initialAuthed) { - require(chronicle_ != address(0)); - require(chainlink_ != address(0)); - - chronicle = chronicle_; - chainlink = chainlink_; - - // Note that IChronicle::wat() is constant and save to cache. - wat = IChronicle(chronicle_).wat(); - - // Optionally initialize Uniswap. - address uniPoolInitializer; - address uniBasePairInitializer; - address uniQuotePairInitializer; - uint8 uniBaseDecInitializer; - uint8 uniQuoteDecInitializer; - - if (uniPool_ != address(0)) { - uniPoolInitializer = uniPool_; - - if (uniUseToken0AsBase) { - uniBasePairInitializer = - IUniswapV3PoolImmutables(uniPoolInitializer).token0(); - uniQuotePairInitializer = - IUniswapV3PoolImmutables(uniPoolInitializer).token1(); - } else { - uniBasePairInitializer = - IUniswapV3PoolImmutables(uniPoolInitializer).token1(); - uniQuotePairInitializer = - IUniswapV3PoolImmutables(uniPoolInitializer).token0(); - } + _setOracles(oracles_); - uniBaseDecInitializer = IERC20(uniBasePairInitializer).decimals(); - uniQuoteDecInitializer = IERC20(uniQuotePairInitializer).decimals(); - } + wat = wat_; + twap = twap_; + isPeggedAsset = isPeggedAsset_; + acceptableAgeThreshold = acceptableAgeThreshold_; + } - uniPool = uniPoolInitializer; - uniBasePair = uniBasePairInitializer; - uniQuotePair = uniQuotePairInitializer; - uniBaseDec = uniBaseDecInitializer; - uniQuoteDec = uniQuoteDecInitializer; + // -- Read Functionality -- - // Default config values - _setStalenessThreshold(1 days); - _setSpread(500); // 5% + function _read() internal view returns (uint, uint, StatusInfo memory) { + // Instrospect and track status of this workflow + StatusInfo memory status; - if (uniPool != address(0)) { - _setUniSecondsAgo(5 minutes); - } - } + /// @dev In-memory arrays don't have push() so instantiate with enough slots + /// for all prices. Then we will _shorten() to get "pushed" prices. You + /// MUST increment goodPricesTotal whenever goodPrices is assigned a price. + PriceData[] memory goodPrices = new PriceData[](_oracles.length + 1); + uint goodPricesTotal; - /// @inheritdoc IAggor - function poke() external { - _poke(); - } + bool ok; + uint val; + uint age; - /// @dev Optimized function selector: 0x00000000. - /// Note that this function is _not_ defined via the IAggor interface - /// and one should _not_ depend on it. - function poke_optimized_3923566589() external { - _poke(); - } + for (uint i = 0; i < _oracles.length; i++) { + (ok, val, age) = IChronicle(_oracles[i]).tryReadWithAge(); + if (ok + && val != 0 + && age <= block.timestamp // Can't be from the future + && (block.timestamp - age) <= acceptableAgeThreshold + ) { + goodPrices[goodPricesTotal++] = PriceData(val, age); + } else { + status.countFailedOraclePrices++; + } + } - function _poke() internal { - bool ok; + status.countGoodOraclePrices = goodPricesTotal; - // Read chronicle. - uint valChronicle; - (ok, valChronicle) = _tryReadChronicle(); - if (!ok) { - revert OracleReadFailed(chronicle); + // Preferred scenario, will fall through to less desirable ones below. + if (goodPricesTotal >= 3) { + PriceData memory price = _median(_shorten(goodPrices, goodPricesTotal)); + status.returnLevel = 1; + return (price.val, price.age, status); } - // assert(valChronicle != 0); - // assert(valChronicle <= type(uint128).max); - - // Read second oracle, either Chainlink or Uniswap TWAP. - uint valOther; - if (!uniswapSelected) { - // Read Chainlink. - (ok, valOther) = _tryReadChainlink(); - if (!ok) { - revert OracleReadFailed(chainlink); - } - } else { - // assert(uniPool != address(0)); - // Read Uniswap. - (ok, valOther) = _tryReadUniswap(); - if (!ok) { - revert OracleReadFailed(uniPool); + if (goodPricesTotal == 2) { // Try to return mean of Oracles: + // Prices from the Oracles MUST be within the agreement distance (%) + if (LibCalc.pctDiff(uint128(goodPrices[0].val), uint128(goodPrices[1].val), _pscale) <= agreementDistance) { + status.returnLevel = 2; + return ( + (goodPrices[0].val + goodPrices[1].val) / 2, + goodPrices[0].age, + status + ); + } else { // Otherwise, use alternate methods to obtain median: + status.returnLevel = 3; + if (isPeggedAsset) { + // NOTE(jamesr) Aggor treats all price values as having 18 + // decimals, at least until necessary to scale, e.g. the + // return from latestAnswer(). So the notion of "inserting + // 1 into the price set for median" really means inserting + // 1 ether. + goodPrices[goodPricesTotal++] = PriceData(1 ether, block.timestamp); + PriceData memory price = _median(_shorten(goodPrices, goodPricesTotal)); + return (price.val, price.age, status); + } + if (twap != address(0)) { + (ok, val, age) = IChronicle(twap).tryReadWithAge(); + if (ok) { + goodPrices[goodPricesTotal++] = PriceData(val, age); + status.twapUsed = true; + PriceData memory price = _median(_shorten(goodPrices, goodPricesTotal)); + return (price.val, price.age, status); + } + } } } - // assert(valOther != 0); - // assert(valOther <= type(uint128).max); - - // Compute difference of oracle values. - uint diff = - LibCalc.pctDiff(uint128(valChronicle), uint128(valOther), _pscale); - - if (diff > spread) { - // If difference is bigger than acceptable spread, let _val be the - // oracle's value with less difference to the current _val. - // forgefmt: disable-next-item - _val = LibCalc.distance(_val, valChronicle) < LibCalc.distance(_val, valOther) - ? uint128(valChronicle) - : uint128(valOther); - } else { - // If difference is within acceptable spread, let _val be the mean - // of the oracles' values. - // Note that unsafe computation is fine because both arguments are - // less than or equal to type(uint128).max. - _val = uint128(LibCalc.unsafeMean(valChronicle, valOther)); + + // If only one oracle with good data, return that + if (goodPricesTotal == 1) { + status.returnLevel = 4; + return (goodPrices[0].val, goodPrices[0].age, status); } - // assert(_val <= type(uint128).max); - // Update _val's age to current timestamp. - _age = uint32(block.timestamp); - } + // Last attempt to get price, return TWAP if possible. + if (twap != address(0)) { + (ok, val, age) = IChronicle(twap).tryReadWithAge(); + if (ok + && age <= block.timestamp // Can't be from the future + && (block.timestamp - age) <= acceptableAgeThreshold) + { + status.twapUsed = true; + status.returnLevel = 5; + return (val, age, status); + } + } - // -- Read Functionality -- + // Finally, no price could be obtained. The defi world has ended, + // probably in fire. + status.returnLevel = 6; + return (0, 0, status); + } // -- IChronicle /// @inheritdoc IChronicle function read() external view toll returns (uint) { + (uint _val,,) = _read(); require(_val != 0); return _val; } /// @inheritdoc IChronicle function tryRead() external view toll returns (bool, uint) { + (uint _val,,) = _read(); return (_val != 0, _val); } /// @inheritdoc IChronicle function readWithAge() external view toll returns (uint, uint) { + (uint _val, uint _age,) = _read(); require(_val != 0); return (_val, _age); } /// @inheritdoc IChronicle function tryReadWithAge() external view toll returns (bool, uint, uint) { + (uint _val, uint _age,) = _read(); return (_val != 0, _val, _age); } @@ -250,8 +211,8 @@ contract Aggor is IAggor, Auth, Toll { ) { roundId = 1; - answer = _toInt(_val); - // assert(uint(answer) == uint(_val)); + (uint _val, uint _age,) = _read(); + answer = _toInt(uint128(_val)); startedAt = 0; updatedAt = _age; answeredInRound = roundId; @@ -259,159 +220,116 @@ contract Aggor is IAggor, Auth, Toll { /// @inheritdoc IAggor function latestAnswer() external view toll returns (int) { - return _toInt(_val); + (uint val,,) = _read(); + return _toInt(uint128(LibCalc.scale(val, decimals, 8))); + } + + // -- IAggor + + /// @inheritdoc IAggor + function readWithStatus() external view toll returns (uint, uint, StatusInfo memory) { + return _read(); } // -- Auth'ed Functionality -- /// @inheritdoc IAggor - function setStalenessThreshold(uint32 stalenessThreshold_) external auth { - _setStalenessThreshold(stalenessThreshold_); + function setAgreementDistance(uint agreementDistance_) external auth { + _setAgreementDistance(agreementDistance_); } - function _setStalenessThreshold(uint32 stalenessThreshold_) internal { - require(stalenessThreshold_ != 0); + function _setAgreementDistance(uint agreementDistance_) internal { + require(agreementDistance_ != 0); - if (stalenessThreshold != stalenessThreshold_) { - emit StalenessThresholdUpdated( - msg.sender, stalenessThreshold, stalenessThreshold_ + if (agreementDistance != agreementDistance_) { + emit AgreementDistanceUpdated( + msg.sender, agreementDistance, agreementDistance_ ); - stalenessThreshold = stalenessThreshold_; + agreementDistance = agreementDistance_; } } - /// @inheritdoc IAggor - function setSpread(uint16 spread_) external auth { - _setSpread(spread_); + function setAcceptableAgeThreshold(uint acceptableAgeThreshold_) external auth { + _setAcceptableAgeThreshold(acceptableAgeThreshold_); } - function _setSpread(uint16 spread_) internal { - require(spread_ <= _pscale); + function _setAcceptableAgeThreshold(uint acceptableAgeThreshold_) internal auth { + require(acceptableAgeThreshold_ != 0); - if (spread != spread_) { - emit SpreadUpdated(msg.sender, spread, spread_); - spread = spread_; + if (acceptableAgeThreshold != acceptableAgeThreshold_) { + emit AcceptableAgeThresholdUpdated( + msg.sender, acceptableAgeThreshold, acceptableAgeThreshold_ + ); + acceptableAgeThreshold = acceptableAgeThreshold_; } } /// @inheritdoc IAggor - function useUniswap(bool selected) external auth { - // Uniswap pool must be configured - require(uniPool != address(0)); - - // Revert unless there is something to change - require(uniswapSelected != selected); - - emit UniswapSelectedUpdated({ - caller: msg.sender, - oldValue: uniswapSelected, - newValue: selected - }); + function setOracles(address[] memory oracles_) external auth { + _setOracles(oracles_); + } - uniswapSelected = selected; + function _setOracles(address[] memory oracles_) internal { + _oracles = new address[](oracles_.length); + for (uint i = 0; i < oracles_.length; i++) { + require(oracles_[i] != address(0)); + _oracles[i] = oracles_[i]; + } } /// @inheritdoc IAggor - function setUniSecondsAgo(uint32 uniSecondsAgo_) external auth { - _setUniSecondsAgo(uniSecondsAgo_); + function setTwap(address twap_) external auth { + twap = twap_; } - function _setUniSecondsAgo(uint32 uniSecondsAgo_) internal { - // Uniswap is optional, make sure it's configured - require(uniPool != address(0)); - require(uniSecondsAgo_ >= minUniSecondsAgo); - - if (uniSecondsAgo != uniSecondsAgo_) { - emit UniswapSecondsAgoUpdated( - msg.sender, uniSecondsAgo, uniSecondsAgo_ - ); - uniSecondsAgo = uniSecondsAgo_; - } - - // Ensure that the pool works within the desired "lookback" period. - (bool ok,) = _tryReadUniswap(); - require(ok); + /// @inheritdoc IAggor + function oracles() external view returns(address[] memory) { + return _oracles; } // -- Private Helpers -- - - function _tryReadUniswap() internal view returns (bool, uint) { - // assert(uniPool != address(0)); - - uint val = LibUniswapOracles.readOracle( - uniPool, uniBasePair, uniQuotePair, uniBaseDec, uniSecondsAgo - ); - - // We always scale to 'decimals', up OR down. - if (uniQuoteDec != decimals) { - val = LibCalc.scale(val, uniQuoteDec, decimals); - } - - // Fail if value is zero. - if (val == 0) { - return (false, 0); - } - - // Also fail if could cause overflow. - if (val > type(uint128).max) { - return (false, 0); - } - - return (true, val); + function _toInt(uint128 val) private pure returns (int) { + // Note that int(type(uint128).max) == type(uint128).max. + return int(uint(val)); } - function _tryReadChronicle() internal view returns (bool, uint) { - bool ok; - uint val; - uint age; - (ok, val, age) = IChronicle(chronicle).tryReadWithAge(); - // assert(!ok || val != 0); - - // Fail if value stale. - uint diff = block.timestamp - age; - if (diff > stalenessThreshold) { - return (false, 0); + function _shorten(PriceData[] memory a, uint len) internal pure returns (PriceData[] memory) { + if (len >= a.length) return a; + PriceData[] memory b = new PriceData[](len); + for (uint i = 0; i < len; i++) { + b[i] = a[i]; } - - return (ok, val); + return b; } - function _tryReadChainlink() internal view returns (bool, uint) { - int answer; - uint updatedAt; - (, answer,, updatedAt,) = - IChainlinkAggregatorV3(chainlink).latestRoundData(); - - // Fail if value stale. - uint diff = block.timestamp - updatedAt; - if (diff > stalenessThreshold) { - return (false, 0); - } - - // Fail if value negative. - if (answer < 0) { - return (false, 0); - } - - // Adjust decimals, if necessary. - uint val = uint(answer); - uint decimals_ = IChainlinkAggregatorV3(chainlink).decimals(); - if (decimals_ != decimals) { - val = LibCalc.scale(val, decimals_, decimals); - } - - // Fail if value is zero. - if (val == 0) { - return (false, 0); + function _median(PriceData[] memory price) private view returns (PriceData memory) { + PriceData[] memory res = _quickSort(price, int(0), int(price.length - 1)); + if (res.length % 2 == 0) { + uint a = res[(res.length/2)-1].val; + uint b = res[(res.length/2)].val; + return PriceData({val: (a+b)/2, age: res[res.length/2-1].age}); + } else { + return res[res.length/2]; } - - // Otherwise value is ok. - return (true, val); } - function _toInt(uint128 val) private pure returns (int) { - // Note that int(type(uint128).max) == type(uint128).max. - return int(uint(val)); + function _quickSort(PriceData[] memory price, int left, int right) private view returns (PriceData[] memory) { + int i = left; + int j = right; + if (i == j) return price; + uint pivot = price[uint(left + (right - left) / 2)].val; + while (i <= j) { + while (price[uint(i)].val < pivot) i++; + while (pivot < price[uint(j)].val) j--; + if (i <= j) { + (price[uint(i)], price[uint(j)]) = (price[uint(j)], price[uint(i)]); + i++; + j--; + } + } + if (left < j) _quickSort(price, left, j); + if (i < right) _quickSort(price, i, right); + return price; } // -- Overridden Toll Functions -- @@ -429,11 +347,17 @@ contract Aggor_COUNTER is Aggor { // @todo ^^^^^^^ Adjust name of Aggor instance constructor( address initialAuthed, - address chronicle_, - address chainlink_, - address uniPool_, - bool uniUseToken0AsBase - ) - Aggor(initialAuthed, chronicle_, chainlink_, uniPool_, uniUseToken0AsBase) - {} + bytes32 wat_, + address[] memory oracles_, + address twap_, + uint acceptableAgeThreshold_, + bool isPeggedAsset_ + ) Aggor( + initialAuthed, + wat_, + oracles_, + twap_, + acceptableAgeThreshold_, + isPeggedAsset_ + ){} } diff --git a/src/IAggor.sol b/src/IAggor.sol index 52719c8..8691740 100644 --- a/src/IAggor.sol +++ b/src/IAggor.sol @@ -4,99 +4,52 @@ pragma solidity ^0.8.16; import {IChronicle} from "chronicle-std/IChronicle.sol"; interface IAggor is IChronicle { - /// @notice Thrown if an oracle read fails. - /// @param oracle The oracle address which read's failed. - error OracleReadFailed(address oracle); - - /// @notice Emitted when staleness threshold updated. - /// @param caller The caller's address. - /// @param oldStalenessThreshold The old staleness threshold. - /// @param newStalenessThreshold The new staleness threshold. - event StalenessThresholdUpdated( + /// @notice Returns the number of decimals of the oracle's value. + /// @return decimals The oracle value's number of decimals. + function decimals() external view returns (uint8); + + /// @notice Returns the agreement distance (%) used to determine if Oracle + /// prices are "in agreement". + /// @return agreementDistance The agreement distance. + function agreementDistance() external view returns (uint); + + /// @notice If true, the contract pair (wat) is a 1:1 pegged asset. + /// @return isPeggedAsset Whether the asset pair is pegged or not. + function isPeggedAsset() external view returns (bool); + + /// @notice Returns the set of addresses that constitute external Oracles. + /// @return oracles The set of Oracles used to obtain price. + function oracles() external view returns (address[] memory); + + /// @notice The address of the tie-breaking TWAP instance. + /// @return twap Address of TWAP interface contract. + function twap() external view returns (address); + + /// @notice The acceptable age of price that will be allowed. + /// @return acceptableAgeThreshold The time in seconds where a price is + // considered "fresh". + function acceptableAgeThreshold() external view returns (uint); + + /// @notice Emitted when the agreement distance is changed. + /// @param caller The caller's address + /// @param oldAgreementDistance Current agreement distance + /// @param newAgreementDistance Updated agreement distance + event AgreementDistanceUpdated( address indexed caller, - uint32 oldStalenessThreshold, - uint32 newStalenessThreshold - ); - - /// @notice Emitted when Uniswap is selected or deselected - /// @param caller The caller's address. - /// @param oldValue The previous value. - /// @param newValue The updated value. - event UniswapSelectedUpdated( - address indexed caller, bool oldValue, bool newValue - ); - - /// @notice Emitted when spread is updated. - /// @param caller The caller's address. - /// @param oldSpread The old spread value. - /// @param newSpread The new spread value. - event SpreadUpdated( - address indexed caller, uint16 oldSpread, uint16 newSpread + uint oldAgreementDistance, + uint newAgreementDistance ); - /// @notice Emitted when Uniswap TWAP's lookback period is updated. - /// @param caller The caller's address. - /// @param oldUniswapSecondsAgo The old uniswapSecondsAgo value. - /// @param newUniswapSecondsAgo The new uniswapSecondsAgo value. - event UniswapSecondsAgoUpdated( + /// @notice Emitted when the acceptable age for price is changed. + /// @param caller The caller's address + /// @param oldAcceptableAgeThreshold Current acceptable age + /// @param newAcceptableAgeThreshold Updated acceptable age + event AcceptableAgeThresholdUpdated( address indexed caller, - uint32 oldUniswapSecondsAgo, - uint32 newUniswapSecondsAgo + uint oldAcceptableAgeThreshold, + uint newAcceptableAgeThreshold ); - /// @notice Emitted when Chainlink's oracle delivered a zero value. - event ChainlinkValueZero(); - - /// @notice The Chronicle oracle to aggregate. - /// @return The address of the Chronicle oracle being aggregated. - function chronicle() external view returns (address); - - /// @notice The Chainlink oracle to aggregate. - /// @return The address of the Chainlink oracle being aggregated. - function chainlink() external view returns (address); - - /// @notice The Uniswap pool that wil be observed. - function uniPool() external view returns (address); - - /// @notice The base pair for the pool, e.g. WETH in WETHUSDT. - function uniBasePair() external view returns (address); - - /// @notice The quote pair for the pool, e.g. USDT in WETHUSDT. - function uniQuotePair() external view returns (address); - - /// @notice The decimals of the base pair ERC-20 token. - function uniBaseDec() external view returns (uint8); - - /// @notice The decimals of the quote pair ERC-20 token. - function uniQuoteDec() external view returns (uint8); - - /// @notice The time in seconds to "look back" per TWAP. - function uniSecondsAgo() external view returns (uint32); - - /// @notice Determines which secondary oracle is selected. If false - // (default), use Chainlink. - function uniswapSelected() external view returns (bool); - - /// @notice The minimum allowed lookback period for the Uniswap TWAP. - /// @dev Value is constant and save to cache. - /// @return The minimum allowed value for uniSecondsAgo. - function minUniSecondsAgo() external view returns (uint32); - - /// @notice Pokes aggor, i.e. updates aggor's value to the mean of - /// Chronicle's and Chainlink's current values. - /// @dev Reverts if an oracle's value cannot be read. - /// @dev Reverts if an oracle's value is zero. - /// @dev Reverts if Chainlink's oracle value is negative. - /// @dev Reverts if Chainlink's oracle value is stale as being defined via - /// staleness threshold. - function poke() external; - - /// @notice Returns the number of decimals of the oracle's value. - /// @dev Provides partial compatibility with Chainlink's - /// IAggregatorV3Interface. - /// @return decimals The oracle value's number of decimals. - function decimals() external view returns (uint8 decimals); - /// @notice Returns the oracle's latest value. /// @dev Provides partial compatibility to Chainlink's /// IAggregatorV3Interface. @@ -121,44 +74,36 @@ interface IAggor is IChronicle { /// @return answer The oracle's latest value. function latestAnswer() external view returns (int); - /// @notice Defines the allowed age of an oracle's value before being - /// declared stale. - /// @return The staleness threshold parameter. - function stalenessThreshold() external view returns (uint32); - - /// @notice Updates the staleness threshold parameter to - /// `stalenessThreshold`. - /// @dev Only callable by auth'ed address. - /// @dev Reverts if `stalenessThreshold` is zero. - /// @param stalenessThreshold The value to update stalenessThreshold to. - function setStalenessThreshold(uint32 stalenessThreshold) external; - - /// @notice The percentage difference between the price gotten from - /// oracles, used as a trigger to detect a potentially - /// compromised oracle. - /// @dev The percent spread (difference in price) we can tolerate between - /// sources. If the difference is over this amount, assume one of the - /// sources is sussy. Defaults to 5%. Acceptable range 0 - 9999 (99.99%). - /// @return The spread as a percentage difference between oracle prices - function spread() external view returns (uint16); - - /// @notice Updates the spread parameter to `spread`. - /// @dev Only callable by auth'ed address. - /// @dev Revert is `spread` is more than 10000. - /// @param spread The value to which to update spread. - function setSpread(uint16 spread) external; - - /// @notice Switch from default oracle (Chainlink) to alt (Uniswap), - /// and back. - /// @dev Only callable by auth'ed address. - /// @param select If true will swap to Uniswap. If false will select - /// Chainlink (default). - function useUniswap(bool select) external; - - /// @notice Set the Uniswap TWAP lookback period. If never called, default - // is 5m. - /// @dev Only callable by auth'ed address. - /// @dev Reverts if uniSecondsAgo less than minUniSecondsAgo. - /// @param uniSecondsAgo Time in seconds used in the TWAP lookback. - function setUniSecondsAgo(uint32 uniSecondsAgo) external; + /// @notice As price is determined during read this struct tracks + /// information about how the price was obtained. + /// @param returnlevel The point along the degradation path at which the + /// price was returned. Lower is better, i.e. 1 is better than 6. + /// @param countGoodOraclePrices The number of Oracles that returned a trustworthy price. + /// @param countFailedOraclePrices The number of Oracles that returned a bad price. + /// @param twapUsed Flag as to whether TWAP had to be used as a tie-breaker. + struct StatusInfo { + uint returnLevel; + uint countGoodOraclePrices; + uint countFailedOraclePrices; + bool twapUsed; + } + + /// @notice Returns the aggregate price along with introspection information. + /// @return val The price obtained. + /// @return age The age of the price. + /// @return status Details of the introspection of the read call. + function readWithStatus() external view returns (uint, uint, StatusInfo memory); + + /// @notice Updates the agreement distance (%). + /// @param agreementDistance The percentage under which Oracle prices must agree. + function setAgreementDistance(uint agreementDistance) external; + + /// @notice Updates the set of Oracles to query. + /// @param oracles The set of oracle addresses to update. Will overwrite + /// existing oracles. + function setOracles(address[] calldata oracles) external; + + /// @notice Sets the TWAP address for the TWAP tie-breaker. + /// @param twap The address of the TWAP wrapper contract. + function setTwap(address twap) external; } diff --git a/src/libs/LibCalc.sol b/src/libs/LibCalc.sol index c0c2f37..b9071ae 100644 --- a/src/libs/LibCalc.sol +++ b/src/libs/LibCalc.sol @@ -42,8 +42,8 @@ library LibCalc { require(dec > 0 && destDec > 0); return destDec > dec - ? n * (10 ** (destDec - dec)) // Scale up - : n / (10 ** (dec - destDec)); // Scale down + ? n * (10 ** (destDec - dec)) // Scale up + : n / (10 ** (dec - destDec)); // Scale down } /// @dev Optimized mean calculation at the expense of safety. Careful! diff --git a/test/AggorTest.t.sol b/test/AggorTest.t.sol new file mode 100644 index 0000000..8b402d3 --- /dev/null +++ b/test/AggorTest.t.sol @@ -0,0 +1,420 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.16; + +import {Test} from "forge-std/Test.sol"; + +import {IAuth} from "chronicle-std/auth/IAuth.sol"; +import {IToll} from "chronicle-std/toll/IToll.sol"; +import {IChronicle} from "chronicle-std/IChronicle.sol"; + +import {MockIChronicle} from "./mocks/MockIChronicle.sol"; +import {LibCalc} from "src/libs/LibCalc.sol"; + +// -- Aggor Tests -- + +import {Aggor} from "src/Aggor.sol"; + +contract AggorTest is Test { + Aggor aggor; + Aggor aggorPegged; + + address[] public oracles; + address public twap; + bytes32 wat = "ETH/USD"; + + function setUp() public { + oracles.push(address(new MockIChronicle())); // I.e. Chronicle + oracles.push(address(new MockIChronicle())); // Chainlink + twap = address(new MockIChronicle()); + + aggor = new Aggor( + address(this), + wat, + oracles, + twap, + 1 hours, + false + ); + + aggorPegged = new Aggor( + address(this), + wat, + oracles, + twap, + 1 hours, + true + ); + + IToll(address(aggor)).kiss(address(this)); + IToll(address(aggorPegged)).kiss(address(this)); + } + + function test_Deployment() public { + // Deployer is auth'ed. + assertTrue(IAuth(address(aggor)).authed(address(this))); + assertTrue(IAuth(address(aggorPegged)).authed(address(this))); + + // Oracles set. + assertTrue(aggor.oracles().length == 2); + assertTrue(aggor.oracles()[0] != address(0)); + assertTrue(aggor.oracles()[1] != address(0)); + + assertFalse(aggor.isPeggedAsset()); + assertTrue(aggorPegged.isPeggedAsset()); + + assertEq(aggor.decimals(), uint8(18)); + + // No value set after deploy. + (bool ok, uint val, uint age) = aggor.tryReadWithAge(); + assertFalse(ok); + assertEq(val, 0); + assertEq(age, 0); + + Aggor.StatusInfo memory status; + (,, status) = aggor.readWithStatus(); + assertEq(status.returnLevel, 6); + } + + function test_setOracles() public { + assertTrue(aggor.oracles().length == 2); + oracles.push(address(new MockIChronicle())); + aggor.setOracles(oracles); + assertTrue(aggor.oracles().length == 3); + assertEq(aggor.oracles()[0], oracles[0]); + assertEq(aggor.oracles()[2], oracles[2]); + } + + function test_setAgreementDistance() public { + assertEq(aggor.agreementDistance(), 0); + aggor.setAgreementDistance(500); // 5% + assertEq(aggor.agreementDistance(), 500); + } + + function test_setAcceptableAgeThreshold() public { + assertEq(aggor.acceptableAgeThreshold(), 1 hours); + aggor.setAcceptableAgeThreshold(5 minutes); + assertEq(aggor.acceptableAgeThreshold(), 5 minutes); + } + + function test_Oracles_3orMore() public { // i.e. Happy path + oracles.push(address(new MockIChronicle())); + + MockIChronicle(oracles[0]).setVal(1 ether); + MockIChronicle(oracles[1]).setVal(uint(1 ether) + 1); + MockIChronicle(oracles[2]).setVal(uint(1 ether) + 2); + + uint ts = block.timestamp; + uint ts1 = ts - 10; + uint ts2 = ts - 20; + MockIChronicle(oracles[0]).setAge(ts1); + MockIChronicle(oracles[1]).setAge(ts2); + MockIChronicle(oracles[2]).setAge(ts); + + aggor.setOracles(oracles); + + bool ok; + uint val; + uint age; + + (ok, val, age) = aggor.tryReadWithAge(); + assertTrue(ok); + assertEq(val, uint(1 ether) + 1); // Median + assertEq(age, ts2); + + Aggor.StatusInfo memory status; + (,, status) = aggor.readWithStatus(); + assertEq(status.returnLevel, 1); + + // Add fourth Oracle + oracles.push(address(new MockIChronicle())); + MockIChronicle(oracles[3]).setVal(uint(1 ether) - 50); + MockIChronicle(oracles[3]).setAge(ts - 99); + aggor.setOracles(oracles); + + (ok, val, age) = aggor.tryReadWithAge(); + assertTrue(ok); + assertEq(val, 1 ether); + assertEq(age, ts1); + + (,, status) = aggor.readWithStatus(); + assertEq(status.returnLevel, 1); + + // Fourth Oracle returns bad age (too old), but we still + // have enough prices for median. Note, this implicitly tests + // acceptableAgeThreshold. + + MockIChronicle(oracles[3]).setVal(uint(1 ether) - 50); + MockIChronicle(oracles[3]).setAge(ts - (ts/2)); + (val, age, status) = aggor.readWithStatus(); + assertEq(val, uint(1 ether) + 1); + assertEq(age, ts2); + assertEq(status.returnLevel, 1); + assertEq(status.countGoodOraclePrices, 3); + } + + function test_Oracles_2PricesAgree() public { + uint agreementDistance = 3333; // 33.33% + aggor.setAgreementDistance(agreementDistance); + + uint price1 = uint(1.2 ether); + uint price2 = uint(1.6 ether); + MockIChronicle(oracles[0]).setVal(price1); + MockIChronicle(oracles[1]).setVal(price2); + MockIChronicle(oracles[0]).setAge(block.timestamp); + MockIChronicle(oracles[1]).setAge(block.timestamp); + + bool ok; + uint val; + (ok, val) = aggor.tryRead(); + + // We expect the price to be the mean of the two good prices + assertEq((price1 + price2) / 2, val); + + Aggor.StatusInfo memory status; + (,, status) = aggor.readWithStatus(); + assertEq(status.returnLevel, 2); + } + + function test_Oracles_2PricesDisagree() public { + aggor.setAgreementDistance(3333); // 33.33% + + uint price1 = uint(1 ether); + uint price2 = uint(1.5 ether); + + MockIChronicle(oracles[0]).setVal(price1); + MockIChronicle(oracles[1]).setVal(price2); + + MockIChronicle(oracles[0]).setAge(block.timestamp); + MockIChronicle(oracles[1]).setAge(block.timestamp); + + MockIChronicle(twap).setAge(block.timestamp); + MockIChronicle(twap).setVal(0.9 ether); + + (bool ok, uint val) = aggor.tryRead(); + assertTrue(ok); + + // Price will be median of Oracles and TWAP + assertEq(val, price1); + + Aggor.StatusInfo memory status; + (,, status) = aggor.readWithStatus(); + assertEq(status.returnLevel, 3); + } + + function test_Oracles_1GoodPrice() public { + oracles.push(address(new MockIChronicle())); + + MockIChronicle(oracles[0]).setVal(0); + MockIChronicle(oracles[1]).setVal(1 ether); + MockIChronicle(oracles[2]).setVal(1 ether); + + MockIChronicle(oracles[0]).setAge(block.timestamp); + MockIChronicle(oracles[1]).setAge(block.timestamp); + MockIChronicle(oracles[2]).setAge(0); // Bad age + + (bool ok, uint val, uint age) = aggor.tryReadWithAge(); + assertTrue(ok); + assertEq(val, 1 ether); + assertEq(age, block.timestamp); + + Aggor.StatusInfo memory status; + (,, status) = aggor.readWithStatus(); + assertEq(status.returnLevel, 4); + } + + function test_TWAPOnly() public { + MockIChronicle(oracles[0]).setVal(0); // Bad price + MockIChronicle(oracles[1]).setVal(1 ether); + + MockIChronicle(oracles[1]).setAge(block.timestamp); + MockIChronicle(oracles[1]).setAge(0); // Bad age + + MockIChronicle(twap).setAge(block.timestamp - 1); + MockIChronicle(twap).setVal(uint(1 ether) - 1); + + (bool ok, uint val, uint age) = aggor.tryReadWithAge(); + assertTrue(ok); + assertEq(val, uint(1 ether) - 1); + assertEq(age, block.timestamp - 1); + + Aggor.StatusInfo memory status; + (,, status) = aggor.readWithStatus(); + assertEq(status.returnLevel, 5); + } + + function test_Oracles_AgeInFuture() public { + oracles.push(address(new MockIChronicle())); + aggor.setOracles(oracles); + aggor.setAgreementDistance(uint(1 ether) + 1); + + MockIChronicle(oracles[0]).setVal(1 ether); + MockIChronicle(oracles[1]).setVal(uint(1 ether) * 2); + MockIChronicle(oracles[2]).setVal(1 ether); + + MockIChronicle(oracles[0]).setAge(block.timestamp - 1); + MockIChronicle(oracles[1]).setAge(block.timestamp); + MockIChronicle(oracles[2]).setAge(block.timestamp + 1000); + + (uint val, + uint age, + Aggor.StatusInfo memory status) = aggor.readWithStatus(); + + assertEq(val, (uint(1 ether) * 3) / 2); + // Note on two Oracles (mean) within the agreement distance, we choose + // the first oracle's age. + assertEq(age, block.timestamp - 1); + + assertEq(status.returnLevel, 2); + assertEq(status.countGoodOraclePrices, 2); + assertEq(status.countFailedOraclePrices, 1); + } + + function test_BigFailure() public { + oracles.push(address(new MockIChronicle())); + oracles.push(address(new MockIChronicle())); + oracles.push(address(new MockIChronicle())); + aggor.setOracles(oracles); + aggor.setAgreementDistance(1001); + + MockIChronicle(oracles[0]).setVal(1 ether); + MockIChronicle(oracles[1]).setVal(0); // Bad price + MockIChronicle(oracles[2]).setVal(0); // Bad price + MockIChronicle(oracles[3]).setVal(1 ether); + MockIChronicle(oracles[4]).setVal(1 ether); + + MockIChronicle(oracles[0]).setAge(0); // Stale + MockIChronicle(oracles[1]).setAge(block.timestamp); + MockIChronicle(oracles[2]).setAge(block.timestamp); + MockIChronicle(oracles[3]).setAge(block.timestamp + 1000); // Future + MockIChronicle(oracles[4]).setAge(block.timestamp + 1); // Future + + MockIChronicle(twap).setAge(0); + MockIChronicle(twap).setVal(1 ether); + + (uint val, + uint age, + Aggor.StatusInfo memory status) = aggor.readWithStatus(); + + assertEq(val, 0); + assertEq(age, 0); + + assertEq(status.returnLevel, 6); + assertEq(status.countFailedOraclePrices, 5); + assertFalse(status.twapUsed); + } + + function test_AllReads() public { + oracles.push(address(new MockIChronicle())); + aggor.setOracles(oracles); + + MockIChronicle(oracles[0]).setVal(1 ether); + MockIChronicle(oracles[1]).setVal(2 ether); + MockIChronicle(oracles[2]).setVal(3 ether); + MockIChronicle(oracles[0]).setAge(block.timestamp); + MockIChronicle(oracles[1]).setAge(block.timestamp); + MockIChronicle(oracles[2]).setAge(block.timestamp); + + uint val; + uint valCmp; + + // Success + + val = aggor.read(); + assertEq(val, 2 ether); + + (,valCmp) = aggor.tryRead(); + assertEq(val, valCmp); + + (valCmp,) = aggor.readWithAge(); + assertEq(val, valCmp); + + (,valCmp,) = aggor.tryReadWithAge(); + assertEq(val, valCmp); + + (valCmp,,) = aggor.readWithStatus(); + assertEq(val, valCmp); + + int answer; + (,answer,,,) = aggor.latestRoundData(); + assertEq(val, uint(answer)); + + answer = aggor.latestAnswer(); + assertEq(LibCalc.scale(val, aggor.decimals(), 8), uint(answer)); + + // Failure + + delete oracles; + oracles.push(address(new MockIChronicle())); + aggor.setOracles(oracles); + + (val,,) = aggor.readWithStatus(); + assertEq(val, 0); + + vm.expectRevert(); + val = aggor.read(); + + (,valCmp) = aggor.tryRead(); + assertEq(val, valCmp); + + vm.expectRevert(); + (valCmp,) = aggor.readWithAge(); + + (,valCmp,) = aggor.tryReadWithAge(); + assertEq(val, valCmp); + + (,answer,,,) = aggor.latestRoundData(); + assertEq(val, uint(answer)); + + answer = aggor.latestAnswer(); + assertEq(LibCalc.scale(val, aggor.decimals(), 8), uint(answer)); + } + + function test_Pegged() public { + oracles.push(address(new MockIChronicle())); + aggorPegged.setOracles(oracles); + + aggorPegged.setAgreementDistance(500); // 5% + + // Prices above 1 + uint price1 = uint(1.05 ether); + uint price2 = uint(1.15 ether); + + MockIChronicle(oracles[0]).setVal(price1); + MockIChronicle(oracles[1]).setVal(0); + MockIChronicle(oracles[2]).setVal(price2); + MockIChronicle(oracles[0]).setAge(block.timestamp); + MockIChronicle(oracles[1]).setAge(block.timestamp); + MockIChronicle(oracles[2]).setAge(block.timestamp); + + uint val; + + val = aggorPegged.read(); + assertEq(val, price1); + + // Prices below 1 + price1 = uint(0.95 ether); + price2 = uint(0.85 ether); + MockIChronicle(oracles[0]).setVal(0); + MockIChronicle(oracles[1]).setVal(price1); + MockIChronicle(oracles[2]).setVal(price2); + + val = aggorPegged.read(); + assertEq(val, price1); + + // Prices disagree either side of 1 + price1 = uint(0.95 ether); + price2 = uint(1.05 ether); + MockIChronicle(oracles[0]).setVal(price2); + MockIChronicle(oracles[1]).setVal(price1); + MockIChronicle(oracles[2]).setVal(0); + + val = aggorPegged.read(); + assertEq(val, 1 ether); + } +} + +// -- Library Tests -- + +import {LibCalcTest as LibCalcTest_} from "./LibCalcTest.sol"; + +contract LibCalcTest is LibCalcTest_ {} diff --git a/test/IAggorTest.sol b/test/IAggorTest.sol deleted file mode 100644 index 48ec152..0000000 --- a/test/IAggorTest.sol +++ /dev/null @@ -1,540 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.16; - -import {Test} from "forge-std/Test.sol"; - -import {IAuth} from "chronicle-std/auth/IAuth.sol"; -import {IToll} from "chronicle-std/toll/IToll.sol"; - -import {LibCalc} from "src/libs/LibCalc.sol"; -import {IAggor} from "src/IAggor.sol"; -import {Aggor} from "src/Aggor.sol"; - -import {MockIChronicle} from "./mocks/MockIChronicle.sol"; -import {MockIChainlinkAggregatorV3} from - "./mocks/MockIChainlinkAggregatorV3.sol"; -import {MockIERC20} from "./mocks/MockIERC20.sol"; - -abstract contract IAggorTest is Test { - IAggor aggor; - - MockIChronicle chronicle; - MockIChainlinkAggregatorV3 chainlink; - - /// @dev Must match the value in Aggor.sol - uint16 internal constant _pscale = 10_000; - - // Copied from IAggor. - event UniswapUpdated( - address indexed caller, address oldUniswapPool, address newUniswapPool - ); - event StalenessThresholdUpdated( - address indexed caller, - uint32 oldStalenessThreshold, - uint32 newStalenessThreshold - ); - event SpreadUpdated( - address indexed caller, uint16 oldSpread, uint16 newSpread - ); - event UniswapSecondsAgoUpdated( - address indexed caller, - uint32 oldUniswapSecondsAgo, - uint32 newUniswapSecondsAgo - ); - - function setUp(IAggor aggor_) internal { - aggor = aggor_; - - chronicle = MockIChronicle(aggor.chronicle()); - chainlink = MockIChainlinkAggregatorV3(aggor.chainlink()); - - // Toll address(this). - IToll(address(aggor)).kiss(address(this)); - } - - function test_Deployment() public { - // Deployer is auth'ed. - assertTrue(IAuth(address(aggor)).authed(address(this))); - - // Oracles set. - assertTrue(address(aggor.chronicle()) != address(0)); - assertTrue(address(aggor.chainlink()) != address(0)); - - // StalenessThreshold set. - assertTrue(aggor.stalenessThreshold() != 0); - - // Spread set. - assertTrue(aggor.spread() != 0); - - // UniSecondsAgo set. - assertTrue(aggor.uniSecondsAgo() != 0); - - // IChainlinkAggregatorV3::decimals() set. - assertEq(aggor.decimals(), uint8(18)); - - // No value set. - bool ok; - uint val; - (ok, val) = aggor.tryRead(); - assertFalse(ok); - assertEq(val, 0); - } - - // -- Poke -- - - function _checkReadFunctions(uint128 wantVal, uint wantAge) private { - // -- IChronicle - bool ok; - uint gotVal; - uint gotAge; - - // IChronicle::read - gotVal = aggor.read(); - assertEq(gotVal, wantVal); - - // IChronicle::tryRead - (ok, gotVal) = aggor.tryRead(); - assertTrue(ok); - assertEq(gotVal, wantVal); - - // IChronicle::readWithAge - (gotVal, gotAge) = aggor.readWithAge(); - assertEq(gotVal, wantVal); - assertEq(gotAge, wantAge); - - // IChronicle::tryReadWithAge - (ok, gotVal, gotAge) = aggor.tryReadWithAge(); - assertTrue(ok); - assertEq(gotVal, wantVal); - assertEq(gotAge, wantAge); - - // -- IChainlink - uint80 roundId; - int answer; - uint startedAt; - uint updatedAt; - uint80 answeredInRound; - - // IChainlinkAggregatorV3::latestRoundData - (roundId, answer, startedAt, updatedAt, answeredInRound) = - aggor.latestRoundData(); - assertEq(roundId, 1); - assertTrue(answer > 0); - assertEq(uint128(uint(answer)), wantVal); - assertEq(startedAt, 0); - assertEq(updatedAt, wantAge); - assertEq(answeredInRound, 1); - - // IChainlinkAggregatorV3::latestAnswer - answer = aggor.latestAnswer(); - assertTrue(answer > 0); - assertEq(uint128(uint(answer)), wantVal); - } - - function testFuzz_poke_basic( - uint128 chronicleVal, - uint128 chainlinkVal, - uint chainlinkAgeSeed, - uint chronicleAgeSeed, - uint warp - ) public { - vm.assume(chronicleVal != 0); - vm.assume(chainlinkVal != 0); - vm.assume(warp < 100 days); // Make sure to not overflow timestamp. - - // Make sure chainlink's age is not stale. - uint32 chainlinkAge = uint32( - bound( - chainlinkAgeSeed, - block.timestamp - aggor.stalenessThreshold(), - block.timestamp - ) - ); - - // Make sure chronicle'a age is not stale. - uint32 chronicleAge = uint32( - bound( - chronicleAgeSeed, - block.timestamp - aggor.stalenessThreshold(), - block.timestamp - ) - ); - - uint32 age = uint32(block.timestamp); - age++; // Note to use variable before warp as --via-ir optimization may - age--; // optimize it away. solc doesn't know about vm.warp(). - - chronicle.setVal(chronicleVal); - chronicle.setAge(chronicleAge); - - chainlink.setAnswer(int(uint(chainlinkVal))); - chainlink.setUpdatedAt(chainlinkAge); - - aggor.poke(); - - // Wait for some time. - vm.warp(block.timestamp + warp); - - // Read aggor's value. - bool ok; - uint cur; - (ok, cur) = aggor.tryRead(); - assertTrue(ok); - assertNotEq(cur, 0); - - // Compute expected value. - uint wantVal; - - uint diff = LibCalc.pctDiff(chainlinkVal, chronicleVal, _pscale); - if (diff != 0 && diff > aggor.spread()) { - // If difference of values is bigger than acceptable spread, the - // expected value is the oracle's value with less difference to - // aggor's previous value.j - uint previousVal = 0; - - wantVal = LibCalc.distance(previousVal, chronicleVal) - < LibCalc.distance(previousVal, chainlinkVal) - ? chronicleVal - : chainlinkVal; - } else { - // If difference of values is less then acceptable spread, the - // expected value is the mean of the values. - // Note that the mean of two values is their average. - wantVal = (uint(chronicleVal) + uint(chainlinkVal)) / 2; - } - - _checkReadFunctions({wantVal: uint128(wantVal), wantAge: age}); - } - - function testFuzz_poke_FailsIf_ChronicleValueZero(uint128 val) public { - vm.assume(val != 0); - _setValAndAge(val, uint32(block.timestamp)); - - // Let chronicle's val to zero. - chronicle.setVal(0); - - vm.expectRevert( - abi.encodeWithSelector( - IAggor.OracleReadFailed.selector, address(chronicle) - ) - ); - aggor.poke(); - } - - function testFuzz_poke_FailsIf_ChronicleValueStale( - uint128 val, - uint chronicleAgeSeed - ) public { - vm.assume(val != 0); - _setValAndAge(val, uint32(block.timestamp)); - - // Let chronicle's age be stale. - uint chronicleAge = bound( - chronicleAgeSeed, - 0, - block.timestamp - aggor.stalenessThreshold() - 1 - ); - chronicle.setAge(chronicleAge); - - vm.expectRevert( - abi.encodeWithSelector( - IAggor.OracleReadFailed.selector, address(chronicle) - ) - ); - aggor.poke(); - } - - function testFuzz_poke_FailsIf_ChainlinkValueZero(uint128 val) public { - vm.assume(val != 0); - _setValAndAge(val, uint32(block.timestamp)); - - // Let chainlink's val be zero. - chainlink.setAnswer(0); - - vm.expectRevert( - abi.encodeWithSelector( - IAggor.OracleReadFailed.selector, address(chainlink) - ) - ); - aggor.poke(); - } - - function testFuzz_poke_FailsIf_ChainlinkValueStale( - uint128 val, - uint chainlinkAgeSeed - ) public { - vm.assume(val != 0); - _setValAndAge(val, uint32(block.timestamp)); - - // Let chainlink's age be stale. - uint chainlinkAge = bound( - chainlinkAgeSeed, - 0, - block.timestamp - aggor.stalenessThreshold() - 1 - ); - chainlink.setUpdatedAt(chainlinkAge); - - vm.expectRevert( - abi.encodeWithSelector( - IAggor.OracleReadFailed.selector, address(chainlink) - ) - ); - aggor.poke(); - } - - function testFuzz_poke_FailsIf_ChainlinkValueNegative( - uint128 val, - int chainlinkValSeed - ) public { - vm.assume(val != 0); - _setValAndAge(val, uint32(block.timestamp)); - - // Let chainlink's val be negative. - int chainlinkVal = bound(chainlinkValSeed, type(int).min, -1); - chainlink.setAnswer(chainlinkVal); - - vm.expectRevert( - abi.encodeWithSelector( - IAggor.OracleReadFailed.selector, address(chainlink) - ) - ); - aggor.poke(); - } - - function test_poke_ChainlinkDecimalConversion() public { - uint want; - uint val; - aggor.setSpread(uint16(_pscale)); - - // Case 1: chainlink.decimals < 18. - _setValAndAge(10e17, block.timestamp); - chainlink.setDecimals(17); - aggor.poke(); - - want = 55e17; // = 5.5e18 => (1 + 10) / 2 = 5.5 - val = aggor.read(); - - assertEq(val, want); - - // Case 2: chainlink.decimals > 18. - _setValAndAge(1e19, block.timestamp); - chainlink.setDecimals(19); - aggor.poke(); - - want = 55e17; // = 5.5e18 => (10 + 1) / 2 = 5.5 - val = aggor.read(); - - assertEq(val, want); - } - - // -- Read Functionality -- - - // -- IChronicle - - function test_read_FailsIfValIsZero() public { - vm.expectRevert(); - aggor.read(); - } - - function test_tryRead_ReturnsFalseIfValIsZero() public { - bool ok; - (ok,) = aggor.tryRead(); - assertFalse(ok); - } - - function test_readWithAge_FailsIfValIsZero() public { - vm.expectRevert(); - aggor.readWithAge(); - } - - function test_tryReadWithAge_ReturnsFalseIfValIsZero() public { - bool ok; - (ok,,) = aggor.tryReadWithAge(); - assertFalse(ok); - } - - // -- Toll Protection - - function test_read_IsTollProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) - ); - aggor.read(); - } - - function test_tryRead_IsTollProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) - ); - aggor.tryRead(); - } - - function test_readWithAge_IsTollProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) - ); - aggor.readWithAge(); - } - - function test_tryReadWithAge_IsTollProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) - ); - aggor.tryReadWithAge(); - } - - function test_latestRoundData_IsTollProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) - ); - aggor.latestRoundData(); - } - - function test_latestAnswer_IsTollProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector(IToll.NotTolled.selector, address(0xbeef)) - ); - aggor.latestAnswer(); - } - - // -- Auth'ed Functionality -- - - function testFuzz_setStalenessThreshold(uint32 stalenessThreshold) public { - vm.assume(stalenessThreshold != 0); - - if (aggor.stalenessThreshold() != stalenessThreshold) { - vm.expectEmit(); - emit StalenessThresholdUpdated( - address(this), aggor.stalenessThreshold(), stalenessThreshold - ); - } - - aggor.setStalenessThreshold(stalenessThreshold); - assertEq(aggor.stalenessThreshold(), stalenessThreshold); - } - - function test_setStalenessThreshold_FailsIf_IsZero() public { - vm.expectRevert(); - aggor.setStalenessThreshold(0); - } - - function test_setStalenessThreshold_IsAuthProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector( - IAuth.NotAuthorized.selector, address(0xbeef) - ) - ); - aggor.setStalenessThreshold(1); - } - - function testFuzz_setSpread(uint16 spread) public { - vm.assume(spread <= _pscale); - - if (aggor.spread() != spread) { - vm.expectEmit(); - emit SpreadUpdated(address(this), aggor.spread(), spread); - } - - aggor.setSpread(spread); - assertEq(aggor.spread(), spread); - } - - function testFuzz_setSpread_FailsIf_BiggerThanPScale(uint16 spread) - public - { - vm.assume(spread > _pscale); - - vm.expectRevert(); - aggor.setSpread(spread); - } - - function test_setSpread_IsAuthProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector( - IAuth.NotAuthorized.selector, address(0xbeef) - ) - ); - aggor.setSpread(1); - } - - function test_useUniswap_IsAuthProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector( - IAuth.NotAuthorized.selector, address(0xbeef) - ) - ); - aggor.useUniswap(true); - } - - function testFuzz_setUniSecondsAgo(uint32 uniSecondsAgo) public { - vm.assume(uniSecondsAgo >= aggor.minUniSecondsAgo()); - - if (aggor.uniSecondsAgo() != uniSecondsAgo) { - vm.expectEmit(); - emit UniswapSecondsAgoUpdated( - address(this), aggor.uniSecondsAgo(), uniSecondsAgo - ); - } - - aggor.setUniSecondsAgo(uniSecondsAgo); - assertEq(aggor.uniSecondsAgo(), uniSecondsAgo); - } - - function testFuzz_setUniSecondsAgo_FailsIf_LessThanMinAllowedSeconds( - uint32 uniSecondsAgo - ) public { - vm.assume(uniSecondsAgo < aggor.minUniSecondsAgo()); - - vm.expectRevert(); - aggor.setUniSecondsAgo(uniSecondsAgo); - } - - function test_setUniSecondsAgo_IsAuthProtected() public { - vm.prank(address(0xbeef)); - vm.expectRevert( - abi.encodeWithSelector( - IAuth.NotAuthorized.selector, address(0xbeef) - ) - ); - aggor.setUniSecondsAgo(1); - } - - function test_useUniswap_NotConfigured() public { - IAggor aggor_ = new Aggor( - address(this), - aggor.chronicle(), - aggor.chainlink(), - address(0), - false - ); - vm.expectRevert(); - aggor_.useUniswap(true); - } - - // -- Private Helpers -- - - function _setValAndAge(uint val, uint age) private { - require( - val <= uint(type(int).max), - "IAggorTest::_setValAndAge: val overflows int" - ); - - chronicle.setVal(val); - chronicle.setAge(age); - - chainlink.setAnswer(int(val)); - chainlink.setUpdatedAt(age); - chainlink.setShouldFail(false); - - aggor.poke(); - } -} diff --git a/test/MainnetIntegration.t.sol b/test/MainnetIntegration.t.sol deleted file mode 100644 index c414567..0000000 --- a/test/MainnetIntegration.t.sol +++ /dev/null @@ -1,187 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.16; - -import {Test} from "forge-std/Test.sol"; -import {console2} from "forge-std/console2.sol"; - -import {IToll} from "chronicle-std/toll/IToll.sol"; - -import "uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol"; -import "uniswap/v3-periphery/contracts/libraries/OracleLibrary.sol"; - -import {LibCalc} from "src/libs/LibCalc.sol"; -import {LibUniswapOracles} from "src/libs/LibUniswapOracles.sol"; -import {Aggor} from "src/Aggor.sol"; - -import {IChainlinkAggregatorV3} from - "src/interfaces/_external/IChainlinkAggregatorV3.sol"; - -// @todo Delete and swap with Scribe contract, when one is avaiable onchain. -// https://app.shortcut.com/chronicle-labs/story/2660/aggor-integration-test-to-use-scribe-contract -interface DeleteMeIMedianizer { - function wat() external view returns (bytes32 wat); - function read() external view returns (uint value); - function age() external view returns (uint32 age); -} - -contract DeleteMeMedianizerWrapper { - address medianizer; - - constructor(address medianizer_) { - medianizer = medianizer_; - } - - function wat() external view returns (bytes32) { - return DeleteMeIMedianizer(medianizer).wat(); - } - - function tryReadWithAge() - external - view - returns (bool isValid, uint value, uint age) - { - uint val = DeleteMeIMedianizer(medianizer).read(); - return (val > 0, val, uint(DeleteMeIMedianizer(medianizer).age())); - } -} - -// Integration test queries the real Uniswap WETHUSDT pool and verifies mean. -contract MainnetIntegrationTest is Test { - /// @dev Uniswap pool - address constant UNI_POOL_WETHUSDT = - 0x11b815efB8f581194ae79006d24E0d814B7697F6; - - /// @dev WETH ERC-20 contract - address constant UNI_TOKEN_WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; - - /// @dev USDT ERC-20 contract - address constant UNI_TOKEN_USDT = 0xdAC17F958D2ee523a2206206994597C13D831ec7; - - /// @dev MedianETHUSD - /// https://etherscan.io/address/0x64DE91F5A373Cd4c28de3600cB34C7C6cE410C85#code - address constant MEDIAN_ETHUSD = 0x64DE91F5A373Cd4c28de3600cB34C7C6cE410C85; - // Authed on the above: - address constant PAUSE_PROXY = 0xBE8E3e3618f7474F8cB1d074A26afFef007E98FB; - - address constant CHAINLINK_ETHUSD = - 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419; - - /// @dev Decimals of Uni base pair - uint8 constant BASE_DEC = 18; - - /// @dev Decimals of Uni quote pair - uint8 constant QUOTE_DEC = 6; - - Aggor aggor; - DeleteMeMedianizerWrapper medianWrapper; - - function setUp() public { - // Create mainnet fork. - vm.createSelectFork("mainnet"); - - // Allow our test to read MedianETHUSD - medianWrapper = new DeleteMeMedianizerWrapper(MEDIAN_ETHUSD); - vm.prank(PAUSE_PROXY); - IToll(address(MEDIAN_ETHUSD)).kiss(address(medianWrapper)); - - aggor = new Aggor( - address(this), - address(medianWrapper), - CHAINLINK_ETHUSD, - UNI_POOL_WETHUSDT, - true - ); - IToll(address(aggor)).kiss(address(this)); - } - - // Full integration test, price values will not be deterministic - function testIntegration_Mainnet() public { - (, uint chronval,) = medianWrapper.tryReadWithAge(); - assertTrue(chronval > 0); - - uint unival = LibUniswapOracles.readOracle( - UNI_POOL_WETHUSDT, UNI_TOKEN_WETH, UNI_TOKEN_USDT, BASE_DEC, 300 - ); - assertTrue(unival > 0); - - console2.log("Uni source ", unival); - unival = LibCalc.scale(unival, QUOTE_DEC, BASE_DEC); - console2.log("Uni scaled ", unival); - - (, int chainvalSource,,,) = - IChainlinkAggregatorV3(CHAINLINK_ETHUSD).latestRoundData(); - assertTrue(chainvalSource > 0); - console2.log("Chain source ", chainvalSource); - uint chainval = LibCalc.scale( - uint(chainvalSource), - uint(IChainlinkAggregatorV3(CHAINLINK_ETHUSD).decimals()), - BASE_DEC - ); - console2.log("Chain scaled ", chainval); - console2.log("Chron source ", chronval); - - uint spread = - LibCalc.pctDiff(uint128(chronval), uint128(chainval), 10_000); - uint spreadUni = - LibCalc.pctDiff(uint128(chronval), uint128(unival), 10_000); - console2.log("Spread ", spread); - console2.log("Spread/uni ", spreadUni); - - // Test mean with Chainlink - aggor.setSpread(uint16(spread) + 1); - aggor.poke(); - console2.log("Aggor(chain) ", aggor.read()); - uint mean = (chronval + chainval) / 2; - assertEq(aggor.read(), mean); - - // Test closest to previous value with Chainlink - aggor.setSpread(0); - aggor.poke(); - console2.log("Aggor(chain) ", aggor.read()); - assertEq( - aggor.read(), - LibCalc.distance(mean, chronval) < LibCalc.distance(mean, chainval) - ? chronval - : chainval - ); - - // Switch to Uniswap - aggor.useUniswap(true); - - // Test mean with Uni - aggor.setSpread(uint16(spreadUni) + 1); - aggor.poke(); - console2.log("Aggor(uni) ", aggor.read()); - mean = (chronval + unival) / 2; - assertEq(aggor.read(), mean); - - // Test closest to previous value with Uni - aggor.setSpread(0); - aggor.poke(); - console2.log("Aggor(uni) ", aggor.read()); - assertEq( - aggor.read(), - LibCalc.distance(mean, chronval) < LibCalc.distance(mean, unival) - ? chronval - : unival - ); - - // Switch back to Chainlink and verify state has changed. - aggor.useUniswap(false); - assertTrue(!aggor.uniswapSelected()); - } - - // Ensure that Aggor will revert if dev is requesting a lookback period - // that is too far. - function testIntegration_UniswapLookback() public { - aggor.useUniswap(true); - assertTrue(aggor.uniswapSelected()); - - aggor.poke(); - - vm.expectRevert(); - aggor.setUniSecondsAgo(type(uint32).max); - - aggor.poke(); - } -} diff --git a/test/Runner.t.sol b/test/Runner.t.sol deleted file mode 100644 index 0ff73b1..0000000 --- a/test/Runner.t.sol +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.16; - -import {MockIChronicle} from "./mocks/MockIChronicle.sol"; -import {MockIChainlinkAggregatorV3} from - "./mocks/MockIChainlinkAggregatorV3.sol"; -import {MockUniswapPool} from "./mocks/MockUniswapPool.sol"; -import {MockIERC20} from "./mocks/MockIERC20.sol"; - -// -- Aggor Tests -- - -import {Aggor} from "src/Aggor.sol"; - -import {IAggorTest} from "./IAggorTest.sol"; - -contract AggorTest is IAggorTest { - MockUniswapPool uniPool; - MockIERC20 uniPoolToken0; - MockIERC20 uniPoolToken1; - - function setUp() public { - uniPoolToken0 = - new MockIERC20("Uniswap Pool Token 0", "UniToken0", uint8(18)); - uniPoolToken1 = - new MockIERC20("Uniswap Pool Token 1", "UniToken1", uint8(18)); - uniPool = - new MockUniswapPool(address(uniPoolToken0), address(uniPoolToken1)); - - setUp( - new Aggor( - address(this), - address(new MockIChronicle()), - address(new MockIChainlinkAggregatorV3()), - address(uniPool), - true - ) - ); - } -} - -// -- Library Tests -- - -import {LibCalcTest as LibCalcTest_} from "./LibCalcTest.sol"; - -contract LibCalcTest is LibCalcTest_ {}