From edc9874d0402c954339714ace2fbbceaa7449fd7 Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Sat, 9 Sep 2023 19:36:18 -0500 Subject: [PATCH 1/3] Require that oracle is prepared before update (#148) --- core/.gas-snapshot | 2 +- core/src/VolatilityOracle.sol | 13 ++++---- core/src/libraries/constants/Constants.sol | 18 +++++++---- core/test/VolatilityOracle.t.sol | 35 +++++++++++----------- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/core/.gas-snapshot b/core/.gas-snapshot index 060b0c47..2f081dbe 100644 --- a/core/.gas-snapshot +++ b/core/.gas-snapshot @@ -23,4 +23,4 @@ LiquidatorGasTest:test_noCallbackTwoAssetsAndUniswapPosition() (gas: 95598) LiquidatorGasTest:test_warn() (gas: 35026) LiquidatorGasTest:test_withCallbackAndSwap() (gas: 130185) VolatilityGasTest:test_consult() (gas: 43075) -VolatilityGasTest:test_updateNoBinarySearch() (gas: 145880) \ No newline at end of file +VolatilityGasTest:test_updateNoBinarySearch() (gas: 145910) \ No newline at end of file diff --git a/core/src/VolatilityOracle.sol b/core/src/VolatilityOracle.sol index 3d01a977..1dcb2890 100644 --- a/core/src/VolatilityOracle.sol +++ b/core/src/VolatilityOracle.sol @@ -46,6 +46,7 @@ contract VolatilityOracle { unchecked { // Read `lastWrite` info from storage LastWrite memory lastWrite = lastWrites[pool]; + require(lastWrite.time > 0); // We need to call `Oracle.consult` even if we're going to return early, so go ahead and do it (Oracle.PoolData memory data, uint56 metric) = Oracle.consult(pool, seed); @@ -71,9 +72,9 @@ contract VolatilityOracle { // Only update IV if the feeGrowthGlobals samples are approximately `FEE_GROWTH_AVG_WINDOW` hours apart if ( _isInInterval({ - min: FEE_GROWTH_AVG_WINDOW - 3 * FEE_GROWTH_SAMPLE_PERIOD, + min: FEE_GROWTH_AVG_WINDOW - FEE_GROWTH_SAMPLE_PERIOD / 2, x: b.timestamp - a.timestamp, - max: FEE_GROWTH_AVG_WINDOW + 3 * FEE_GROWTH_SAMPLE_PERIOD + max: FEE_GROWTH_AVG_WINDOW + FEE_GROWTH_SAMPLE_PERIOD / 2 }) ) { // Estimate, then clamp so it lies within [previous - maxChange, previous + maxChange] @@ -166,11 +167,9 @@ contract VolatilityOracle { atOrAfter = arr[(i + 1) % FEE_GROWTH_ARRAY_LENGTH]; if (_isInInterval(beforeOrAt.timestamp, target, atOrAfter.timestamp)) break; - if (beforeOrAt.timestamp <= target) { - l = i + 1; - } else { - r = i - 1; - } + + if (target < beforeOrAt.timestamp) r = i - 1; + else l = i + 1; } uint256 errorA = target - beforeOrAt.timestamp; diff --git a/core/src/libraries/constants/Constants.sol b/core/src/libraries/constants/Constants.sol index 3d2938eb..dfc47d29 100644 --- a/core/src/libraries/constants/Constants.sol +++ b/core/src/libraries/constants/Constants.sol @@ -74,23 +74,31 @@ uint256 constant IV_MIN = 0.01e18; /// To avoid underflow in `BalanceSheet.computeProbePrices`, ensure that `IV_MAX * nSigma <= 1e18` uint256 constant IV_MAX = 0.18e18; +/// @dev The timescale of implied volatility, applied to measurements and calculations. When `BalanceSheet` detects +/// that an `nSigma` event would cause insolvency in this time period, it enables liquidations. So if you squint your +/// eyes and wave your hands enough, this is (in expectation) the time liquidators have to act before the protocol +/// accrues bad debt. uint256 constant IV_SCALE = 24 hours; -uint256 constant IV_CHANGE_PER_SECOND = 5e12; +/// @dev The maximum rate at which (reported) implied volatility can change. Raw samples in `VolatilityOracle.update` +/// are clamped (before being stored) so as not to exceed this rate. +/// Expressed in wad percentage points at `IV_SCALE` **per second**, e.g. {462962962962, 24 hours} means daily IV can +/// change by 0.0000463 percentage points per second → 4 percentage points per day. +uint256 constant IV_CHANGE_PER_SECOND = 462962962962; /// @dev To estimate volume, we need 2 samples. One is always at the current block, the other is from -/// `FEE_GROWTH_AVG_WINDOW` seconds ago, +/- `3 * FEE_GROWTH_SAMPLE_PERIOD`. Larger values make the resulting volume +/// `FEE_GROWTH_AVG_WINDOW` seconds ago, +/- `FEE_GROWTH_SAMPLE_PERIOD / 2`. Larger values make the resulting volume /// estimate more robust, but may cause the oracle to miss brief spikes in activity. uint256 constant FEE_GROWTH_AVG_WINDOW = 6 hours; /// @dev The length of the circular buffer that stores feeGrowthGlobals samples. /// Must be in interval /// \\( \left[ \frac{\text{FEE_GROWTH_AVG_WINDOW}}{\text{FEE_GROWTH_SAMPLE_PERIOD}}, 256 \right) \\) -uint256 constant FEE_GROWTH_ARRAY_LENGTH = 72; +uint256 constant FEE_GROWTH_ARRAY_LENGTH = 48; -/// @dev The minimum number of seconds that must elapse before a new feeGrowthGlobals sample will be stored. This also +/// @dev The minimum number of seconds that must elapse before a new feeGrowthGlobals sample will be stored. This /// controls how often the oracle can update IV. -uint256 constant FEE_GROWTH_SAMPLE_PERIOD = 5 minutes; +uint256 constant FEE_GROWTH_SAMPLE_PERIOD = 15 minutes; /// @dev To compute Uniswap mean price & liquidity, we need 2 samples. One is always at the current block, the other is /// from `UNISWAP_AVG_WINDOW` seconds ago. Larger values make the resulting price/liquidity values harder to diff --git a/core/test/VolatilityOracle.t.sol b/core/test/VolatilityOracle.t.sol index 808c3b29..d22f4308 100644 --- a/core/test/VolatilityOracle.t.sol +++ b/core/test/VolatilityOracle.t.sol @@ -10,7 +10,7 @@ contract VolatilityOracleTest is Test { uint256 constant SIX_HOURS_LATER = 70_045_000; uint256 constant TWELVE_HOURS_LATER = 70_090_000; - uint256 constant BLOCKS_PER_MINUTE = 567; + uint256 constant BLOCKS_PER_SECOND = 2; VolatilityOracle oracle; @@ -49,12 +49,8 @@ contract VolatilityOracleTest is Test { for (uint256 i = 0; i < count; i++) { IUniswapV3Pool pool = IUniswapV3Pool(pools[i]); - (uint56 metricConsult, uint160 priceConsult, uint256 ivConsult) = oracle.consult(pool, (1 << 32)); - (uint56 metricUpdate, uint160 priceUpdate, uint256 ivUpdate) = oracle.update(pool, (1 << 32)); - - assertEq(metricConsult, metricUpdate); - assertEq(priceUpdate, priceConsult); - assertEqDecimal(ivUpdate, ivConsult, 18); + vm.expectRevert(bytes("")); + oracle.update(pool, (1 << 32)); } } @@ -95,7 +91,7 @@ contract VolatilityOracleTest is Test { (uint256 index, uint256 time, uint256 ivOldExpected) = oracle.lastWrites(pool); assertEq(index, 0); - assertGe(block.timestamp, time + 6 hours + 15 minutes); + assertGe(block.timestamp, time + 6 hours + 7.5 minutes); (, , uint256 ivOld) = oracle.consult(pool, (1 << 32)); (, , uint256 ivNew) = oracle.update(pool, (1 << 32)); @@ -149,7 +145,6 @@ contract VolatilityOracleTest is Test { (, , uint256 iv) = oracle.update(pool, (1 << 32)); (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(address(oracle)); - // NOTE: When compiling without --via-ir, foundry doesn't count correctly assertEq(reads.length, 10); assertEq(writes.length, 4); @@ -162,20 +157,25 @@ contract VolatilityOracleTest is Test { } function test_historical_updateSequenceETHUSDC() public { + uint256 currentBlock = 109019618; + vm.createSelectFork("optimism", currentBlock); + IUniswapV3Pool pool = IUniswapV3Pool(pools[1]); // WETH/USDC + oracle = new VolatilityOracle(); oracle.prepare(pool); + vm.makePersistent(address(oracle)); - uint256 currentBlock = START_BLOCK; (uint256 currentIndex, uint256 currentTime, uint256 currentIV) = oracle.lastWrites(pool); uint256 initialTime = currentTime; - for (uint256 i = 0; i < 100; i++) { + for (uint256 i = 0; i < 48; i++) { console2.log(currentTime, currentIV); - currentBlock += (2 + uint256(blockhash(block.number)) % 10) * BLOCKS_PER_MINUTE * 5; - vm.rollFork(currentBlock); + uint256 interval = FEE_GROWTH_SAMPLE_PERIOD * 2; + currentBlock += BLOCKS_PER_SECOND * interval; + vm.createSelectFork("optimism", currentBlock); (, , uint256 ivWritten) = oracle.update(pool, (1 << 32)); (uint256 newIndex, uint256 newTime, uint256 ivStored) = oracle.lastWrites(pool); @@ -183,8 +183,9 @@ contract VolatilityOracleTest is Test { assertEqDecimal(ivStored, ivWritten, 18); assertEq(newIndex, (currentIndex + 1) % FEE_GROWTH_ARRAY_LENGTH); - assertLe(ivWritten, currentIV + (newTime - currentTime) * IV_CHANGE_PER_SECOND); - assertGe(ivWritten, currentIV - (newTime - currentTime) * IV_CHANGE_PER_SECOND); + uint256 maxChange = (newTime - currentTime) * IV_CHANGE_PER_SECOND; + assertLe(ivWritten, currentIV + maxChange); + assertGe(ivWritten + maxChange, currentIV); currentIndex = newIndex; currentTime = newTime; @@ -203,8 +204,8 @@ contract VolatilityOracleTest is Test { uint256 totalGas = 0; for (uint256 i = 0; i < 600; i++) { - currentBlock += (1 + uint256(blockhash(block.number)) % 3) * BLOCKS_PER_MINUTE * 60; - vm.rollFork(currentBlock); + currentBlock += (1 + uint256(blockhash(block.number)) % 3) * 7200; + vm.createSelectFork("optimism", currentBlock); uint256 g = gasleft(); (uint56 metric, uint160 sqrtPriceX96, uint256 iv) = oracle.update(pool, (1 << 32)); From 55edd89723a319a52e984430329ec6f9808d2a96 Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Sat, 9 Sep 2023 22:56:11 -0500 Subject: [PATCH 2/3] Make rewardToken mutable to prepare for governance (#149) --- core/.gas-snapshot | 30 +++++++++++----------- core/.storage-layout.md | 13 +++++----- core/src/Factory.sol | 12 ++++----- core/src/Ledger.sol | 2 +- core/src/libraries/constants/Constants.sol | 2 +- 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/core/.gas-snapshot b/core/.gas-snapshot index 2f081dbe..04bb3a5d 100644 --- a/core/.gas-snapshot +++ b/core/.gas-snapshot @@ -1,26 +1,26 @@ BorrowerGasTest:test_addMargin() (gas: 16203) -BorrowerGasTest:test_borrow() (gas: 110492) +BorrowerGasTest:test_borrow() (gas: 110489) BorrowerGasTest:test_getUniswapPositions() (gas: 5219) -BorrowerGasTest:test_modify() (gas: 82111) -BorrowerGasTest:test_modifyWithAnte() (gas: 88566) -BorrowerGasTest:test_repay() (gas: 111851) -BorrowerGasTest:test_uniswapDepositInBorrower() (gas: 257726) +BorrowerGasTest:test_modify() (gas: 82108) +BorrowerGasTest:test_modifyWithAnte() (gas: 88563) +BorrowerGasTest:test_repay() (gas: 111848) +BorrowerGasTest:test_uniswapDepositInBorrower() (gas: 257723) BorrowerGasTest:test_uniswapDepositStandard() (gas: 167558) -BorrowerGasTest:test_uniswapWithdraw() (gas: 147623) -BorrowerGasTest:test_withdraw() (gas: 105631) -FactoryGasTest:test_createBorrower() (gas: 156437) -FactoryGasTest:test_createMarket() (gas: 3900597) +BorrowerGasTest:test_uniswapWithdraw() (gas: 147620) +BorrowerGasTest:test_withdraw() (gas: 105628) +FactoryGasTest:test_createBorrower() (gas: 156431) +FactoryGasTest:test_createMarket() (gas: 3900591) LenderGasTest:test_accrueInterest() (gas: 46287) LenderGasTest:test_borrow() (gas: 40812) LenderGasTest:test_deposit() (gas: 53639) LenderGasTest:test_depositWithCourier() (gas: 53785) LenderGasTest:test_redeem() (gas: 53334) -LenderGasTest:test_redeemWithCourier() (gas: 83730) +LenderGasTest:test_redeemWithCourier() (gas: 83726) LenderGasTest:test_repay() (gas: 44752) -LiquidatorGasTest:test_noCallbackOneAsset() (gas: 51020) -LiquidatorGasTest:test_noCallbackTwoAssets() (gas: 59179) -LiquidatorGasTest:test_noCallbackTwoAssetsAndUniswapPosition() (gas: 95598) -LiquidatorGasTest:test_warn() (gas: 35026) -LiquidatorGasTest:test_withCallbackAndSwap() (gas: 130185) +LiquidatorGasTest:test_noCallbackOneAsset() (gas: 51017) +LiquidatorGasTest:test_noCallbackTwoAssets() (gas: 59176) +LiquidatorGasTest:test_noCallbackTwoAssetsAndUniswapPosition() (gas: 95596) +LiquidatorGasTest:test_warn() (gas: 35023) +LiquidatorGasTest:test_withCallbackAndSwap() (gas: 130182) VolatilityGasTest:test_consult() (gas: 43075) VolatilityGasTest:test_updateNoBinarySearch() (gas: 145910) \ No newline at end of file diff --git a/core/.storage-layout.md b/core/.storage-layout.md index 0ebcc522..e4bcd4b0 100644 --- a/core/.storage-layout.md +++ b/core/.storage-layout.md @@ -24,10 +24,11 @@ forge inspect --pretty src/Borrower.sol:Borrower storage-layout forge inspect --pretty src/Factory.sol:Factory storage-layout | Name | Type | Slot | Offset | Bytes | Contract | |---------------|---------------------------------------------------------------|------|--------|-------|-------------------------| -| getMarket | mapping(contract IUniswapV3Pool => struct Factory.Market) | 0 | 0 | 32 | src/Factory.sol:Factory | -| getParameters | mapping(contract IUniswapV3Pool => struct Factory.Parameters) | 1 | 0 | 32 | src/Factory.sol:Factory | -| isLender | mapping(address => bool) | 2 | 0 | 32 | src/Factory.sol:Factory | -| isBorrower | mapping(address => bool) | 3 | 0 | 32 | src/Factory.sol:Factory | -| couriers | mapping(uint32 => struct Factory.Courier) | 4 | 0 | 32 | src/Factory.sol:Factory | -| isCourier | mapping(address => bool) | 5 | 0 | 32 | src/Factory.sol:Factory | +| rewardsToken | contract ERC20 | 0 | 0 | 20 | src/Factory.sol:Factory | +| getMarket | mapping(contract IUniswapV3Pool => struct Factory.Market) | 1 | 0 | 32 | src/Factory.sol:Factory | +| getParameters | mapping(contract IUniswapV3Pool => struct Factory.Parameters) | 2 | 0 | 32 | src/Factory.sol:Factory | +| isLender | mapping(address => bool) | 3 | 0 | 32 | src/Factory.sol:Factory | +| isBorrower | mapping(address => bool) | 4 | 0 | 32 | src/Factory.sol:Factory | +| couriers | mapping(uint32 => struct Factory.Courier) | 5 | 0 | 32 | src/Factory.sol:Factory | +| isCourier | mapping(address => bool) | 6 | 0 | 32 | src/Factory.sol:Factory | diff --git a/core/src/Factory.sol b/core/src/Factory.sol index ac00a399..14669fe8 100644 --- a/core/src/Factory.sol +++ b/core/src/Factory.sol @@ -47,10 +47,10 @@ contract Factory { IRateModel public immutable RATE_MODEL; - ERC20 public immutable REWARDS_TOKEN; - address public immutable LENDER_IMPLEMENTATION; + ERC20 public rewardsToken; + /*////////////////////////////////////////////////////////////// WORLD STORAGE //////////////////////////////////////////////////////////////*/ @@ -80,12 +80,12 @@ contract Factory { CONSTRUCTOR //////////////////////////////////////////////////////////////*/ - constructor(VolatilityOracle oracle, IRateModel rateModel, ERC20 rewardsToken) { + constructor(VolatilityOracle oracle, IRateModel rateModel, ERC20 rewardsToken_) { ORACLE = oracle; RATE_MODEL = rateModel; - REWARDS_TOKEN = rewardsToken; - LENDER_IMPLEMENTATION = address(new Lender(address(this))); + + rewardsToken = rewardsToken_; } function pause(IUniswapV3Pool pool) external { @@ -178,6 +178,6 @@ contract Factory { } } - REWARDS_TOKEN.safeTransfer(beneficiary, earned); + rewardsToken.safeTransfer(beneficiary, earned); } } diff --git a/core/src/Ledger.sol b/core/src/Ledger.sol index da702a31..be6cd333 100644 --- a/core/src/Ledger.sol +++ b/core/src/Ledger.sol @@ -300,7 +300,7 @@ contract Ledger { * @return The maximum amount of `asset()` that can be withdrawn */ function maxWithdraw(address owner) external view returns (uint256) { - return convertToAssets(this.maxRedeem(owner)); + return convertToAssets(maxRedeem(owner)); } /*////////////////////////////////////////////////////////////// diff --git a/core/src/libraries/constants/Constants.sol b/core/src/libraries/constants/Constants.sol index dfc47d29..dde3864e 100644 --- a/core/src/libraries/constants/Constants.sol +++ b/core/src/libraries/constants/Constants.sol @@ -29,7 +29,7 @@ uint256 constant MAX_RATE = 706354; /// @dev The default amount of Ether required to take on debt in a `Borrower`. The `Factory` can override this value /// on a per-market basis. -uint216 constant DEFAULT_ANTE = 0.1 ether; +uint216 constant DEFAULT_ANTE = 0.01 ether; /// @dev The default number of standard deviations of price movement used to determine probe prices for `Borrower` /// solvency. The `Factory` can override this value on a per-market basis. From 512f3866ffa7e6e24ffcb692ebf298445ecd35b7 Mon Sep 17 00:00:00 2001 From: Hayden Shively <17186559+haydenshively@users.noreply.github.com> Date: Sat, 9 Sep 2023 23:06:47 -0500 Subject: [PATCH 3/3] Increase RateModel gas allowance (#150) --- core/src/RateModel.sol | 2 +- core/test/RateModel.t.sol | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/core/src/RateModel.sol b/core/src/RateModel.sol index 32f97a79..2ce045eb 100644 --- a/core/src/RateModel.sol +++ b/core/src/RateModel.sol @@ -43,7 +43,7 @@ library SafeRateLib { // but this is slightly more gas efficient. bytes memory encodedCall = abi.encodeCall(IRateModel.getYieldPerSecond, (utilization, address(this))); assembly ("memory-safe") { - let success := staticcall(10000, rateModel, add(encodedCall, 32), mload(encodedCall), 0, 32) + let success := staticcall(100000, rateModel, add(encodedCall, 32), mload(encodedCall), 0, 32) rate := mul(success, mload(0)) } diff --git a/core/test/RateModel.t.sol b/core/test/RateModel.t.sol index ce881453..ebfba214 100644 --- a/core/test/RateModel.t.sol +++ b/core/test/RateModel.t.sol @@ -4,10 +4,26 @@ pragma solidity 0.8.17; import "forge-std/Test.sol"; import {MAX_LEVERAGE} from "src/libraries/constants/Constants.sol"; -import {RateModel, SafeRateLib} from "src/RateModel.sol"; +import {IRateModel, RateModel, SafeRateLib} from "src/RateModel.sol"; + +contract EvilRateModel is IRateModel { + function getYieldPerSecond(uint256 utilization, address) external view returns (uint256) { + console2.log(gasleft()); + + if (utilization % 3 == 0) return type(uint256).max; + else if (utilization % 3 == 1) { + while (true) { + utilization = gasleft(); + } + return utilization; + } + revert(); + } +} contract RateModelTest is Test { using SafeRateLib for RateModel; + using SafeRateLib for IRateModel; RateModel model; @@ -20,6 +36,17 @@ contract RateModelTest is Test { assertEq(result, 1e12); } + function test_accrualFactorBehavesDespiteEvilModel(uint256 elapsedTime, uint256 utilization) public { + IRateModel evilModel = new EvilRateModel(); + + uint256 before = gasleft(); + uint256 result = evilModel.getAccrualFactor(utilization, elapsedTime); + assertLe(before - gasleft(), 105000); + + assertGe(result, 1e12); + assertLt(result, 1.533e12); + } + function test_accrualFactorIsWithinBounds( uint256 elapsedTime, uint256 utilization) public { uint256 result = model.getAccrualFactor(utilization, elapsedTime);