Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: safety modules #75

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/component/ConcentratedOracle.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
104 changes: 104 additions & 0 deletions src/component/ExchangeRateSentinel.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
63 changes: 63 additions & 0 deletions test/component/ConcentratedOracle.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
54 changes: 54 additions & 0 deletions test/component/ExchangeRateSentinel.fork.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading