diff --git a/src/component/ConcentratedOracle.sol b/src/component/ConcentratedOracle.sol new file mode 100644 index 00000000..ae6ebe11 --- /dev/null +++ b/src/component/ConcentratedOracle.sol @@ -0,0 +1,62 @@ +// 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 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 = "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 for base/quote. + address public immutable fundamentalOracle; + /// @notice The market price oracle for base/quote. + address public immutable marketOracle; + /// @notice Exponential decay constant. + uint256 public immutable lambda; + + /// @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; + fundamentalOracle = _fundamentalOracle; + marketOracle = _marketOracle; + lambda = _lambda; + } + + /// @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. + 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 * 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 * (WAD - wf)) / WAD; + } +} 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/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/component/ExchangeRateSentinel.fork.t.sol b/test/component/ExchangeRateSentinel.fork.t.sol new file mode 100644 index 00000000..30439003 --- /dev/null +++ b/test/component/ExchangeRateSentinel.fork.t.sol @@ -0,0 +1,54 @@ +// 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 {ExchangeRateSentinel} from "src/component/ExchangeRateSentinel.sol"; + +contract ExchangeRateSentinelForkTest 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; + ExchangeRateSentinel sentinel = + new ExchangeRateSentinel(address(adapter), WSTETH, WETH, 0.9e18, 1.5e18, 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; + ExchangeRateSentinel sentinel = + new ExchangeRateSentinel(address(adapter), RETH, WETH, 0.9e18, 1.5e18, 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; + ExchangeRateSentinel sentinel = + new ExchangeRateSentinel(address(adapter), WEETH, WETH, 0.9e18, 1.5e18, 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/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); + } +}