From 36d6ca4e0b967334453e051709efd5b48b7f548e Mon Sep 17 00:00:00 2001 From: totomanov Date: Mon, 7 Oct 2024 16:57:37 +0300 Subject: [PATCH 1/4] feat: implement safety modules --- src/component/RateDeviationBreaker.sol | 69 ++++++++++++++++++++++ src/wrapper/GrowthSentinel.sol | 80 +++++++++++++++++++++++++ src/wrapper/RangeSentinel.sol | 59 +++++++++++++++++++ test/wrapper/GrowthSentinel.fork.t.sol | 51 ++++++++++++++++ test/wrapper/GrowthSentinel.t.sol | 81 ++++++++++++++++++++++++++ 5 files changed, 340 insertions(+) create mode 100644 src/component/RateDeviationBreaker.sol create mode 100644 src/wrapper/GrowthSentinel.sol create mode 100644 src/wrapper/RangeSentinel.sol create mode 100644 test/wrapper/GrowthSentinel.fork.t.sol create mode 100644 test/wrapper/GrowthSentinel.t.sol diff --git a/src/component/RateDeviationBreaker.sol b/src/component/RateDeviationBreaker.sol new file mode 100644 index 00000000..b7ded79a --- /dev/null +++ b/src/component/RateDeviationBreaker.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {BaseAdapter, Errors, IPriceOracle} from "../adapter/BaseAdapter.sol"; +import {ScaleUtils, Scale} from "../lib/ScaleUtils.sol"; + +/// @title RateDeviationBreaker +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice Component that can detect and react to a depeg. +contract RateDeviationBreaker is BaseAdapter { + uint256 internal constant WAD = 1e18; + /// @inheritdoc IPriceOracle + string public constant name = "RateDeviationBreaker"; + /// @notice The address of the base asset corresponding to the oracle. + address public immutable base; + /// @notice The address of the quote asset corresponding to the oracle. + address public immutable quote; + /// @notice The exchange rate oracle. + address public immutable exchangeRateOracle; + /// @notice The market price oracle. + address public immutable marketPriceOracle; + /// @notice The scale factors used for decimal conversions. + uint256 public immutable disableThreshold; + uint256 public immutable switchThreshold; + Scale internal immutable scale; + + /// @notice Deploy a GrowthSentinel. + /// @param _base The address of the base asset corresponding to the oracle. + /// @param _quote The address of the quote asset corresponding to the oracle. + constructor( + address _base, + address _quote, + address _exchangeRateOracle, + address _marketPriceOracle, + uint256 _disableThreshold, + uint256 _switchThreshold + ) { + base = _base; + quote = _quote; + exchangeRateOracle = _exchangeRateOracle; + marketPriceOracle = _marketPriceOracle; + disableThreshold = _disableThreshold; + switchThreshold = _switchThreshold; + + uint8 baseDecimals = _getDecimals(base); + uint8 quoteDecimals = _getDecimals(quote); + scale = ScaleUtils.calcScale(baseDecimals, quoteDecimals, quoteDecimals); + } + + /// @notice Get the quote from the wrapped oracle and apply a cap to the rate. + /// @param inAmount The amount of `base` to convert. + /// @param _base The token that is being priced. + /// @param _quote The token that is the unit of account. + /// @return The converted amount using the wrapped oracle, with its growth capped. + function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { + uint256 exchangeRateQuote = IPriceOracle(exchangeRateOracle).getQuote(inAmount, _base, _quote); + uint256 marketPriceQuote = IPriceOracle(marketPriceOracle).getQuote(inAmount, _base, _quote); + + uint256 ratio = marketPriceQuote * WAD / exchangeRateQuote; + + // Disable oracle if the discount is too large. + if (ratio < disableThreshold) revert Errors.PriceOracle_InvalidAnswer(); + // Switch to market price if there is a large discount. + if (ratio < switchThreshold) return marketPriceQuote; + // Under normal operation return the exchange rate. + return exchangeRateQuote; + } +} diff --git a/src/wrapper/GrowthSentinel.sol b/src/wrapper/GrowthSentinel.sol new file mode 100644 index 00000000..484680ab --- /dev/null +++ b/src/wrapper/GrowthSentinel.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {BaseAdapter, Errors, IPriceOracle} from "../adapter/BaseAdapter.sol"; +import {ScaleUtils, Scale} from "../lib/ScaleUtils.sol"; + +/// @title GrowthSentinel +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice Wrapper component that caps the growth rate of an exchange rate oracle. +/// @dev If the rate exceeds the cap then the cap is returned. +contract GrowthSentinel is BaseAdapter { + /// @inheritdoc IPriceOracle + string public constant name = "GrowthSentinel"; + /// @notice The address of the wrapped (underlying) oracle. + address public immutable wrappedOracle; + /// @notice The address of the base asset corresponding to the oracle. + address public immutable base; + /// @notice The address of the quote asset corresponding to the oracle. + address public immutable quote; + /// @notice The maximum per-second growth of the exchange rate. + uint256 public immutable maxRateGrowth; + /// @notice The unit exchange rate of base/quote taken at contract creation. + uint256 public immutable snapshotRate; + /// @notice The timestamp of the exchange rate snapshot. + uint256 public immutable snapshotAt; + /// @notice The scale factors used for decimal conversions. + Scale internal immutable scale; + + /// @notice Deploy a GrowthSentinel. + /// @param _wrappedOracle The address of the underlying exchange rate oracle. + /// @param _base The address of the base asset corresponding to the oracle. + /// @param _quote The address of the quote asset corresponding to the oracle. + /// @param _maxRateGrowth The maximum permitted growth of the exchange rate. + constructor(address _wrappedOracle, address _base, address _quote, uint256 _maxRateGrowth) { + wrappedOracle = _wrappedOracle; + base = _base; + quote = _quote; + maxRateGrowth = _maxRateGrowth; + + uint8 baseDecimals = _getDecimals(base); + uint8 quoteDecimals = _getDecimals(quote); + + snapshotRate = IPriceOracle(wrappedOracle).getQuote(10 ** baseDecimals, base, quote); + snapshotAt = block.timestamp; + scale = ScaleUtils.calcScale(baseDecimals, quoteDecimals, quoteDecimals); + } + + /// @notice Get the maximum permitted current exchange rate. + /// @return The maximum permitted unit exchange rate of base/quote. + function maxRate() external view returns (uint256) { + return maxRate(block.timestamp); + } + + /// @notice Get the maximum permitted exchange rate at a timestamp. + /// @param timestamp The timestamp to use. Must not be earlier than `snapshotAt`. + /// @return The maximum permitted unit exchange rate of base/quote. + function maxRate(uint256 timestamp) public view returns (uint256) { + if (timestamp < snapshotAt) revert Errors.PriceOracle_InvalidAnswer(); + uint256 secondsElapsed = timestamp - snapshotAt; + return snapshotRate + maxRateGrowth * secondsElapsed; + } + + /// @notice Get the quote from the wrapped oracle and apply a cap to the rate. + /// @param inAmount The amount of `base` to convert. + /// @param _base The token that is being priced. + /// @param _quote The token that is the unit of account. + /// @return The converted amount using the wrapped oracle, with its growth capped. + function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { + bool inverse = ScaleUtils.getDirectionOrRevert(_base, base, _quote, quote); + + uint256 outAmount = IPriceOracle(wrappedOracle).getQuote(inAmount, _base, _quote); + uint256 capAmount = ScaleUtils.calcOutAmount(inAmount, maxRate(block.timestamp), scale, inverse); + + if (inverse) { + return outAmount > capAmount ? outAmount : capAmount; + } + return outAmount < capAmount ? outAmount : capAmount; + } +} diff --git a/src/wrapper/RangeSentinel.sol b/src/wrapper/RangeSentinel.sol new file mode 100644 index 00000000..a25953eb --- /dev/null +++ b/src/wrapper/RangeSentinel.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {BaseAdapter, Errors, IPriceOracle} from "../adapter/BaseAdapter.sol"; +import {ScaleUtils, Scale} from "../lib/ScaleUtils.sol"; + +/// @title RangeSentinel +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice Wrapper component that bounds an exchange rate to a range. +/// @dev Outside of the range the rate is saturated to the boundary. +contract RangeSentinel is BaseAdapter { + /// @inheritdoc IPriceOracle + string public constant name = "RangeSentinel"; + /// @notice The address of the wrapped (underlying) oracle. + address public immutable wrappedOracle; + /// @notice The address of the base asset corresponding to the oracle. + address public immutable base; + /// @notice The address of the quote asset corresponding to the oracle. + address public immutable quote; + /// @notice The minimum unit exchange rate of base/quote. + uint256 public immutable minRate; + /// @notice The maximum unit exchange rate of base/quote. + uint256 public immutable maxRate; + /// @notice The scale factors used for decimal conversions. + Scale internal immutable scale; + + /// @notice Deploy a RangeSentinel. + /// @param _wrappedOracle The address of the underlying exchange rate oracle. + /// @param _base The address of the base asset corresponding to the oracle. + /// @param _quote The address of the quote asset corresponding to the oracle. + /// @param _minRate The minimum unit exchange rate of base/quote. + /// @param _maxRate The maximum unit exchange rate of base/quote. + constructor(address _wrappedOracle, address _base, address _quote, uint256 _minRate, uint256 _maxRate) { + if (_minRate > _maxRate || _minRate == 0) revert Errors.PriceOracle_InvalidConfiguration(); + wrappedOracle = _wrappedOracle; + base = _base; + quote = _quote; + minRate = _minRate; + maxRate = _maxRate; + } + + /// @notice Get the quote from the wrapped oracle and bound it to the range. + /// @param inAmount The amount of `base` to convert. + /// @param _base The token that is being priced. + /// @param _quote The token that is the unit of account. + /// @return The converted amount using the wrapped oracle, bounded to the range. + function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { + bool inverse = ScaleUtils.getDirectionOrRevert(_base, base, _quote, quote); + + uint256 outAmount = IPriceOracle(wrappedOracle).getQuote(inAmount, _base, _quote); + uint256 minAmount = ScaleUtils.calcOutAmount(inAmount, minRate, scale, inverse); + uint256 maxAmount = ScaleUtils.calcOutAmount(inAmount, maxRate, scale, inverse); + + if (outAmount < minAmount) return minAmount; + if (outAmount > maxAmount) return maxAmount; + return outAmount; + } +} diff --git a/test/wrapper/GrowthSentinel.fork.t.sol b/test/wrapper/GrowthSentinel.fork.t.sol new file mode 100644 index 00000000..047b197c --- /dev/null +++ b/test/wrapper/GrowthSentinel.fork.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.23; + +import {BALANCER_RETH_RATE_PROVIDER, BALANCER_WEETH_RATE_PROVIDER} from "test/adapter/rate/RateProviderAddresses.sol"; +import {RETH, WEETH, WETH, WSTETH} from "test/utils/EthereumAddresses.sol"; +import {ForkTest} from "test/utils/ForkTest.sol"; +import {LidoFundamentalOracle} from "src/adapter/lido/LidoFundamentalOracle.sol"; +import {RateProviderOracle} from "src/adapter/rate/RateProviderOracle.sol"; +import {GrowthSentinel} from "src/wrapper/GrowthSentinel.sol"; + +contract GrowthSentinelForkTest is ForkTest { + function setUp() public { + _setUpFork(20893573); + } + + function test_wstETH() public { + vm.rollFork(12000000); + LidoFundamentalOracle adapter = new LidoFundamentalOracle(); + uint256 maxRateGrowth = uint256(0.08e18) / 365 days; + GrowthSentinel sentinel = new GrowthSentinel(address(adapter), WSTETH, WETH, maxRateGrowth); + + vm.rollFork(20893573); + uint256 adapterOutAmount = adapter.getQuote(1e18, WSTETH, WETH); + uint256 sentinelOutAmount = sentinel.getQuote(1e18, WSTETH, WETH); + assertEq(sentinelOutAmount, adapterOutAmount); + } + + function test_rETH() public { + vm.rollFork(13846103); + RateProviderOracle adapter = new RateProviderOracle(RETH, WETH, BALANCER_RETH_RATE_PROVIDER); + uint256 maxRateGrowth = uint256(0.08e18) / 365 days; + GrowthSentinel sentinel = new GrowthSentinel(address(adapter), RETH, WETH, maxRateGrowth); + + vm.rollFork(20893573); + uint256 adapterOutAmount = adapter.getQuote(1e18, RETH, WETH); + uint256 sentinelOutAmount = sentinel.getQuote(1e18, RETH, WETH); + assertEq(sentinelOutAmount, adapterOutAmount); + } + + function test_weETH() public { + vm.rollFork(18550000); + RateProviderOracle adapter = new RateProviderOracle(WEETH, WETH, BALANCER_WEETH_RATE_PROVIDER); + uint256 maxRateGrowth = uint256(0.08e18) / 365 days; + GrowthSentinel sentinel = new GrowthSentinel(address(adapter), WEETH, WETH, maxRateGrowth); + + vm.rollFork(20893573); + uint256 adapterOutAmount = adapter.getQuote(1e18, WEETH, WETH); + uint256 sentinelOutAmount = sentinel.getQuote(1e18, WEETH, WETH); + assertEq(sentinelOutAmount, adapterOutAmount); + } +} diff --git a/test/wrapper/GrowthSentinel.t.sol b/test/wrapper/GrowthSentinel.t.sol new file mode 100644 index 00000000..ae35c267 --- /dev/null +++ b/test/wrapper/GrowthSentinel.t.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.23; + +import {console2} from "forge-std/console2.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {Test} from "forge-std/Test.sol"; +import {StubPriceOracle} from "test/adapter/StubPriceOracle.sol"; +import {GrowthSentinel} from "src/wrapper/GrowthSentinel.sol"; + +contract GrowthSentinelTest is Test { + address base = makeAddr("BASE"); + address quote = makeAddr("QUOTE"); + uint256 INITIAL_PRICE = 2e18; + uint256 MAX_GROWTH = 0.1e18; + StubPriceOracle wrappedAdapter; + GrowthSentinel sentinel; + + function setUp() public { + vm.warp(0); + wrappedAdapter = new StubPriceOracle(); + vm.mockCall(base, abi.encodeWithSelector(IERC20.decimals.selector), abi.encode(18)); + vm.mockCall(quote, abi.encodeWithSelector(IERC20.decimals.selector), abi.encode(18)); + setPrice(INITIAL_PRICE); + sentinel = new GrowthSentinel(address(wrappedAdapter), base, quote, MAX_GROWTH); + } + + function test_Quote_NoIncrease() public { + vm.warp(10); + setPrice(INITIAL_PRICE); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + assertEq(sentinelOutAmount, INITIAL_PRICE); + assertEq(adapterOutAmount, INITIAL_PRICE); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / INITIAL_PRICE); + assertEq(adapterOutAmount, 1e36 / INITIAL_PRICE); + } + + function test_Quote_IncreaseAtMaxGrowth() public { + vm.warp(10); + uint256 price = INITIAL_PRICE + MAX_GROWTH * 10; + setPrice(price); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + assertEq(sentinelOutAmount, price); + assertEq(adapterOutAmount, price); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / price); + assertEq(adapterOutAmount, 1e36 / price); + } + + function test_Quote_IncreaseOverMaxGrowth() public { + vm.warp(20); + uint256 price = 5e18; + setPrice(price); + uint256 maxPrice = INITIAL_PRICE + MAX_GROWTH * 20; + assertEq(sentinel.maxRate(), maxPrice); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + + assertEq(sentinelOutAmount, maxPrice); + assertEq(adapterOutAmount, price); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / maxPrice); + assertEq(adapterOutAmount, 1e36 / price); + } + + function setPrice(uint256 price) internal { + wrappedAdapter.setPrice(base, quote, price); + wrappedAdapter.setPrice(quote, base, 1e36 / price); + } +} From babb720027ba4cc9179bc56b5de5e5860c999f11 Mon Sep 17 00:00:00 2001 From: totomanov Date: Mon, 16 Dec 2024 17:15:33 +0200 Subject: [PATCH 2/4] feat: concentrated oracle, er sentinel --- src/component/ConcentratedOracle.sol | 58 +++++++ src/component/ExchangeRateSentinel.sol | 104 ++++++++++++ src/component/RateDeviationBreaker.sol | 69 -------- src/wrapper/GrowthSentinel.sol | 80 ---------- src/wrapper/RangeSentinel.sol | 59 ------- test/component/ConcentratedOracle.t.sol | 63 ++++++++ .../ExchangeRateSentinel.fork.t.sol} | 13 +- test/component/ExchangeRateSentinel.t.sol | 151 ++++++++++++++++++ test/wrapper/GrowthSentinel.t.sol | 81 ---------- 9 files changed, 384 insertions(+), 294 deletions(-) create mode 100644 src/component/ConcentratedOracle.sol create mode 100644 src/component/ExchangeRateSentinel.sol delete mode 100644 src/component/RateDeviationBreaker.sol delete mode 100644 src/wrapper/GrowthSentinel.sol delete mode 100644 src/wrapper/RangeSentinel.sol create mode 100644 test/component/ConcentratedOracle.t.sol rename test/{wrapper/GrowthSentinel.fork.t.sol => component/ExchangeRateSentinel.fork.t.sol} (77%) create mode 100644 test/component/ExchangeRateSentinel.t.sol delete mode 100644 test/wrapper/GrowthSentinel.t.sol diff --git a/src/component/ConcentratedOracle.sol b/src/component/ConcentratedOracle.sol new file mode 100644 index 00000000..799ae661 --- /dev/null +++ b/src/component/ConcentratedOracle.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {FixedPointMathLib} from "@solady/utils/FixedPointMathLib.sol"; +import {BaseAdapter, IPriceOracle} from "../adapter/BaseAdapter.sol"; + +/// @title ConcentratedOracle +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice Component that concentrates a market price around the exchange rate. +/// @dev See Desmos: https://www.desmos.com/calculator/dzet62w513 +contract ConcentratedOracle is BaseAdapter { + uint256 internal constant WAD = 1e18; + /// @inheritdoc IPriceOracle + string public constant name = "RateDeviationBreaker"; + /// @notice The address of the base asset corresponding to the oracle. + address public immutable base; + /// @notice The address of the quote asset corresponding to the oracle. + address public immutable quote; + /// @notice The exchange rate oracle. + address public immutable fundamentalOracle; + /// @notice The market price oracle. + address public immutable marketOracle; + /// @notice Exponential decay constant. + uint256 public immutable lambda; + + /// @notice Deploy a BlendedOracle. + /// @param _base The address of the base asset corresponding to the oracle. + /// @param _quote The address of the quote asset corresponding to the oracle. + constructor(address _base, address _quote, address _fundamentalOracle, address _marketOracle, uint256 _lambda) { + base = _base; + quote = _quote; + fundamentalOracle = _fundamentalOracle; + marketOracle = _marketOracle; + lambda = _lambda; + } + + /// @notice Get the quote from the wrapped oracle and apply a cap to the rate. + /// @param inAmount The amount of `base` to convert. + /// @param _base The token that is being priced. + /// @param _quote The token that is the unit of account. + /// @return The converted amount using the wrapped oracle, with its growth capped. + function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { + // Fetch the market quote (m) and the fundamental quote (f). + uint256 m = IPriceOracle(marketOracle).getQuote(inAmount, _base, _quote); + uint256 f = IPriceOracle(fundamentalOracle).getQuote(inAmount, _base, _quote); + if (f == 0) return 0; + // Calculate the relative error ε = |f - m| / f. + uint256 dist = f > m ? f - m : m - f; + uint256 err = (dist * 1e18) / f; + // Calculate the weight of the fundamental quote w_f = exp(-λε). + // Since the power is always negative, 0 ≤ w_f ≤ 1. + int256 power = -int256(lambda * err); + uint256 wf = uint256(FixedPointMathLib.expWad(power)); + // Apply the weights and return the result. + return (f * wf + m * (1e18 - wf)) / 1e18; + } +} diff --git a/src/component/ExchangeRateSentinel.sol b/src/component/ExchangeRateSentinel.sol new file mode 100644 index 00000000..559e5e17 --- /dev/null +++ b/src/component/ExchangeRateSentinel.sol @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {BaseAdapter, Errors, IPriceOracle} from "../adapter/BaseAdapter.sol"; +import {ScaleUtils, Scale} from "../lib/ScaleUtils.sol"; + +/// @title ExchangeRateSentinel +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice The sentinel is used to clamp the exchange rate and constrain its growth. +/// @dev If out of bounds the rate is saturated (clamped) to the boundary. +contract ExchangeRateSentinel is BaseAdapter { + /// @inheritdoc IPriceOracle + string public constant name = "ExchangeRateSentinel"; + /// @notice The address of the underlying oracle. + address public immutable oracle; + /// @notice The address of the base asset corresponding to the oracle. + address public immutable base; + /// @notice The address of the quote asset corresponding to the oracle. + address public immutable quote; + /// @notice The lower bound for the unit exchange rate of base/quote. + /// @dev Below this value the exchange rate is saturated (returns the floor). + uint256 public immutable floorRate; + /// @notice The upper bound for the unit exchange rate of base/quote. + /// @dev Above this value the exchange rate is saturated (returns the ceil). + uint256 public immutable ceilRate; + /// @notice The maximum per-second growth of the exchange rate. + /// @dev Relative to the snapshotted rate at deployment. + uint256 public immutable maxRateGrowth; + /// @notice The unit exchange rate of base/quote taken at deployment. + uint256 public immutable snapshotRate; + /// @notice The timestamp of the exchange rate snapshot. + uint256 public immutable snapshotAt; + /// @notice The scale factors used for decimal conversions. + Scale internal immutable scale; + + /// @notice Deploy an ExchangeRateSentinel. + /// @param _oracle The address of the underlying exchange rate oracle. + /// @param _base The address of the base asset corresponding to the oracle. + /// @param _quote The address of the quote asset corresponding to the oracle. + /// @param _floorRate The minimum unit exchange rate of base/quote. + /// @param _ceilRate The maximum unit exchange rate of base/quote. + /// @param _maxRateGrowth The maximum per-second growth of the exchange rate. + constructor( + address _oracle, + address _base, + address _quote, + uint256 _floorRate, + uint256 _ceilRate, + uint256 _maxRateGrowth + ) { + if (_floorRate > _ceilRate || _floorRate == 0) revert Errors.PriceOracle_InvalidConfiguration(); + oracle = _oracle; + base = _base; + quote = _quote; + floorRate = _floorRate; + ceilRate = _ceilRate; + maxRateGrowth = _maxRateGrowth; + + uint8 baseDecimals = _getDecimals(base); + uint8 quoteDecimals = _getDecimals(quote); + + // Snapshot the unit exchange rate at deployment. + snapshotRate = IPriceOracle(oracle).getQuote(10 ** baseDecimals, base, quote); + snapshotAt = block.timestamp; + scale = ScaleUtils.calcScale(baseDecimals, quoteDecimals, quoteDecimals); + } + + /// @notice Get the upper bound of the unit exchange rate of base/quote. + /// @dev This value is either bound by `maxRate` or `maxRateGrowth`. + /// @return The current maximum exchange rate. + function maxRate() external view returns (uint256) { + return _maxRateAt(block.timestamp); + } + + /// @notice Get the upper bound of the unit exchange rate of base/quote at a timestamp. + /// @param timestamp The timestamp to use. Must not be earlier than `snapshotAt`. + /// @return The maximum unit exchange rate of base/quote at the given timestamp. + function _maxRateAt(uint256 timestamp) internal view returns (uint256) { + if (timestamp < snapshotAt) revert Errors.PriceOracle_InvalidAnswer(); + uint256 secondsElapsed = timestamp - snapshotAt; + uint256 max = snapshotRate + maxRateGrowth * secondsElapsed; + return max < ceilRate ? max : ceilRate; + } + + /// @notice Get the quote from the wrapped oracle and bound it to the range. + /// @param inAmount The amount of `base` to convert. + /// @param _base The token that is being priced. + /// @param _quote The token that is the unit of account. + /// @return The converted amount using the wrapped oracle, bounded to the range. + function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { + bool inverse = ScaleUtils.getDirectionOrRevert(_base, base, _quote, quote); + + uint256 outAmount = IPriceOracle(oracle).getQuote(inAmount, _base, _quote); + uint256 minAmount = ScaleUtils.calcOutAmount(inAmount, floorRate, scale, inverse); + uint256 maxAmount = ScaleUtils.calcOutAmount(inAmount, _maxRateAt(block.timestamp), scale, inverse); + + // If inverse route then flip the limits because they are specified per unit base/quote by convention. + (minAmount, maxAmount) = inverse ? (maxAmount, minAmount) : (minAmount, maxAmount); + if (outAmount < minAmount) return minAmount; + if (outAmount > maxAmount) return maxAmount; + return outAmount; + } +} diff --git a/src/component/RateDeviationBreaker.sol b/src/component/RateDeviationBreaker.sol deleted file mode 100644 index b7ded79a..00000000 --- a/src/component/RateDeviationBreaker.sol +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.8.0; - -import {BaseAdapter, Errors, IPriceOracle} from "../adapter/BaseAdapter.sol"; -import {ScaleUtils, Scale} from "../lib/ScaleUtils.sol"; - -/// @title RateDeviationBreaker -/// @custom:security-contact security@euler.xyz -/// @author Euler Labs (https://www.eulerlabs.com/) -/// @notice Component that can detect and react to a depeg. -contract RateDeviationBreaker is BaseAdapter { - uint256 internal constant WAD = 1e18; - /// @inheritdoc IPriceOracle - string public constant name = "RateDeviationBreaker"; - /// @notice The address of the base asset corresponding to the oracle. - address public immutable base; - /// @notice The address of the quote asset corresponding to the oracle. - address public immutable quote; - /// @notice The exchange rate oracle. - address public immutable exchangeRateOracle; - /// @notice The market price oracle. - address public immutable marketPriceOracle; - /// @notice The scale factors used for decimal conversions. - uint256 public immutable disableThreshold; - uint256 public immutable switchThreshold; - Scale internal immutable scale; - - /// @notice Deploy a GrowthSentinel. - /// @param _base The address of the base asset corresponding to the oracle. - /// @param _quote The address of the quote asset corresponding to the oracle. - constructor( - address _base, - address _quote, - address _exchangeRateOracle, - address _marketPriceOracle, - uint256 _disableThreshold, - uint256 _switchThreshold - ) { - base = _base; - quote = _quote; - exchangeRateOracle = _exchangeRateOracle; - marketPriceOracle = _marketPriceOracle; - disableThreshold = _disableThreshold; - switchThreshold = _switchThreshold; - - uint8 baseDecimals = _getDecimals(base); - uint8 quoteDecimals = _getDecimals(quote); - scale = ScaleUtils.calcScale(baseDecimals, quoteDecimals, quoteDecimals); - } - - /// @notice Get the quote from the wrapped oracle and apply a cap to the rate. - /// @param inAmount The amount of `base` to convert. - /// @param _base The token that is being priced. - /// @param _quote The token that is the unit of account. - /// @return The converted amount using the wrapped oracle, with its growth capped. - function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { - uint256 exchangeRateQuote = IPriceOracle(exchangeRateOracle).getQuote(inAmount, _base, _quote); - uint256 marketPriceQuote = IPriceOracle(marketPriceOracle).getQuote(inAmount, _base, _quote); - - uint256 ratio = marketPriceQuote * WAD / exchangeRateQuote; - - // Disable oracle if the discount is too large. - if (ratio < disableThreshold) revert Errors.PriceOracle_InvalidAnswer(); - // Switch to market price if there is a large discount. - if (ratio < switchThreshold) return marketPriceQuote; - // Under normal operation return the exchange rate. - return exchangeRateQuote; - } -} diff --git a/src/wrapper/GrowthSentinel.sol b/src/wrapper/GrowthSentinel.sol deleted file mode 100644 index 484680ab..00000000 --- a/src/wrapper/GrowthSentinel.sol +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.8.0; - -import {BaseAdapter, Errors, IPriceOracle} from "../adapter/BaseAdapter.sol"; -import {ScaleUtils, Scale} from "../lib/ScaleUtils.sol"; - -/// @title GrowthSentinel -/// @custom:security-contact security@euler.xyz -/// @author Euler Labs (https://www.eulerlabs.com/) -/// @notice Wrapper component that caps the growth rate of an exchange rate oracle. -/// @dev If the rate exceeds the cap then the cap is returned. -contract GrowthSentinel is BaseAdapter { - /// @inheritdoc IPriceOracle - string public constant name = "GrowthSentinel"; - /// @notice The address of the wrapped (underlying) oracle. - address public immutable wrappedOracle; - /// @notice The address of the base asset corresponding to the oracle. - address public immutable base; - /// @notice The address of the quote asset corresponding to the oracle. - address public immutable quote; - /// @notice The maximum per-second growth of the exchange rate. - uint256 public immutable maxRateGrowth; - /// @notice The unit exchange rate of base/quote taken at contract creation. - uint256 public immutable snapshotRate; - /// @notice The timestamp of the exchange rate snapshot. - uint256 public immutable snapshotAt; - /// @notice The scale factors used for decimal conversions. - Scale internal immutable scale; - - /// @notice Deploy a GrowthSentinel. - /// @param _wrappedOracle The address of the underlying exchange rate oracle. - /// @param _base The address of the base asset corresponding to the oracle. - /// @param _quote The address of the quote asset corresponding to the oracle. - /// @param _maxRateGrowth The maximum permitted growth of the exchange rate. - constructor(address _wrappedOracle, address _base, address _quote, uint256 _maxRateGrowth) { - wrappedOracle = _wrappedOracle; - base = _base; - quote = _quote; - maxRateGrowth = _maxRateGrowth; - - uint8 baseDecimals = _getDecimals(base); - uint8 quoteDecimals = _getDecimals(quote); - - snapshotRate = IPriceOracle(wrappedOracle).getQuote(10 ** baseDecimals, base, quote); - snapshotAt = block.timestamp; - scale = ScaleUtils.calcScale(baseDecimals, quoteDecimals, quoteDecimals); - } - - /// @notice Get the maximum permitted current exchange rate. - /// @return The maximum permitted unit exchange rate of base/quote. - function maxRate() external view returns (uint256) { - return maxRate(block.timestamp); - } - - /// @notice Get the maximum permitted exchange rate at a timestamp. - /// @param timestamp The timestamp to use. Must not be earlier than `snapshotAt`. - /// @return The maximum permitted unit exchange rate of base/quote. - function maxRate(uint256 timestamp) public view returns (uint256) { - if (timestamp < snapshotAt) revert Errors.PriceOracle_InvalidAnswer(); - uint256 secondsElapsed = timestamp - snapshotAt; - return snapshotRate + maxRateGrowth * secondsElapsed; - } - - /// @notice Get the quote from the wrapped oracle and apply a cap to the rate. - /// @param inAmount The amount of `base` to convert. - /// @param _base The token that is being priced. - /// @param _quote The token that is the unit of account. - /// @return The converted amount using the wrapped oracle, with its growth capped. - function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { - bool inverse = ScaleUtils.getDirectionOrRevert(_base, base, _quote, quote); - - uint256 outAmount = IPriceOracle(wrappedOracle).getQuote(inAmount, _base, _quote); - uint256 capAmount = ScaleUtils.calcOutAmount(inAmount, maxRate(block.timestamp), scale, inverse); - - if (inverse) { - return outAmount > capAmount ? outAmount : capAmount; - } - return outAmount < capAmount ? outAmount : capAmount; - } -} diff --git a/src/wrapper/RangeSentinel.sol b/src/wrapper/RangeSentinel.sol deleted file mode 100644 index a25953eb..00000000 --- a/src/wrapper/RangeSentinel.sol +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.8.0; - -import {BaseAdapter, Errors, IPriceOracle} from "../adapter/BaseAdapter.sol"; -import {ScaleUtils, Scale} from "../lib/ScaleUtils.sol"; - -/// @title RangeSentinel -/// @custom:security-contact security@euler.xyz -/// @author Euler Labs (https://www.eulerlabs.com/) -/// @notice Wrapper component that bounds an exchange rate to a range. -/// @dev Outside of the range the rate is saturated to the boundary. -contract RangeSentinel is BaseAdapter { - /// @inheritdoc IPriceOracle - string public constant name = "RangeSentinel"; - /// @notice The address of the wrapped (underlying) oracle. - address public immutable wrappedOracle; - /// @notice The address of the base asset corresponding to the oracle. - address public immutable base; - /// @notice The address of the quote asset corresponding to the oracle. - address public immutable quote; - /// @notice The minimum unit exchange rate of base/quote. - uint256 public immutable minRate; - /// @notice The maximum unit exchange rate of base/quote. - uint256 public immutable maxRate; - /// @notice The scale factors used for decimal conversions. - Scale internal immutable scale; - - /// @notice Deploy a RangeSentinel. - /// @param _wrappedOracle The address of the underlying exchange rate oracle. - /// @param _base The address of the base asset corresponding to the oracle. - /// @param _quote The address of the quote asset corresponding to the oracle. - /// @param _minRate The minimum unit exchange rate of base/quote. - /// @param _maxRate The maximum unit exchange rate of base/quote. - constructor(address _wrappedOracle, address _base, address _quote, uint256 _minRate, uint256 _maxRate) { - if (_minRate > _maxRate || _minRate == 0) revert Errors.PriceOracle_InvalidConfiguration(); - wrappedOracle = _wrappedOracle; - base = _base; - quote = _quote; - minRate = _minRate; - maxRate = _maxRate; - } - - /// @notice Get the quote from the wrapped oracle and bound it to the range. - /// @param inAmount The amount of `base` to convert. - /// @param _base The token that is being priced. - /// @param _quote The token that is the unit of account. - /// @return The converted amount using the wrapped oracle, bounded to the range. - function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { - bool inverse = ScaleUtils.getDirectionOrRevert(_base, base, _quote, quote); - - uint256 outAmount = IPriceOracle(wrappedOracle).getQuote(inAmount, _base, _quote); - uint256 minAmount = ScaleUtils.calcOutAmount(inAmount, minRate, scale, inverse); - uint256 maxAmount = ScaleUtils.calcOutAmount(inAmount, maxRate, scale, inverse); - - if (outAmount < minAmount) return minAmount; - if (outAmount > maxAmount) return maxAmount; - return outAmount; - } -} diff --git a/test/component/ConcentratedOracle.t.sol b/test/component/ConcentratedOracle.t.sol new file mode 100644 index 00000000..8780f9ee --- /dev/null +++ b/test/component/ConcentratedOracle.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; +import {StubPriceOracle} from "test/adapter/StubPriceOracle.sol"; +import {ConcentratedOracle} from "src/component/ConcentratedOracle.sol"; + +contract ConcentratedOracleNumericTest is Test { + address base = makeAddr("BASE"); + address quote = makeAddr("QUOTE"); + StubPriceOracle fundamentalOracle; + StubPriceOracle marketOracle; + ConcentratedOracle oracle; + + function setUp() public { + fundamentalOracle = new StubPriceOracle(); + marketOracle = new StubPriceOracle(); + } + + /// forge-config: default.fuzz.runs = 10000 + function test_Quote_Lambda40(uint256 scale) public { + scale = bound(scale, 1e18, 1e27); + oracle = new ConcentratedOracle(base, quote, address(fundamentalOracle), address(marketOracle), 40); + fundamentalOracle.setPrice(base, quote, 1e18); + + _testCase(1000e18, 1000e18, scale); + _testCase(1.5e18, 1.5e18, scale); + _testCase(1.1e18, 1.098168e18, scale); + _testCase(1.025e18, 1.015803e18, scale); + _testCase(1.01e18, 1.003297e18, scale); + _testCase(1e18, 1e18, scale); + _testCase(0.99e18, 0.996703e18, scale); + _testCase(0.975e18, 0.984197e18, scale); + _testCase(0.9e18, 0.901832e18, scale); + _testCase(0.5e18, 0.5e18, scale); + _testCase(0.01e18, 0.01e18, scale); + } + + /// forge-config: default.fuzz.runs = 10000 + function test_Quote_Lambda100(uint256 scale) public { + scale = bound(scale, 1e9, 1e27); + oracle = new ConcentratedOracle(base, quote, address(fundamentalOracle), address(marketOracle), 100); + fundamentalOracle.setPrice(base, quote, 1e18); + + _testCase(1000e18, 1000e18, scale); + _testCase(1.5e18, 1.5e18, scale); + _testCase(1.1e18, 1.099995e18, scale); + _testCase(1.01e18, 1.006321e18, scale); + _testCase(1e18, 1e18, scale); + _testCase(0.99e18, 0.993679e18, scale); + _testCase(0.975e18, 0.977052e18, scale); + _testCase(0.9e18, 0.900005e18, scale); + _testCase(0.5e18, 0.5e18, scale); + _testCase(0.01e18, 0.01e18, scale); + } + + function _testCase(uint256 m, uint256 r, uint256 scale) internal { + marketOracle.setPrice(base, quote, m * scale / 1e18); + fundamentalOracle.setPrice(base, quote, scale); + + assertApproxEqRel(oracle.getQuote(1e18, base, quote), r * scale / 1e18, 0.000001e18); + } +} diff --git a/test/wrapper/GrowthSentinel.fork.t.sol b/test/component/ExchangeRateSentinel.fork.t.sol similarity index 77% rename from test/wrapper/GrowthSentinel.fork.t.sol rename to test/component/ExchangeRateSentinel.fork.t.sol index 047b197c..30439003 100644 --- a/test/wrapper/GrowthSentinel.fork.t.sol +++ b/test/component/ExchangeRateSentinel.fork.t.sol @@ -6,9 +6,9 @@ import {RETH, WEETH, WETH, WSTETH} from "test/utils/EthereumAddresses.sol"; import {ForkTest} from "test/utils/ForkTest.sol"; import {LidoFundamentalOracle} from "src/adapter/lido/LidoFundamentalOracle.sol"; import {RateProviderOracle} from "src/adapter/rate/RateProviderOracle.sol"; -import {GrowthSentinel} from "src/wrapper/GrowthSentinel.sol"; +import {ExchangeRateSentinel} from "src/component/ExchangeRateSentinel.sol"; -contract GrowthSentinelForkTest is ForkTest { +contract ExchangeRateSentinelForkTest is ForkTest { function setUp() public { _setUpFork(20893573); } @@ -17,7 +17,8 @@ contract GrowthSentinelForkTest is ForkTest { vm.rollFork(12000000); LidoFundamentalOracle adapter = new LidoFundamentalOracle(); uint256 maxRateGrowth = uint256(0.08e18) / 365 days; - GrowthSentinel sentinel = new GrowthSentinel(address(adapter), WSTETH, WETH, maxRateGrowth); + ExchangeRateSentinel sentinel = + new ExchangeRateSentinel(address(adapter), WSTETH, WETH, 0.9e18, 1.5e18, maxRateGrowth); vm.rollFork(20893573); uint256 adapterOutAmount = adapter.getQuote(1e18, WSTETH, WETH); @@ -29,7 +30,8 @@ contract GrowthSentinelForkTest is ForkTest { vm.rollFork(13846103); RateProviderOracle adapter = new RateProviderOracle(RETH, WETH, BALANCER_RETH_RATE_PROVIDER); uint256 maxRateGrowth = uint256(0.08e18) / 365 days; - GrowthSentinel sentinel = new GrowthSentinel(address(adapter), RETH, WETH, maxRateGrowth); + ExchangeRateSentinel sentinel = + new ExchangeRateSentinel(address(adapter), RETH, WETH, 0.9e18, 1.5e18, maxRateGrowth); vm.rollFork(20893573); uint256 adapterOutAmount = adapter.getQuote(1e18, RETH, WETH); @@ -41,7 +43,8 @@ contract GrowthSentinelForkTest is ForkTest { vm.rollFork(18550000); RateProviderOracle adapter = new RateProviderOracle(WEETH, WETH, BALANCER_WEETH_RATE_PROVIDER); uint256 maxRateGrowth = uint256(0.08e18) / 365 days; - GrowthSentinel sentinel = new GrowthSentinel(address(adapter), WEETH, WETH, maxRateGrowth); + ExchangeRateSentinel sentinel = + new ExchangeRateSentinel(address(adapter), WEETH, WETH, 0.9e18, 1.5e18, maxRateGrowth); vm.rollFork(20893573); uint256 adapterOutAmount = adapter.getQuote(1e18, WEETH, WETH); diff --git a/test/component/ExchangeRateSentinel.t.sol b/test/component/ExchangeRateSentinel.t.sol new file mode 100644 index 00000000..7f2a2f75 --- /dev/null +++ b/test/component/ExchangeRateSentinel.t.sol @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity 0.8.23; + +import {console2} from "forge-std/console2.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {Test} from "forge-std/Test.sol"; +import {StubPriceOracle} from "test/adapter/StubPriceOracle.sol"; +import {ExchangeRateSentinel} from "src/component/ExchangeRateSentinel.sol"; + +contract ExchangeRateSentinelTest is Test { + address base = makeAddr("BASE"); + address quote = makeAddr("QUOTE"); + uint256 INITIAL_RATE = 2e18; + uint256 MAX_RATE_GROWTH = 0.1e18; + uint256 FLOOR_RATE = 1e18; + uint256 CEIL_RATE = 5e18; + StubPriceOracle wrappedAdapter; + ExchangeRateSentinel sentinel; + + function setUp() public { + vm.warp(0); + wrappedAdapter = new StubPriceOracle(); + vm.mockCall(base, abi.encodeWithSelector(IERC20.decimals.selector), abi.encode(18)); + vm.mockCall(quote, abi.encodeWithSelector(IERC20.decimals.selector), abi.encode(18)); + setPrice(INITIAL_RATE); + sentinel = + new ExchangeRateSentinel(address(wrappedAdapter), base, quote, FLOOR_RATE, CEIL_RATE, MAX_RATE_GROWTH); + } + + function test_Quote_AtInitial() public { + vm.warp(10); + setPrice(INITIAL_RATE); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + assertEq(sentinelOutAmount, INITIAL_RATE); + assertEq(adapterOutAmount, INITIAL_RATE); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / INITIAL_RATE); + assertEq(adapterOutAmount, 1e36 / INITIAL_RATE); + } + + function test_Quote_AtFloor() public { + vm.warp(10); + setPrice(FLOOR_RATE); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + assertEq(sentinelOutAmount, FLOOR_RATE); + assertEq(adapterOutAmount, FLOOR_RATE); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / FLOOR_RATE); + assertEq(adapterOutAmount, 1e36 / FLOOR_RATE); + } + + function test_Quote_BelowFloor() public { + vm.warp(10); + setPrice(0.5e18); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + assertEq(sentinelOutAmount, FLOOR_RATE); + assertEq(adapterOutAmount, 0.5e18); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / FLOOR_RATE); + assertEq(adapterOutAmount, 1e36 / 0.5e18); + } + + function test_Quote_IncreaseAtMaxGrowth() public { + vm.warp(10); + uint256 price = INITIAL_RATE + MAX_RATE_GROWTH * 10; + setPrice(price); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + assertEq(sentinelOutAmount, price); + assertEq(adapterOutAmount, price); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / price); + assertEq(adapterOutAmount, 1e36 / price); + } + + function test_Quote_IncreaseOverMaxGrowthUnderCeil() public { + vm.warp(20); + uint256 price = 4e18; + setPrice(price); + uint256 maxPrice = INITIAL_RATE + MAX_RATE_GROWTH * 20; + assertEq(sentinel.maxRate(), maxPrice); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + + assertEq(sentinelOutAmount, maxPrice); + assertEq(adapterOutAmount, price); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / maxPrice); + assertEq(adapterOutAmount, 1e36 / price); + } + + function test_Quote_IncreaseOverMaxGrowthAtCeil() public { + vm.warp(30); + uint256 price = 5e18; + setPrice(price); + uint256 maxPrice = INITIAL_RATE + MAX_RATE_GROWTH * 30; + assertEq(sentinel.maxRate(), maxPrice); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + + assertEq(sentinelOutAmount, maxPrice); + assertEq(adapterOutAmount, price); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / maxPrice); + assertEq(adapterOutAmount, 1e36 / price); + } + + function test_Quote_IncreaseOverMaxGrowthOverCeil() public { + vm.warp(40); + uint256 price = 6e18; + setPrice(price); + assertEq(sentinel.maxRate(), CEIL_RATE); + + uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); + uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); + + assertEq(sentinelOutAmount, CEIL_RATE); + assertEq(adapterOutAmount, price); + + sentinelOutAmount = sentinel.getQuote(1e18, quote, base); + adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); + assertEq(sentinelOutAmount, 1e36 / CEIL_RATE); + assertEq(adapterOutAmount, 1e36 / price); + } + + function setPrice(uint256 price) internal { + wrappedAdapter.setPrice(base, quote, price); + wrappedAdapter.setPrice(quote, base, 1e36 / price); + } +} diff --git a/test/wrapper/GrowthSentinel.t.sol b/test/wrapper/GrowthSentinel.t.sol deleted file mode 100644 index ae35c267..00000000 --- a/test/wrapper/GrowthSentinel.t.sol +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity 0.8.23; - -import {console2} from "forge-std/console2.sol"; -import {IERC20} from "forge-std/interfaces/IERC20.sol"; -import {Test} from "forge-std/Test.sol"; -import {StubPriceOracle} from "test/adapter/StubPriceOracle.sol"; -import {GrowthSentinel} from "src/wrapper/GrowthSentinel.sol"; - -contract GrowthSentinelTest is Test { - address base = makeAddr("BASE"); - address quote = makeAddr("QUOTE"); - uint256 INITIAL_PRICE = 2e18; - uint256 MAX_GROWTH = 0.1e18; - StubPriceOracle wrappedAdapter; - GrowthSentinel sentinel; - - function setUp() public { - vm.warp(0); - wrappedAdapter = new StubPriceOracle(); - vm.mockCall(base, abi.encodeWithSelector(IERC20.decimals.selector), abi.encode(18)); - vm.mockCall(quote, abi.encodeWithSelector(IERC20.decimals.selector), abi.encode(18)); - setPrice(INITIAL_PRICE); - sentinel = new GrowthSentinel(address(wrappedAdapter), base, quote, MAX_GROWTH); - } - - function test_Quote_NoIncrease() public { - vm.warp(10); - setPrice(INITIAL_PRICE); - - uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); - uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); - assertEq(sentinelOutAmount, INITIAL_PRICE); - assertEq(adapterOutAmount, INITIAL_PRICE); - - sentinelOutAmount = sentinel.getQuote(1e18, quote, base); - adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); - assertEq(sentinelOutAmount, 1e36 / INITIAL_PRICE); - assertEq(adapterOutAmount, 1e36 / INITIAL_PRICE); - } - - function test_Quote_IncreaseAtMaxGrowth() public { - vm.warp(10); - uint256 price = INITIAL_PRICE + MAX_GROWTH * 10; - setPrice(price); - - uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); - uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); - assertEq(sentinelOutAmount, price); - assertEq(adapterOutAmount, price); - - sentinelOutAmount = sentinel.getQuote(1e18, quote, base); - adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); - assertEq(sentinelOutAmount, 1e36 / price); - assertEq(adapterOutAmount, 1e36 / price); - } - - function test_Quote_IncreaseOverMaxGrowth() public { - vm.warp(20); - uint256 price = 5e18; - setPrice(price); - uint256 maxPrice = INITIAL_PRICE + MAX_GROWTH * 20; - assertEq(sentinel.maxRate(), maxPrice); - - uint256 sentinelOutAmount = sentinel.getQuote(1e18, base, quote); - uint256 adapterOutAmount = wrappedAdapter.getQuote(1e18, base, quote); - - assertEq(sentinelOutAmount, maxPrice); - assertEq(adapterOutAmount, price); - - sentinelOutAmount = sentinel.getQuote(1e18, quote, base); - adapterOutAmount = wrappedAdapter.getQuote(1e18, quote, base); - assertEq(sentinelOutAmount, 1e36 / maxPrice); - assertEq(adapterOutAmount, 1e36 / price); - } - - function setPrice(uint256 price) internal { - wrappedAdapter.setPrice(base, quote, price); - wrappedAdapter.setPrice(quote, base, 1e36 / price); - } -} From 86d037a61f38b1a36d38d59de09790552fadea40 Mon Sep 17 00:00:00 2001 From: totomanov Date: Mon, 16 Dec 2024 17:26:16 +0200 Subject: [PATCH 3/4] typo: name --- src/component/ConcentratedOracle.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/component/ConcentratedOracle.sol b/src/component/ConcentratedOracle.sol index 799ae661..3fe37377 100644 --- a/src/component/ConcentratedOracle.sol +++ b/src/component/ConcentratedOracle.sol @@ -24,7 +24,7 @@ contract ConcentratedOracle is BaseAdapter { /// @notice Exponential decay constant. uint256 public immutable lambda; - /// @notice Deploy a BlendedOracle. + /// @notice Deploy a ConcentratedOracle. /// @param _base The address of the base asset corresponding to the oracle. /// @param _quote The address of the quote asset corresponding to the oracle. constructor(address _base, address _quote, address _fundamentalOracle, address _marketOracle, uint256 _lambda) { From 9ddcfa94fcd5902d036bac3a723c4fadcf242eb5 Mon Sep 17 00:00:00 2001 From: totomanov Date: Wed, 18 Dec 2024 11:42:11 +0200 Subject: [PATCH 4/4] ichore: typos --- src/component/ConcentratedOracle.sol | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/component/ConcentratedOracle.sol b/src/component/ConcentratedOracle.sol index 3fe37377..ae6ebe11 100644 --- a/src/component/ConcentratedOracle.sol +++ b/src/component/ConcentratedOracle.sol @@ -7,19 +7,20 @@ import {BaseAdapter, IPriceOracle} from "../adapter/BaseAdapter.sol"; /// @title ConcentratedOracle /// @custom:security-contact security@euler.xyz /// @author Euler Labs (https://www.eulerlabs.com/) -/// @notice Component that concentrates a market price around the exchange rate. -/// @dev See Desmos: https://www.desmos.com/calculator/dzet62w513 +/// @notice Component that dampens the fluctuations of a market price around a peg. +/// @dev See Desmos: https://www.desmos.com/calculator/xwnz5uzomi contract ConcentratedOracle is BaseAdapter { + /// @notice 1e18 scalar used for precision. uint256 internal constant WAD = 1e18; /// @inheritdoc IPriceOracle - string public constant name = "RateDeviationBreaker"; + string public constant name = "ConcentratedOracle"; /// @notice The address of the base asset corresponding to the oracle. address public immutable base; /// @notice The address of the quote asset corresponding to the oracle. address public immutable quote; - /// @notice The exchange rate oracle. + /// @notice The exchange rate oracle for base/quote. address public immutable fundamentalOracle; - /// @notice The market price oracle. + /// @notice The market price oracle for base/quote. address public immutable marketOracle; /// @notice Exponential decay constant. uint256 public immutable lambda; @@ -27,6 +28,9 @@ contract ConcentratedOracle is BaseAdapter { /// @notice Deploy a ConcentratedOracle. /// @param _base The address of the base asset corresponding to the oracle. /// @param _quote The address of the quote asset corresponding to the oracle. + /// @param _fundamentalOracle The exchange rate oracle for base/quote. + /// @param _marketOracle The market price oracle for base/quote. + /// @param lambda Exponential decay constant. constructor(address _base, address _quote, address _fundamentalOracle, address _marketOracle, uint256 _lambda) { base = _base; quote = _quote; @@ -35,11 +39,11 @@ contract ConcentratedOracle is BaseAdapter { lambda = _lambda; } - /// @notice Get the quote from the wrapped oracle and apply a cap to the rate. + /// @notice Get a quote and concentrate it to the fundamental price based on deviation. /// @param inAmount The amount of `base` to convert. /// @param _base The token that is being priced. /// @param _quote The token that is the unit of account. - /// @return The converted amount using the wrapped oracle, with its growth capped. + /// @return The converted amount. function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) { // Fetch the market quote (m) and the fundamental quote (f). uint256 m = IPriceOracle(marketOracle).getQuote(inAmount, _base, _quote); @@ -47,12 +51,12 @@ contract ConcentratedOracle is BaseAdapter { if (f == 0) return 0; // Calculate the relative error ε = |f - m| / f. uint256 dist = f > m ? f - m : m - f; - uint256 err = (dist * 1e18) / f; + uint256 err = dist * WAD / f; // Calculate the weight of the fundamental quote w_f = exp(-λε). // Since the power is always negative, 0 ≤ w_f ≤ 1. int256 power = -int256(lambda * err); uint256 wf = uint256(FixedPointMathLib.expWad(power)); // Apply the weights and return the result. - return (f * wf + m * (1e18 - wf)) / 1e18; + return (f * wf + m * (WAD - wf)) / WAD; } }