Skip to content

feat: ERC4626 and Sentinel #83

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
45 changes: 45 additions & 0 deletions src/adapter/erc4626/ERC4626Oracle.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {IERC4626} from "forge-std/interfaces/IERC4626.sol";
import {BaseAdapter, Errors, IPriceOracle} from "../BaseAdapter.sol";

/// @title ERC4626Oracle
/// @custom:security-contact security@euler.xyz
/// @author Euler Labs (https://www.eulerlabs.com/)
/// @notice PriceOracle adapter for ERC4626 vaults.
/// @dev Warning: This adapter may not be suitable for all ERC4626 vaults.
/// By ERC4626 spec `convert*` ignores liquidity restrictions, fees, slippage and per-user restrictions.
/// Therefore the reported price may not be realizable through `redeem` or `withdraw`.
/// @dev Warning: Exercise caution when using this pricing method for borrowable vaults.
/// Ensure that the price cannot be atomically manipulated by a donation attack.
contract ERC4626Oracle is BaseAdapter {
/// @inheritdoc IPriceOracle
string public constant name = "ERC4626Oracle";
/// @notice The address of the vault.
address public immutable base;
/// @notice The address of the vault's underlying asset.
address public immutable quote;

/// @notice Deploy an ERC4626Oracle.
/// @param _vault The address of the ERC4626 vault to price.
constructor(address _vault) {
base = _vault;
quote = IERC4626(_vault).asset();
}

/// @notice Get the quote from the ERC4626 vault.
/// @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 ERC4626 vault.
function _getQuote(uint256 inAmount, address _base, address _quote) internal view override returns (uint256) {
if (_base == base && _quote == quote) {
return IERC4626(base).convertToAssets(inAmount);
} else if (_base == quote && _quote == base) {
return IERC4626(base).convertToShares(inAmount);
}

revert Errors.PriceOracle_NotSupported(_base, _quote);
}
}
111 changes: 111 additions & 0 deletions src/component/ExchangeRateSentinel.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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.
/// If the value is `type(uint256).max` then growth bounds are disabled.
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 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.
/// @dev To use absolute bounds only, set `_maxRateGrowth` to `type(uint256).max`.
/// To use growth bounds only, set `_floorRate` to 0 and `_ceilRate` to `type(uint256).max`.
constructor(
address _oracle,
address _base,
address _quote,
uint256 _floorRate,
uint256 _ceilRate,
uint256 _maxRateGrowth
) {
if (_floorRate > _ceilRate) 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 growth bounds are disabled then only the absolute bounds apply.
if (maxRateGrowth == type(uint256).max) return ceilRate;
// Protect against inconsistent timing on non-standard EVMs.
if (timestamp < snapshotAt) revert Errors.PriceOracle_InvalidAnswer();
// Return the smaller of the absolute bound and the growth bound.
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;
}
}
2 changes: 1 addition & 1 deletion test/StubERC4626.sol
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@
pragma solidity ^0.8.0;

contract StubERC4626 {
address public asset;
address public immutable asset;
uint256 private rate;
string revertMsg = "oops";
bool doRevert;
68 changes: 68 additions & 0 deletions test/adapter/erc4626/ERC4626Oracle.fork.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {SDAI, DAI, USDM, WUSDM, USDL, WUSDL} from "test/utils/EthereumAddresses.sol";
import {ForkTest} from "test/utils/ForkTest.sol";
import {ERC4626Oracle} from "src/adapter/erc4626/ERC4626Oracle.sol";
import {Errors} from "src/lib/Errors.sol";

contract ERC4626OracleForkTest is ForkTest {
uint256 constant ABS_PRECISION = 1;
uint256 constant REL_PRECISION = 0.000001e18;

function setUp() public {
_setUpFork();
vm.rollFork(21967153);
}

function test_Constructor_Integrity() public {
ERC4626Oracle oracle = new ERC4626Oracle(SDAI);
assertEq(oracle.base(), SDAI);
assertEq(oracle.quote(), DAI);
}

function test_GetQuote_SDAI() public {
uint256 rate = 1.1493126e18;
ERC4626Oracle oracle = new ERC4626Oracle(SDAI);

uint256 outAmount = oracle.getQuote(1e18, SDAI, DAI);
uint256 outAmount1000 = oracle.getQuote(1000e18, SDAI, DAI);
assertApproxEqRel(outAmount, rate, REL_PRECISION);
assertApproxEqRel(outAmount1000, rate * 1000, REL_PRECISION);

uint256 outAmountInv = oracle.getQuote(outAmount, DAI, SDAI);
assertApproxEqAbs(outAmountInv, 1e18, ABS_PRECISION);
uint256 outAmountInv1000 = oracle.getQuote(outAmount1000, DAI, SDAI);
assertApproxEqAbs(outAmountInv1000, 1000e18, ABS_PRECISION);
}

function test_GetQuote_WUSDM() public {
uint256 rate = 1.070076852246772245e18;
ERC4626Oracle oracle = new ERC4626Oracle(WUSDM);

uint256 outAmount = oracle.getQuote(1e18, WUSDM, USDM);
uint256 outAmount1000 = oracle.getQuote(1000e18, WUSDM, USDM);
assertApproxEqRel(outAmount, rate, REL_PRECISION);
assertApproxEqRel(outAmount1000, rate * 1000, REL_PRECISION);

uint256 outAmountInv = oracle.getQuote(outAmount, USDM, WUSDM);
assertApproxEqAbs(outAmountInv, 1e18, ABS_PRECISION);
uint256 outAmountInv1000 = oracle.getQuote(outAmount1000, USDM, WUSDM);
assertApproxEqAbs(outAmountInv1000, 1000e18, ABS_PRECISION);
}

function test_GetQuote_WUSDL() public {
uint256 rate = 1.01639231015737408e18;
ERC4626Oracle oracle = new ERC4626Oracle(WUSDL);

uint256 outAmount = oracle.getQuote(1e18, WUSDL, USDL);
uint256 outAmount1000 = oracle.getQuote(1000e18, WUSDL, USDL);
assertApproxEqRel(outAmount, rate, REL_PRECISION);
assertApproxEqRel(outAmount1000, rate * 1000, REL_PRECISION);

uint256 outAmountInv = oracle.getQuote(outAmount, USDL, WUSDL);
assertApproxEqAbs(outAmountInv, 1e18, ABS_PRECISION);
uint256 outAmountInv1000 = oracle.getQuote(outAmount1000, USDL, WUSDL);
assertApproxEqAbs(outAmountInv1000, 1000e18, ABS_PRECISION);
}
}
44 changes: 44 additions & 0 deletions test/adapter/erc4626/ERC4626Oracle.prop.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {AdapterPropTest} from "test/adapter/AdapterPropTest.sol";
import {ERC4626OracleHelper} from "test/adapter/erc4626/ERC4626OracleHelper.sol";

contract ERC4626OraclePropTest is ERC4626OracleHelper, AdapterPropTest {
function testProp_Bidirectional(FuzzableState memory s, Prop_Bidirectional memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_NoOtherPaths(FuzzableState memory s, Prop_NoOtherPaths memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_IdempotentQuoteAndQuotes(FuzzableState memory s, Prop_IdempotentQuoteAndQuotes memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_SupportsZero(FuzzableState memory s, Prop_SupportsZero memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_ContinuousDomain(FuzzableState memory s, Prop_ContinuousDomain memory p) public {
setUpPropTest(s);
checkProp(p);
}

function testProp_OutAmountIncreasing(FuzzableState memory s, Prop_OutAmountIncreasing memory p) public {
setUpPropTest(s);
checkProp(p);
}

function setUpPropTest(FuzzableState memory s) internal {
setUpState(s);
adapter = address(oracle);
base = s.base;
quote = s.quote;
}
}
52 changes: 52 additions & 0 deletions test/adapter/erc4626/ERC4626Oracle.unit.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {Test} from "forge-std/Test.sol";
import {ERC4626OracleHelper} from "test/adapter/erc4626/ERC4626OracleHelper.sol";
import {boundAddr} from "test/utils/TestUtils.sol";
import {ERC4626Oracle} from "src/adapter/erc4626/ERC4626Oracle.sol";
import {Errors} from "src/lib/Errors.sol";

contract ERC4626OracleTest is ERC4626OracleHelper {
function test_Constructor_Integrity(FuzzableState memory s) public {
setUpState(s);
assertEq(ERC4626Oracle(oracle).name(), "ERC4626Oracle");
assertEq(ERC4626Oracle(oracle).base(), s.base);
assertEq(ERC4626Oracle(oracle).quote(), s.quote);
}

function test_Quote_RevertsWhen_InvalidTokens(FuzzableState memory s, address otherA, address otherB) public {
setUpState(s);
otherA = boundAddr(otherA);
otherB = boundAddr(otherB);
vm.assume(otherA != s.base && otherA != s.quote);
vm.assume(otherB != s.base && otherB != s.quote);
expectNotSupported(s.inAmount, s.base, s.base);
expectNotSupported(s.inAmount, s.quote, s.quote);
expectNotSupported(s.inAmount, s.base, otherA);
expectNotSupported(s.inAmount, otherA, s.base);
expectNotSupported(s.inAmount, s.quote, otherA);
expectNotSupported(s.inAmount, otherA, s.quote);
expectNotSupported(s.inAmount, otherA, otherA);
expectNotSupported(s.inAmount, otherA, otherB);
}

function test_Quote_Integrity(FuzzableState memory s) public {
setUpState(s);
ERC4626Oracle(oracle).getQuote(s.inAmount, s.base, s.quote);
ERC4626Oracle(oracle).getQuote(s.inAmount, s.quote, s.base);
}

function test_Quotes_Integrity(FuzzableState memory s) public {
setUpState(s);
uint256 outAmount = ERC4626Oracle(oracle).getQuote(s.inAmount, s.base, s.quote);
(uint256 bidOutAmount, uint256 askOutAmount) = ERC4626Oracle(oracle).getQuotes(s.inAmount, s.base, s.quote);
assertEq(bidOutAmount, outAmount);
assertEq(askOutAmount, outAmount);
uint256 outAmountInv = ERC4626Oracle(oracle).getQuote(s.inAmount, s.quote, s.base);
(uint256 bidOutAmountInv, uint256 askOutAmountInv) =
ERC4626Oracle(oracle).getQuotes(s.inAmount, s.quote, s.base);
assertEq(bidOutAmountInv, outAmountInv);
assertEq(askOutAmountInv, outAmountInv);
}
}
37 changes: 37 additions & 0 deletions test/adapter/erc4626/ERC4626OracleHelper.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {IERC4626} from "forge-std/interfaces/IERC4626.sol";
import {AdapterHelper} from "test/adapter/AdapterHelper.sol";
import {boundAddr} from "test/utils/TestUtils.sol";
import {StubERC4626} from "test/StubERC4626.sol";
import {ERC4626Oracle} from "src/adapter/erc4626/ERC4626Oracle.sol";

contract ERC4626OracleHelper is AdapterHelper {
struct FuzzableState {
// Config
address base;
address quote;
// Oracle State
uint256 rate;
// Environment
uint256 inAmount;
}

function setUpState(FuzzableState memory s) internal {
s.base = boundAddr(s.base);
s.quote = boundAddr(s.quote);

vm.assume(s.base != s.quote);

s.rate = bound(s.rate, 1e9, 1e27);

vm.etch(s.base, address(new StubERC4626(s.quote, 0)).code);

StubERC4626(s.base).setRate(s.rate);
StubERC4626(s.base).setRevert(behaviors[Behavior.FeedReverts]);

oracle = address(new ERC4626Oracle(s.base));
s.inAmount = bound(s.inAmount, 0, type(uint128).max);
}
}
Loading