diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fb68829dfa..14ad59f1a3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -159,7 +159,7 @@ jobs: restore-keys: | hardhat-network-fork-${{ runner.os }}- hardhat-network-fork- - - run: yarn hardhat test ./test/plugins/individual-collateral/{cbeth,aave-v3,compoundv3,stargate,lido,meta-morpho}/*.test.ts + - run: yarn hardhat test ./test/plugins/individual-collateral/{cbeth,aave-v3,compoundv3,stargate,lido,meta-morpho,aerodrome}/*.test.ts env: NODE_OPTIONS: '--max-old-space-size=32768' TS_NODE_SKIP_IGNORE: true diff --git a/common/configuration.ts b/common/configuration.ts index ec5170739a..7e76f7d782 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -118,6 +118,9 @@ export interface ITokens { // Mountain USDM?: string wUSDM?: string + + // Aerodrome + AERO?: string } export type ITokensKeys = Array @@ -145,6 +148,7 @@ export interface IPools { crvTriCrypto?: string crvMIM3Pool?: string sdUSDCUSDCPlus?: string + aeroUSDCeUSD?: string } interface INetworkConfig { @@ -519,6 +523,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { STG: '0xE3B53AF74a4BF62Ae5511055290838050bf764Df', eUSD: '0xCfA3Ef56d303AE4fAabA0592388F19d7C3399FB4', meUSD: '0xbb819D845b573B5D7C538F5b85057160cfb5f313', + AERO: '0x940181a94A35A4569E4529A3CDfB74e38FD98631', }, chainlinkFeeds: { DAI: '0x591e79239a7d679378ec8c847e5038150364c78f', // 0.3%, 24hr @@ -536,6 +541,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { ETHUSD: '0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70', // 0.15%, 20min wstETHstETH: '0xB88BAc61a4Ca37C43a3725912B1f472c9A5bc061', // 0.5%, 24h eUSD: '0x9b2C948dbA5952A1f5Ab6fA16101c1392b8da1ab', // 0.5%, 24h + AERO: '0x4EC5970fC728C5f65ba413992CD5fF6FD70fcfF0', // 0.5%, 24h }, GNOSIS_EASY_AUCTION: '0xb1875Feaeea32Bbb02DE83D81772e07E37A40f02', // mock COMET_REWARDS: '0x123964802e6ABabBE1Bc9547D72Ef1B69B00A6b1', diff --git a/contracts/plugins/assets/aerodrome/AerodromeGaugeWrapper.sol b/contracts/plugins/assets/aerodrome/AerodromeGaugeWrapper.sol new file mode 100644 index 0000000000..dc6ff7f5af --- /dev/null +++ b/contracts/plugins/assets/aerodrome/AerodromeGaugeWrapper.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "../erc20/RewardableERC20Wrapper.sol"; +import "./vendor/IAeroGauge.sol"; + +// Note: Only supports AERO rewards. +contract AerodromeGaugeWrapper is RewardableERC20Wrapper { + using SafeERC20 for IERC20; + + IAeroGauge public immutable gauge; + + /// @param _lpToken The Aerodrome LP token, transferrable + constructor( + ERC20 _lpToken, + string memory _name, + string memory _symbol, + ERC20 _aero, + IAeroGauge _gauge + ) RewardableERC20Wrapper(_lpToken, _name, _symbol, _aero) { + require( + address(_aero) != address(0) && + address(_gauge) != address(0) && + address(_lpToken) != address(0), + "invalid address" + ); + + require(address(_aero) == address(_gauge.rewardToken()), "wrong Aero"); + + gauge = _gauge; + } + + // deposit an Aerodrome LP token + function _afterDeposit(uint256 _amount, address) internal override { + underlying.approve(address(gauge), _amount); + gauge.deposit(_amount); + } + + // withdraw to Aerodrome LP token + function _beforeWithdraw(uint256 _amount, address) internal override { + gauge.withdraw(_amount); + } + + // claim rewards - only supports AERO rewards + function _claimAssetRewards() internal virtual override { + gauge.getReward(address(this)); + } +} diff --git a/contracts/plugins/assets/aerodrome/AerodromePoolTokens.sol b/contracts/plugins/assets/aerodrome/AerodromePoolTokens.sol new file mode 100644 index 0000000000..882b32ee36 --- /dev/null +++ b/contracts/plugins/assets/aerodrome/AerodromePoolTokens.sol @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: ISC +pragma solidity 0.8.19; + +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "contracts/plugins/assets/OracleLib.sol"; +import "contracts/libraries/Fixed.sol"; +import "./vendor/IAeroPool.sol"; + +/// Supports Aerodrome stable pools (2 tokens) +contract AerodromePoolTokens { + using OracleLib for AggregatorV3Interface; + using FixLib for uint192; + + error WrongIndex(uint8 maxLength); + error NoToken(uint8 tokenNumber); + + uint8 internal constant nTokens = 2; + + enum AeroPoolType { + Stable, + Volatile // not supported in this version + } + + // === State (Immutable) === + + IAeroPool public immutable pool; + AeroPoolType public immutable poolType; + + IERC20Metadata internal immutable token0; + IERC20Metadata internal immutable token1; + + // For each token, we maintain up to two feeds/timeouts/errors + // The data below would normally be a struct, but we want bytecode substitution + + AggregatorV3Interface internal immutable _t0feed0; + AggregatorV3Interface internal immutable _t0feed1; + uint48 internal immutable _t0timeout0; // {s} + uint48 internal immutable _t0timeout1; // {s} + uint192 internal immutable _t0error0; // {1} + uint192 internal immutable _t0error1; // {1} + + AggregatorV3Interface internal immutable _t1feed0; + AggregatorV3Interface internal immutable _t1feed1; + uint48 internal immutable _t1timeout0; // {s} + uint48 internal immutable _t1timeout1; // {s} + uint192 internal immutable _t1error0; // {1} + uint192 internal immutable _t1error1; // {1} + + // === Config === + + struct APTConfiguration { + IAeroPool pool; + AeroPoolType poolType; + AggregatorV3Interface[][] feeds; // row should multiply to give {UoA/ref}; max columns is 2 + uint48[][] oracleTimeouts; // {s} same order as feeds + uint192[][] oracleErrors; // {1} same order as feeds + } + + constructor(APTConfiguration memory config) { + require(maxFeedsLength(config.feeds) <= 2, "price feeds limited to 2"); + require( + config.feeds.length == nTokens && minFeedsLength(config.feeds) != 0, + "each token needs at least 1 price feed" + ); + require(address(config.pool) != address(0), "pool address is zero"); + + pool = config.pool; + poolType = config.poolType; + + // Solidity does not support immutable arrays. This is a hack to get the equivalent of + // an immutable array so we do not have store the token feeds in the blockchain. This is + // a gas optimization since it is significantly more expensive to read and write on the + // blockchain than it is to use embedded values in the bytecode. + + // === Tokens === + + if (config.poolType != AeroPoolType.Stable || !config.pool.stable()) { + revert("invalid poolType"); + } + + token0 = IERC20Metadata(pool.token0()); + token1 = IERC20Metadata(pool.token1()); + + // === Feeds + timeouts === + // I know this section at-first looks verbose and silly, but it's actually well-justified: + // - immutable variables cannot be conditionally written to + // - a struct or an array would not be able to be immutable + // - immutable variables means values get in-lined in the bytecode + + // token0 + bool more = config.feeds[0].length != 0; + // untestable: + // more will always be true based on previous feeds validations + _t0feed0 = more ? config.feeds[0][0] : AggregatorV3Interface(address(0)); + _t0timeout0 = more && config.oracleTimeouts[0].length != 0 + ? config.oracleTimeouts[0][0] + : 0; + _t0error0 = more && config.oracleErrors[0].length != 0 ? config.oracleErrors[0][0] : 0; + if (more) { + require(address(_t0feed0) != address(0), "t0feed0 empty"); + require(_t0timeout0 != 0, "t0timeout0 zero"); + require(_t0error0 < FIX_ONE, "t0error0 too large"); + } + + more = config.feeds[0].length > 1; + _t0feed1 = more ? config.feeds[0][1] : AggregatorV3Interface(address(0)); + _t0timeout1 = more && config.oracleTimeouts[0].length > 1 ? config.oracleTimeouts[0][1] : 0; + _t0error1 = more && config.oracleErrors[0].length > 1 ? config.oracleErrors[0][1] : 0; + if (more) { + require(address(_t0feed1) != address(0), "t0feed1 empty"); + require(_t0timeout1 != 0, "t0timeout1 zero"); + require(_t0error1 < FIX_ONE, "t0error1 too large"); + } + + // token1 + // untestable: + // more will always be true based on previous feeds validations + more = config.feeds[1].length != 0; + _t1feed0 = more ? config.feeds[1][0] : AggregatorV3Interface(address(0)); + _t1timeout0 = more && config.oracleTimeouts[1].length != 0 + ? config.oracleTimeouts[1][0] + : 0; + _t1error0 = more && config.oracleErrors[1].length != 0 ? config.oracleErrors[1][0] : 0; + if (more) { + require(address(_t1feed0) != address(0), "t1feed0 empty"); + require(_t1timeout0 != 0, "t1timeout0 zero"); + require(_t1error0 < FIX_ONE, "t1error0 too large"); + } + + more = config.feeds[1].length > 1; + _t1feed1 = more ? config.feeds[1][1] : AggregatorV3Interface(address(0)); + _t1timeout1 = more && config.oracleTimeouts[1].length > 1 ? config.oracleTimeouts[1][1] : 0; + _t1error1 = more && config.oracleErrors[1].length > 1 ? config.oracleErrors[1][1] : 0; + if (more) { + require(address(_t1feed1) != address(0), "t1feed1 empty"); + require(_t1timeout1 != 0, "t1timeout1 zero"); + require(_t1error1 < FIX_ONE, "t1error1 too large"); + } + } + + /// @dev Warning: Can revert + /// @param index The index of the token: 0 or 1 + /// @return low {UoA/ref_index} + /// @return high {UoA/ref_index} + function tokenPrice(uint8 index) public view virtual returns (uint192 low, uint192 high) { + if (index >= nTokens) revert WrongIndex(nTokens - 1); + + // Use only 1 feed if 2nd feed not defined + // otherwise: multiply feeds together, e.g; {UoA/ref} = {UoA/target} * {target/ref} + uint192 x; + uint192 y = FIX_ONE; + uint192 xErr; // {1} + uint192 yErr; // {1} + // if only 1 feed: `y` is FIX_ONE and `yErr` is 0 + + if (index == 0) { + x = _t0feed0.price(_t0timeout0); + xErr = _t0error0; + if (address(_t0feed1) != address(0)) { + y = _t0feed1.price(_t0timeout1); + yErr = _t0error1; + } + } else { + x = _t1feed0.price(_t1timeout0); + xErr = _t1error0; + if (address(_t1feed1) != address(0)) { + y = _t1feed1.price(_t1timeout1); + yErr = _t1error1; + } + } + + return toRange(x, y, xErr, yErr); + } + + /// @param index The index of the token: 0 or 1 + /// @return [{ref_index}] + function tokenReserve(uint8 index) public view virtual returns (uint256) { + if (index >= nTokens) revert WrongIndex(nTokens - 1); + // Maybe also cache token decimals as immutable? + IERC20Metadata tokenInterface = getToken(index); + if (index == 0) { + return shiftl_toFix(pool.reserve0(), -int8(tokenInterface.decimals()), FLOOR); + } + return shiftl_toFix(pool.reserve1(), -int8(tokenInterface.decimals()), FLOOR); + } + + /// @param index The index of the token: 0 or 1 + /// @return [address of chainlink feeds] + function tokenFeeds(uint8 index) public view virtual returns (AggregatorV3Interface[] memory) { + if (index >= nTokens) revert WrongIndex(nTokens - 1); + AggregatorV3Interface[] memory feeds = new AggregatorV3Interface[](2); + if (index == 0) { + feeds[0] = _t0feed0; + feeds[1] = _t0feed1; + } else { + feeds[0] = _t1feed0; + feeds[1] = _t1feed1; + } + return feeds; + } + + // === Internal === + + function maxPoolOracleTimeout() internal view virtual returns (uint48) { + return + uint48( + Math.max(Math.max(_t0timeout0, _t1timeout0), Math.max(_t0timeout1, _t1timeout1)) + ); + } + + // === Private === + + function getToken(uint8 index) private view returns (IERC20Metadata) { + // untestable: + // getToken is always called with a valid index + if (index >= nTokens) revert WrongIndex(nTokens - 1); + if (index == 0) return token0; + return token1; + } + + function minFeedsLength(AggregatorV3Interface[][] memory feeds) private pure returns (uint8) { + uint8 minLength = type(uint8).max; + for (uint8 i = 0; i < feeds.length; ++i) { + minLength = uint8(Math.min(minLength, feeds[i].length)); + } + return minLength; + } + + function maxFeedsLength(AggregatorV3Interface[][] memory feeds) private pure returns (uint8) { + uint8 maxLength; + for (uint8 i = 0; i < feeds.length; ++i) { + maxLength = uint8(Math.max(maxLength, feeds[i].length)); + } + return maxLength; + } + + /// x and y can be any two fixes that can be multiplied + /// @param xErr {1} error associated with x + /// @param yErr {1} error associated with y + /// returns low and high extremes of x * y, given errors + function toRange( + uint192 x, + uint192 y, + uint192 xErr, + uint192 yErr + ) private pure returns (uint192 low, uint192 high) { + low = x.mul(FIX_ONE - xErr).mul(y.mul(FIX_ONE - yErr), FLOOR); + high = x.mul(FIX_ONE + xErr).mul(y.mul(FIX_ONE + yErr), CEIL); + } +} diff --git a/contracts/plugins/assets/aerodrome/AerodromeStableCollateral.sol b/contracts/plugins/assets/aerodrome/AerodromeStableCollateral.sol new file mode 100644 index 0000000000..aab8b8f752 --- /dev/null +++ b/contracts/plugins/assets/aerodrome/AerodromeStableCollateral.sol @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol"; +import "contracts/interfaces/IAsset.sol"; +import "contracts/libraries/Fixed.sol"; +import "contracts/plugins/assets/FiatCollateral.sol"; +import "../../../interfaces/IRewardable.sol"; +import "./AerodromePoolTokens.sol"; + +// This plugin only works on Base +IERC20 constant AERO = IERC20(0x940181a94A35A4569E4529A3CDfB74e38FD98631); + +/** + * @title AerodromeStableCollateral + * This plugin contract is designed for Aerodrome stable pools + * Each token in the pool can have between 1 and 2 oracles per each token. + * + * tok = AerodromeStakingWrapper(stablePool) + * ref = LP token /w shift + * tar = USD + * UoA = USD + * + */ +contract AerodromeStableCollateral is FiatCollateral, AerodromePoolTokens { + using OracleLib for AggregatorV3Interface; + using FixLib for uint192; + + /// @dev config Unused members: chainlinkFeed, oracleError, oracleTimeout + /// @dev No revenue hiding (refPerTok() == FIX_ONE) + /// @dev config.erc20 should be an AerodromeStakingWrapper + constructor(CollateralConfig memory config, APTConfiguration memory aptConfig) + FiatCollateral(config) + AerodromePoolTokens(aptConfig) + { + require(config.defaultThreshold != 0, "defaultThreshold zero"); + assert((token0.decimals() + token1.decimals()) % 2 == 0); + maxOracleTimeout = uint48(Math.max(maxOracleTimeout, maxPoolOracleTimeout())); + } + + /// Can revert, used by other contract functions in order to catch errors + /// Should not return FIX_MAX for low + /// Should only return FIX_MAX for high if low is 0 + /// Should NOT be manipulable by MEV + /// @return low {UoA/tok} The low price estimate + /// @return high {UoA/tok} The high price estimate + /// @return pegPrice {target/ref} The actual price observed in the peg + function tryPrice() + external + view + virtual + override + returns ( + uint192 low, + uint192 high, + uint192 pegPrice + ) + { + uint256 r0 = tokenReserve(0); + uint256 r1 = tokenReserve(1); + + // xy^3 + yx^3 >= k for sAMM pools + uint256 sqrtReserve = sqrt256(sqrt256(r0 * r1) * sqrt256(r0 * r0 + r1 * r1)); + + // get token prices + (uint192 p0_low, uint192 p0_high) = tokenPrice(0); + (uint192 p1_low, uint192 p1_high) = tokenPrice(1); + + uint192 totalSupply = shiftl_toFix(pool.totalSupply(), -int8(pool.decimals()), FLOOR); + + // low + { + uint256 ratioLow = ((1e18) * p0_high) / p1_low; + uint256 sqrtPriceLow = sqrt256( + sqrt256((1e18) * ratioLow) * sqrt256(1e36 + ratioLow * ratioLow) + ); + low = _safeWrap(((((1e18) * sqrtReserve) / sqrtPriceLow) * p0_low * 2) / totalSupply); + } + // high + { + uint256 ratioHigh = ((1e18) * p0_low) / p1_high; + uint256 sqrtPriceHigh = sqrt256( + sqrt256((1e18) * ratioHigh) * sqrt256(1e36 + ratioHigh * ratioHigh) + ); + + high = _safeWrap( + ((((1e18) * sqrtReserve) / sqrtPriceHigh) * p0_high * 2) / totalSupply + ); + } + assert(low <= high); //obviously true just by inspection + + // {target/ref} = {UoA/ref} = {UoA/tok} / ({ref/tok} + // {target/ref} and {UoA/ref} are the same since target == UoA + pegPrice = ((low + high) / 2).div(refPerTok()); + } + + /// Should not revert + /// Refresh exchange rates and update default status. + /// Have to override to add custom default checks + function refresh() public virtual override { + CollateralStatus oldStatus = status(); + + // Check for soft default + save prices + try this.tryPrice() returns (uint192 low, uint192 high, uint192 pegPrice) { + // {UoA/tok}, {UoA/tok}, {UoA/tok} + // (0, 0) is a valid price; (0, FIX_MAX) is unpriced + + // Save prices if priced + if (high != FIX_MAX) { + savedLowPrice = low; + savedHighPrice = high; + savedPegPrice = pegPrice; + lastSave = uint48(block.timestamp); + } else { + // must be unpriced + // untested: + // validated in other plugins, cost to test here is high + assert(low == 0); + } + + // If the price is below the default-threshold price, default eventually + // uint192(+/-) is the same as Fix.plus/minus + if (low == 0 || _anyDepeggedInPool()) { + markStatus(CollateralStatus.IFFY); + } else { + markStatus(CollateralStatus.SOUND); + } + } catch (bytes memory errData) { + // see: docs/solidity-style.md#Catching-Empty-Data + if (errData.length == 0) revert(); // solhint-disable-line reason-string + markStatus(CollateralStatus.IFFY); + } + + CollateralStatus newStatus = status(); + if (oldStatus != newStatus) { + emit CollateralStatusChanged(oldStatus, newStatus); + } + } + + /// Claim rewards earned by holding a balance of the ERC20 token + /// @custom:delegate-call + function claimRewards() external virtual override(Asset, IRewardable) { + uint256 aeroBal = AERO.balanceOf(address(this)); + IRewardable(address(erc20)).claimRewards(); + emit RewardsClaimed(AERO, AERO.balanceOf(address(this)) - aeroBal); + } + + /// @return {ref/tok} Actual quantity of whole reference units per whole collateral tokens + function refPerTok() public view virtual override returns (uint192) { + int8 shift = 18 - int8((token0.decimals() + token1.decimals()) / 2); + return shiftl_toFix(2, shift, FLOOR); + } + + // === Internal === + + // Override this later to implement non-stable pools + function _anyDepeggedInPool() internal view virtual returns (bool) { + // Check reference token oracles + for (uint8 i = 0; i < nTokens; ++i) { + try this.tokenPrice(i) returns (uint192 low, uint192 high) { + // {UoA/tok} = {UoA/tok} + {UoA/tok} + uint192 mid = (low + high) / 2; + + // If the price is below the default-threshold price, default eventually + // uint192(+/-) is the same as Fix.plus/minus + if (mid < pegBottom || mid > pegTop) return true; + } catch (bytes memory errData) { + // see: docs/solidity-style.md#Catching-Empty-Data + // untested: + // pattern validated in other plugins, cost to test is high + if (errData.length == 0) revert(); // solhint-disable-line reason-string + return true; + } + } + + return false; + } +} diff --git a/contracts/plugins/assets/aerodrome/README.md b/contracts/plugins/assets/aerodrome/README.md new file mode 100644 index 0000000000..82cf963aab --- /dev/null +++ b/contracts/plugins/assets/aerodrome/README.md @@ -0,0 +1,45 @@ +# Aerodrome Stable Collateral Plugin + +[Aerodrome Finance](https://aerodrome.finance) is an AMM designed to serve as Base's central liquidity hub. This plugin enables the use of any Aerodrome Stable LP token as collateral within the Reserve Protocol. + +Aerodrome Finance offers two different liquidity pool types based on token pair needs, `Stable Pools` and `Volatile Pools`. + +Only `Stable Pools` are currently supported. These pools are designed for tokens which have little to no volatility, and use the current formula for pricing tokens: + +`x³y + y³x ≥ k` + +## Usage + +### Number of Tokens in The Pool + +All Aerodrome Pools are designed to support `2 (two)` tokens. So this field is harcoded and not provided as a configuration deployment parameter. + +### Multiple Price Feeds + +Some tokens require multiple price feeds since they do not have a direct price feed to USD. One example of this is WBTC. To support this, the plugin accepts a `tokensPriceFeeds` field in the configuration deployment parameter. This data structure is a `address[][]` and should have the same length as the number of coins in the Pool. The indices of these price feeds should also match the indices of the tokens in the pool. For example, if I am deploying a collateral plugin for the USDC/EUSD, I would need to pass something like `[[USDC_USD_FEED_ADDR], [EUSD_USD_FEED_ADDR]]` as `tokensPriceFeeds`. Since USDC has an index of 0 in the Aerodrome USDC/eUSD pool, the USDC price feed should be in index 0 in `tokensPriceFeeds`. + +### Wrapped Stake Token + +Since the Aerodrome LP Token needs to be staked in the Gauge to get rewards in AERO, we need to wrap it in another ERC20-token. This repo includes an `AerodromeGaugeStakingWrapper` contract that needs to be deployed and its address passed as the `erc20` configuration parameter. + +### Rewards + +Rewards come in the form of AERO tokens, which will be distributed once `claimRewards()` is called. + +AERO token: `https://basescan.org/token/0x940181a94a35a4569e4529a3cdfb74e38fd98631` + +## Implementation Notes + +### Immutable Arrays for Price Feeds + +Internally, all `tokensPriceFeeds` are stored as multiple separate immutable variables instead of just one array-type state variable for each. This is a gas-optimization done to avoid using SSTORE/SLOAD opcodes which are necessary but expensive operations when using state variables. Immutable variables, on the other hand, are embedded in the bytecode and are much cheaper to use which leads to more gas-efficient `price`, `strictPrice` and `refresh` functions. This work-around is necessary since Solidity does not yet support immutable arrays. + +### refPerTok + +Aerodrome Stable Pools do not appreciate in value over time, so `refPerTok()` will be constant for these plugins and will not change. This also means there are no hard default checks in place. + +## Implementation + +| `tok` | `ref` | `target` | `UoA` | +| :------------------: | :---------------: | :------: | :---: | +| Aero Staking Wrapper | LP token /w shift | USD | USD | diff --git a/contracts/plugins/assets/aerodrome/vendor/IAeroGauge.sol b/contracts/plugins/assets/aerodrome/vendor/IAeroGauge.sol new file mode 100644 index 0000000000..4611f5f505 --- /dev/null +++ b/contracts/plugins/assets/aerodrome/vendor/IAeroGauge.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +interface IAeroGauge { + error ZeroAmount(); + + /// @notice Address of the pool LP token which is deposited (staked) for rewards + function stakingToken() external view returns (address); + + /// @notice Address of the token (AERO) rewarded to stakers + function rewardToken() external view returns (address); + + /// @notice Cached amount of rewardToken earned for an account + function rewards(address) external view returns (uint256); + + /// @notice Returns accrued balance to date from last claim / first deposit. + function earned(address _account) external view returns (uint256); + + /// @notice Retrieve rewards for an address. + /// @dev Throws if not called by same address or voter. + /// @param _account . + function getReward(address _account) external; + + /// @notice Deposit LP tokens into gauge for msg.sender + /// @param _amount . + function deposit(uint256 _amount) external; + + /// @notice Deposit LP tokens into gauge for any user + /// @param _amount . + /// @param _recipient Recipient to give balance to + function deposit(uint256 _amount, address _recipient) external; + + /// @notice Withdraw LP tokens for user + /// @param _amount . + function withdraw(uint256 _amount) external; +} diff --git a/contracts/plugins/assets/aerodrome/vendor/IAeroPool.sol b/contracts/plugins/assets/aerodrome/vendor/IAeroPool.sol new file mode 100644 index 0000000000..7070453f6e --- /dev/null +++ b/contracts/plugins/assets/aerodrome/vendor/IAeroPool.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// solhint-disable func-param-name-mixedcase, func-name-mixedcase +interface IAeroPool is IERC20Metadata { + /// @notice Returns [token0, token1] + function tokens() external view returns (address, address); + + /// @notice Address of token in the pool with the lower address value + function token0() external view returns (address); + + /// @notice Address of token in the poool with the higher address value + function token1() external view returns (address); + + /// @notice Amount of token0 in pool + function reserve0() external view returns (uint256); + + /// @notice Amount of token1 in pool + function reserve1() external view returns (uint256); + + function stable() external view returns (bool); + + function mint(address to) external returns (uint256 liquidity); + + /// @notice Update reserves and, on the first call per block, price accumulators + /// @return _reserve0 . + /// @return _reserve1 . + /// @return _blockTimestampLast . + function getReserves() + external + view + returns ( + uint256 _reserve0, + uint256 _reserve1, + uint256 _blockTimestampLast + ); +} diff --git a/contracts/plugins/assets/aerodrome/vendor/IAeroRouter.sol b/contracts/plugins/assets/aerodrome/vendor/IAeroRouter.sol new file mode 100644 index 0000000000..c183e56f9c --- /dev/null +++ b/contracts/plugins/assets/aerodrome/vendor/IAeroRouter.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +interface IAeroRouter { + /// @notice Add liquidity of two tokens to a Pool + /// @param tokenA . + /// @param tokenB . + /// @param stable True if pool is stable, false if volatile + /// @param amountADesired Amount of tokenA desired to deposit + /// @param amountBDesired Amount of tokenB desired to deposit + /// @param amountAMin Minimum amount of tokenA to deposit + /// @param amountBMin Minimum amount of tokenB to deposit + /// @param to Recipient of liquidity token + /// @param deadline Deadline to receive liquidity + /// @return amountA Amount of tokenA to actually deposit + /// @return amountB Amount of tokenB to actually deposit + /// @return liquidity Amount of liquidity token returned from deposit + function addLiquidity( + address tokenA, + address tokenB, + bool stable, + uint256 amountADesired, + uint256 amountBDesired, + uint256 amountAMin, + uint256 amountBMin, + address to, + uint256 deadline + ) + external + returns ( + uint256 amountA, + uint256 amountB, + uint256 liquidity + ); + + // **** REMOVE LIQUIDITY **** + + /// @notice Remove liquidity of two tokens from a Pool + /// @param tokenA . + /// @param tokenB . + /// @param stable True if pool is stable, false if volatile + /// @param liquidity Amount of liquidity to remove + /// @param amountAMin Minimum amount of tokenA to receive + /// @param amountBMin Minimum amount of tokenB to receive + /// @param to Recipient of tokens received + /// @param deadline Deadline to remove liquidity + /// @return amountA Amount of tokenA received + /// @return amountB Amount of tokenB received + function removeLiquidity( + address tokenA, + address tokenB, + bool stable, + uint256 liquidity, + uint256 amountAMin, + uint256 amountBMin, + address to, + uint256 deadline + ) external returns (uint256 amountA, uint256 amountB); +} diff --git a/scripts/addresses/8453-tmp-assets-collateral.json b/scripts/addresses/8453-tmp-assets-collateral.json index 06257a8a24..ab308bd855 100644 --- a/scripts/addresses/8453-tmp-assets-collateral.json +++ b/scripts/addresses/8453-tmp-assets-collateral.json @@ -1,7 +1,8 @@ { "assets": { "COMP": "0xB8794Fb1CCd62bFe631293163F4A3fC2d22e37e0", - "STG": "0xEE527CC63122732532d0f1ad33Ec035D30f3050f" + "STG": "0xEE527CC63122732532d0f1ad33Ec035D30f3050f", + "AERO": "0x5D09F98B6fA59456E608bD20Ca806140884C3790" }, "collateral": { "DAI": "0x3E40840d0282C9F9cC7d17094b5239f87fcf18e5", @@ -12,7 +13,8 @@ "saBasUSDC": "0xC19f5d60e2Aca1174f3D5Fe189f0A69afaB76f50", "cUSDCv3": "0xf7a9D27c3B60c78c6F6e2c2d6ED6E8B94b352461", "wstETH": "0x8b4374005291B8FCD14C4E947604b2FB3C660A73", - "meUSD": "0x0f1e10871e6a2D3A5Aa696b85b39d61a22A9e8C3" + "meUSD": "0x0f1e10871e6a2D3A5Aa696b85b39d61a22A9e8C3", + "aeroUSDCeUSD": "0x9216CD5cA133aBBd23cc6F873bB4a95A78032db0" }, "erc20s": { "COMP": "0x9e1028F5F1D5eDE59748FFceE5532509976840E0", @@ -25,6 +27,8 @@ "STG": "0xE3B53AF74a4BF62Ae5511055290838050bf764Df", "cUSDCv3": "0x53f1Df4E5591Ae35Bf738742981669c3767241FA", "wstETH": "0xc1CBa3fCea344f92D9239c08C0568f6F2F0ee452", - "meUSD": "0xbb819D845b573B5D7C538F5b85057160cfb5f313" + "meUSD": "0xbb819D845b573B5D7C538F5b85057160cfb5f313", + "aeroUSDCeUSD": "0xDB5b8cead52f77De0f6B5255f73F348AAf2CBb8D", + "AERO": "0x940181a94A35A4569E4529A3CDfB74e38FD98631" } } \ No newline at end of file diff --git a/scripts/addresses/base-4.0.0/8453-tmp-assets-collateral.json b/scripts/addresses/base-4.0.0/8453-tmp-assets-collateral.json index 0ed5fa273b..a315eaf427 100644 --- a/scripts/addresses/base-4.0.0/8453-tmp-assets-collateral.json +++ b/scripts/addresses/base-4.0.0/8453-tmp-assets-collateral.json @@ -1,9 +1,14 @@ { - "assets": {}, + "assets": { + "AERO": "0x5D09F98B6fA59456E608bD20Ca806140884C3790" + }, "collateral": { - "meUSD": "0x0f1e10871e6a2D3A5Aa696b85b39d61a22A9e8C3" + "meUSD": "0x0f1e10871e6a2D3A5Aa696b85b39d61a22A9e8C3", + "aeroUSDCeUSD": "0x9216CD5cA133aBBd23cc6F873bB4a95A78032db0" }, "erc20s": { - "meUSD": "0xbb819D845b573B5D7C538F5b85057160cfb5f313" + "meUSD": "0xbb819D845b573B5D7C538F5b85057160cfb5f313", + "aeroUSDCeUSD": "0xDB5b8cead52f77De0f6B5255f73F348AAf2CBb8D", + "AERO": "0x940181a94A35A4569E4529A3CDfB74e38FD98631" } } diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 7cb13796de..cef9e1df71 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -108,8 +108,9 @@ async function main() { 'phase2-assets/collaterals/deploy_aave_v3_usdc.ts', 'phase2-assets/collaterals/deploy_lido_wsteth_collateral.ts', 'phase2-assets/collaterals/deploy_cbeth_collateral.ts', + 'phase2-assets/assets/deploy_stg.ts', 'phase2-assets/collaterals/deploy_morphoeUSD.ts', - 'phase2-assets/assets/deploy_stg.ts' + 'phase2-assets/collaterals/deploy_aerodrome_usdc_eusd.ts' ) } else if (chainId == '42161' || chainId == '421614') { // Arbitrum One diff --git a/scripts/deployment/phase2-assets/assets/deploy_aero.ts b/scripts/deployment/phase2-assets/assets/deploy_aero.ts new file mode 100644 index 0000000000..d2e467254f --- /dev/null +++ b/scripts/deployment/phase2-assets/assets/deploy_aero.ts @@ -0,0 +1,71 @@ +import fs from 'fs' +import hre, { ethers } from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { baseL2Chains, networkConfig } from '../../../../common/configuration' +import { fp } from '../../../../common/numbers' +import { + getDeploymentFile, + getDeploymentFilename, + getAssetCollDeploymentFilename, + IAssetCollDeployments, + fileExists, +} from '../../common' +import { priceTimeout } from '../../utils' +import { Asset } from '../../../../typechain' + +async function main() { + // ==== Read Configuration ==== + const [burner] = await hre.ethers.getSigners() + const chainId = await getChainId(hre) + + console.log(`Deploying AERO asset to network ${hre.network.name} (${chainId}) + with burner account: ${burner.address}`) + + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + // Get phase1 deployment + const phase1File = getDeploymentFilename(chainId) + if (!fileExists(phase1File)) { + throw new Error(`${phase1File} doesn't exist yet. Run phase 1`) + } + // Check previous step completed + const assetCollDeploymentFilename = getAssetCollDeploymentFilename(chainId) + const assetCollDeployments = getDeploymentFile(assetCollDeploymentFilename) + + const deployedAssets: string[] = [] + + // Only for Base + if (baseL2Chains.includes(hre.network.name)) { + /******** Deploy AERO asset **************************/ + const { asset: aeroAsset } = await hre.run('deploy-asset', { + priceTimeout: priceTimeout.toString(), + priceFeed: networkConfig[chainId].chainlinkFeeds.AERO, + oracleError: fp('0.005').toString(), // 0.5% + tokenAddress: networkConfig[chainId].tokens.AERO, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: '86400', // 24 hr + }) + await (await ethers.getContractAt('Asset', aeroAsset)).refresh() + + assetCollDeployments.assets.AERO = aeroAsset + assetCollDeployments.erc20s.AERO = networkConfig[chainId].tokens.AERO + deployedAssets.push(aeroAsset.toString()) + } else { + throw new Error(`Unsupported chainId: ${chainId}`) + } + + /**************************************************************/ + + fs.writeFileSync(assetCollDeploymentFilename, JSON.stringify(assetCollDeployments, null, 2)) + + console.log(`Deployed AERO asset to ${hre.network.name} (${chainId}): + New deployments: ${deployedAssets} + Deployment file: ${assetCollDeploymentFilename}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/deployment/phase2-assets/collaterals/deploy_aerodrome_usdc_eusd.ts b/scripts/deployment/phase2-assets/collaterals/deploy_aerodrome_usdc_eusd.ts new file mode 100644 index 0000000000..31b39c8107 --- /dev/null +++ b/scripts/deployment/phase2-assets/collaterals/deploy_aerodrome_usdc_eusd.ts @@ -0,0 +1,138 @@ +import fs from 'fs' +import hre, { ethers } from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { baseL2Chains, networkConfig } from '../../../../common/configuration' +import { expect } from 'chai' +import { CollateralStatus, ONE_ADDRESS } from '../../../../common/constants' +import { + getDeploymentFile, + getAssetCollDeploymentFilename, + IAssetCollDeployments, + getDeploymentFilename, + fileExists, +} from '../../common' +import { AerodromeStableCollateral, AerodromeGaugeWrapper, IAeroPool } from '../../../../typechain' +import { combinedError } from '../../utils' +import { + AerodromePoolType, + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + MAX_TRADE_VOL, + PRICE_TIMEOUT, + USDC_ORACLE_ERROR, + USDC_ORACLE_TIMEOUT, + USDC_USD_FEED, + AERO_USDC_eUSD_POOL, + AERO_USDC_eUSD_GAUGE, + AERO, + eUSD_ORACLE_ERROR, + eUSD_ORACLE_TIMEOUT, + eUSD_USD_FEED, +} from '../../../../test/plugins/individual-collateral/aerodrome/constants' + +// Convex Stable Plugin: crvUSD-USDC + +async function main() { + // ==== Read Configuration ==== + const [deployer] = await hre.ethers.getSigners() + + const chainId = await getChainId(hre) + + console.log(`Deploying Collateral to network ${hre.network.name} (${chainId}) + with burner account: ${deployer.address}`) + + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + // Get phase1 deployment + const phase1File = getDeploymentFilename(chainId) + if (!fileExists(phase1File)) { + throw new Error(`${phase1File} doesn't exist yet. Run phase 1`) + } + // Check previous step completed + const assetCollDeploymentFilename = getAssetCollDeploymentFilename(chainId) + const assetCollDeployments = getDeploymentFile(assetCollDeploymentFilename) + + const deployedCollateral: string[] = [] + + /******** Deploy Aerodrome Stable Pool for USDC-eUSD **************************/ + + let collateral: AerodromeStableCollateral + let wusdceusd: AerodromeGaugeWrapper + + // Only for Base + if (baseL2Chains.includes(hre.network.name)) { + const AerodromeStableCollateralFactory = await hre.ethers.getContractFactory( + 'AerodromeStableCollateral' + ) + const AerodromeGaugeWrapperFactory = await ethers.getContractFactory('AerodromeGaugeWrapper') + + // Deploy gauge wrapper + const pool = await ethers.getContractAt('IAeroPool', AERO_USDC_eUSD_POOL) + wusdceusd = ( + await AerodromeGaugeWrapperFactory.deploy( + pool.address, + 'w' + (await pool.name()), + 'w' + (await pool.symbol()), + AERO, + AERO_USDC_eUSD_GAUGE + ) + ) + await wusdceusd.deployed() + + console.log( + `Deployed wrapper for Aerodrome Stable USDC-eUSD pool on ${hre.network.name} (${chainId}): ${wusdceusd.address} ` + ) + + const oracleError = combinedError(USDC_ORACLE_ERROR, eUSD_ORACLE_ERROR) // 0.3% & 0.5% + + collateral = await AerodromeStableCollateralFactory.connect( + deployer + ).deploy( + { + erc20: wusdceusd.address, + targetName: ethers.utils.formatBytes32String('USD'), + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: ONE_ADDRESS, // unused but cannot be zero + oracleError: oracleError.toString(), // unused but cannot be zero + oracleTimeout: USDC_ORACLE_TIMEOUT, // max of oracleTimeouts + maxTradeVolume: MAX_TRADE_VOL, + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + }, + { + pool: AERO_USDC_eUSD_POOL, + poolType: AerodromePoolType.Stable, + feeds: [[USDC_USD_FEED], [eUSD_USD_FEED]], + oracleTimeouts: [[USDC_ORACLE_TIMEOUT], [eUSD_ORACLE_TIMEOUT]], + oracleErrors: [[USDC_ORACLE_ERROR], [eUSD_ORACLE_ERROR]], + } + ) + } else { + throw new Error(`Unsupported chainId: ${chainId}`) + } + + await collateral.deployed() + await (await collateral.refresh()).wait() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + console.log( + `Deployed Aerodrome Stable Collateral for USDC-eUSD to ${hre.network.name} (${chainId}): ${collateral.address}` + ) + + assetCollDeployments.collateral.aeroUSDCeUSD = collateral.address + assetCollDeployments.erc20s.aeroUSDCeUSD = wusdceusd.address + deployedCollateral.push(collateral.address.toString()) + + fs.writeFileSync(assetCollDeploymentFilename, JSON.stringify(assetCollDeployments, null, 2)) + + console.log(`Deployed collateral to ${hre.network.name} (${chainId}) + New deployments: ${deployedCollateral} + Deployment file: ${assetCollDeploymentFilename}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verification/assets/verify_aero.ts b/scripts/verification/assets/verify_aero.ts new file mode 100644 index 0000000000..fa41a2bb4f --- /dev/null +++ b/scripts/verification/assets/verify_aero.ts @@ -0,0 +1,49 @@ +import hre from 'hardhat' + +import { getChainId } from '../../../common/blockchain-utils' +import { developmentChains, networkConfig } from '../../../common/configuration' +import { + getAssetCollDeploymentFilename, + getDeploymentFile, + IAssetCollDeployments, +} from '../../deployment/common' +import { verifyContract } from '../../deployment/utils' +import { fp } from '../../../common/numbers' + +let deployments: IAssetCollDeployments + +async function main() { + // ********** Read config ********** + const chainId = await getChainId(hre) + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + if (developmentChains.includes(hre.network.name)) { + throw new Error(`Cannot verify contracts for development chain ${hre.network.name}`) + } + + deployments = getDeploymentFile(getAssetCollDeploymentFilename(chainId)) + + const asset = await hre.ethers.getContractAt('Asset', deployments.assets.AERO!) + + /** ******************** Verify AERO Asset ****************************************/ + await verifyContract( + chainId, + deployments.assets.AERO, + [ + (await asset.priceTimeout()).toString(), + await asset.chainlinkFeed(), + fp('0.005').toString(), + await asset.erc20(), + (await asset.maxTradeVolume()).toString(), + (await asset.oracleTimeout()).toString(), + ], + 'contracts/plugins/assets/Asset.sol:Asset' + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verification/collateral-plugins/verify_aerodrome_usdc_eusd.ts b/scripts/verification/collateral-plugins/verify_aerodrome_usdc_eusd.ts new file mode 100644 index 0000000000..0c0f434858 --- /dev/null +++ b/scripts/verification/collateral-plugins/verify_aerodrome_usdc_eusd.ts @@ -0,0 +1,102 @@ +import hre, { ethers } from 'hardhat' +import { getChainId } from '../../../common/blockchain-utils' +import { baseL2Chains, developmentChains, networkConfig } from '../../../common/configuration' +import { ONE_ADDRESS } from '../../../common/constants' +import { + getDeploymentFile, + getAssetCollDeploymentFilename, + IAssetCollDeployments, +} from '../../deployment/common' +import { verifyContract } from '../../deployment/utils' +import { combinedError } from '../../deployment/utils' +import { IAeroPool } from '@typechain/IAeroPool' +import { + AerodromePoolType, + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + MAX_TRADE_VOL, + PRICE_TIMEOUT, + AERO_USDC_eUSD_POOL, + AERO_USDC_eUSD_GAUGE, + AERO, + USDC_ORACLE_ERROR, + USDC_ORACLE_TIMEOUT, + USDC_USD_FEED, + eUSD_ORACLE_ERROR, + eUSD_ORACLE_TIMEOUT, + eUSD_USD_FEED, +} from '../../../test/plugins/individual-collateral/aerodrome/constants' + +let deployments: IAssetCollDeployments + +async function main() { + // ********** Read config ********** + const chainId = await getChainId(hre) + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + if (developmentChains.includes(hre.network.name)) { + throw new Error(`Cannot verify contracts for development chain ${hre.network.name}`) + } + + const assetCollDeploymentFilename = getAssetCollDeploymentFilename(chainId) + deployments = getDeploymentFile(assetCollDeploymentFilename) + + // Only on base, aways use wrapper + if (baseL2Chains.includes(hre.network.name)) { + const aeroUSDCeUSDPoolCollateral = await ethers.getContractAt( + 'AerodromeStableCollateral', + deployments.collateral.aeroUSDCeUSD as string + ) + + /******** Verify Gauge Wrapper **************************/ + + const pool = await ethers.getContractAt('IAeroPool', AERO_USDC_eUSD_POOL) + await verifyContract( + chainId, + await aeroUSDCeUSDPoolCollateral.erc20(), + [ + pool.address, + 'w' + (await pool.name()), + 'w' + (await pool.symbol()), + AERO, + AERO_USDC_eUSD_GAUGE, + ], + 'contracts/plugins/assets/aerodrome/AerodromeGaugeWrapper.sol:AerodromeGaugeWrapper' + ) + + /******** Verify USDC-eUSD plugin **************************/ + const oracleError = combinedError(USDC_ORACLE_ERROR, eUSD_ORACLE_ERROR) // 0.3% & 0.5% + await verifyContract( + chainId, + deployments.collateral.aeroUSDCeUSD, + [ + { + erc20: await aeroUSDCeUSDPoolCollateral.erc20(), + targetName: ethers.utils.formatBytes32String('USD'), + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: ONE_ADDRESS, // unused but cannot be zero + oracleError: oracleError.toString(), + oracleTimeout: USDC_ORACLE_TIMEOUT, // max of oracleTimeouts + maxTradeVolume: MAX_TRADE_VOL, + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + }, + { + pool: AERO_USDC_eUSD_POOL, + poolType: AerodromePoolType.Stable, + feeds: [[USDC_USD_FEED], [eUSD_USD_FEED]], + oracleTimeouts: [[USDC_ORACLE_TIMEOUT], [eUSD_ORACLE_TIMEOUT]], + oracleErrors: [[USDC_ORACLE_ERROR], [eUSD_ORACLE_ERROR]], + }, + ], + 'contracts/plugins/assets/aerodrome/AerodromeStableCollateral.sol:AerodromeStableCollateral' + ) + } +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verify_etherscan.ts b/scripts/verify_etherscan.ts index 812374ca18..f2d5950c2f 100644 --- a/scripts/verify_etherscan.ts +++ b/scripts/verify_etherscan.ts @@ -90,8 +90,9 @@ async function main() { 'collateral-plugins/verify_aave_v3_usdc.ts', 'collateral-plugins/verify_wsteth.ts', 'collateral-plugins/verify_cbeth.ts', + 'assets/verify_stg.ts', 'collateral-plugins/verify_morphoeUSD.ts', - 'assets/verify_stg.ts' + 'collateral-plugins/verify_aerodrome_usdc_eusd.ts' ) } else if (chainId == '42161' || chainId == '421614') { // Arbitrum One diff --git a/test/plugins/individual-collateral/aave-v3/common.ts b/test/plugins/individual-collateral/aave-v3/common.ts index 77e11f1be0..f9f22f6a21 100644 --- a/test/plugins/individual-collateral/aave-v3/common.ts +++ b/test/plugins/individual-collateral/aave-v3/common.ts @@ -210,6 +210,7 @@ export const makeTests = (defaultCollateralOpts: CollateralParams, altParams: Al itChecksRefPerTokDefault: it, itHasRevenueHiding: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itIsPricedByPeg: true, chainlinkDefaultAnswer: 1e8, itChecksPriceChanges: it, diff --git a/test/plugins/individual-collateral/aerodrome/AerodromeGaugeWrapper.test.ts b/test/plugins/individual-collateral/aerodrome/AerodromeGaugeWrapper.test.ts new file mode 100644 index 0000000000..652aa034fc --- /dev/null +++ b/test/plugins/individual-collateral/aerodrome/AerodromeGaugeWrapper.test.ts @@ -0,0 +1,387 @@ +import { networkConfig } from '#/common/configuration' +import { useEnv } from '#/utils/env' +import hre, { BigNumber, ethers } from 'hardhat' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { makeWUSDCeUSD, mintLpToken, mintWrappedLpToken, resetFork } from './helpers' +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' +import { + IAeroPool, + ERC20Mock, + AerodromeGaugeWrapper__factory, + AerodromeGaugeWrapper, + IAeroGauge, +} from '@typechain/index' +import { expect } from 'chai' +import { ZERO_ADDRESS } from '#/common/constants' +import { + forkNetwork, + AERO, + eUSD, + AERO_USDC_eUSD_GAUGE, + AERO_USDC_eUSD_POOL, + AERO_USDC_eUSD_HOLDER, +} from './constants' +import { bn, fp } from '#/common/numbers' +import { getChainId } from '#/common/blockchain-utils' +import { advanceTime } from '#/test/utils/time' + +const describeFork = useEnv('FORK') && forkNetwork == 'base' ? describe : describe.skip + +const point1Pct = (value: BigNumber): BigNumber => { + return value.div(1000) +} +describeFork('Aerodrome Gauge Wrapper', () => { + let bob: SignerWithAddress + let charles: SignerWithAddress + let don: SignerWithAddress + let token0: ERC20Mock + let token1: ERC20Mock + let aero: ERC20Mock + let gauge: IAeroGauge + let wrapper: AerodromeGaugeWrapper + let lpToken: IAeroPool + let AerodromeGaugeWrapperFactory: AerodromeGaugeWrapper__factory + + let chainId: number + + before(async () => { + await resetFork() + + chainId = await getChainId(hre) + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + + AerodromeGaugeWrapperFactory = ( + await ethers.getContractFactory('AerodromeGaugeWrapper') + ) + }) + + beforeEach(async () => { + ;[, bob, charles, don] = await ethers.getSigners() + ;({ token0, token1, wrapper, lpToken } = await loadFixture(makeWUSDCeUSD)) + gauge = await ethers.getContractAt('IAeroGauge', AERO_USDC_eUSD_GAUGE) + }) + + describe('Deployment', () => { + it('reverts if deployed with a 0 address for AERO token or staking contract', async () => { + await expect( + AerodromeGaugeWrapperFactory.deploy( + AERO_USDC_eUSD_POOL, + await wrapper.name(), + await wrapper.symbol(), + ZERO_ADDRESS, + AERO_USDC_eUSD_GAUGE + ) + ).to.be.reverted + + await expect( + AerodromeGaugeWrapperFactory.deploy( + AERO_USDC_eUSD_POOL, + await wrapper.name(), + await wrapper.symbol(), + AERO, + ZERO_ADDRESS + ) + ).to.be.reverted + }) + + it('reverts if deployed with invalid pool', async () => { + await expect( + AerodromeGaugeWrapperFactory.deploy( + ZERO_ADDRESS, + await wrapper.name(), + await wrapper.symbol(), + AERO, + AERO_USDC_eUSD_GAUGE + ) + ).to.be.reverted + }) + + it('reverts if deployed with invalid AERO token', async () => { + const INVALID_AERO = eUSD // mock (any erc20) + await expect( + AerodromeGaugeWrapperFactory.deploy( + AERO_USDC_eUSD_POOL, + await wrapper.name(), + await wrapper.symbol(), + INVALID_AERO, + AERO_USDC_eUSD_GAUGE + ) + ).to.be.revertedWith('wrong Aero') + }) + }) + + describe('Deposit', () => { + const amount = fp('0.02') + + beforeEach(async () => { + await mintLpToken(gauge, lpToken, amount, AERO_USDC_eUSD_HOLDER, bob.address) + await lpToken.connect(bob).approve(wrapper.address, ethers.constants.MaxUint256) + }) + + it('deposits correct amount', async () => { + const balanceInLPPrev = await lpToken.balanceOf(bob.address) + + await wrapper.connect(bob).deposit(await lpToken.balanceOf(bob.address), bob.address) + + expect(await lpToken.balanceOf(bob.address)).to.equal(0) + expect(await wrapper.balanceOf(bob.address)).to.equal(balanceInLPPrev) + }) + + it('deposits less than available', async () => { + const depositAmount = await lpToken.balanceOf(bob.address).then((e) => e.div(2)) + + await wrapper.connect(bob).deposit(depositAmount, bob.address) + + expect(await lpToken.balanceOf(bob.address)).to.be.closeTo(depositAmount, 10) + expect(await wrapper.balanceOf(bob.address)).to.closeTo(depositAmount, 10) + }) + + it('has accurate balances when doing multiple deposits', async () => { + const depositAmount = await lpToken.balanceOf(bob.address) + await wrapper.connect(bob).deposit(depositAmount.mul(3).div(4), bob.address) + + await advanceTime(1000) + await wrapper.connect(bob).deposit(depositAmount.mul(1).div(4), bob.address) + + expect(await wrapper.balanceOf(bob.address)).to.closeTo(depositAmount, 10) + }) + + it('updates the totalSupply', async () => { + const totalSupplyBefore = await wrapper.totalSupply() + const expectedAmount = await lpToken.balanceOf(bob.address) + + await wrapper.connect(bob).deposit(expectedAmount, bob.address) + expect(await wrapper.totalSupply()).to.equal(totalSupplyBefore.add(expectedAmount)) + }) + + it('handles deposits with 0 amount', async () => { + const balanceInLPPrev = await lpToken.balanceOf(bob.address) + + await expect(wrapper.connect(bob).deposit(0, bob.address)).to.not.be.reverted + + expect(await lpToken.balanceOf(bob.address)).to.equal(balanceInLPPrev) + expect(await wrapper.balanceOf(bob.address)).to.equal(0) + }) + }) + + describe('Withdraw', () => { + const initAmt = fp('0.02') + + beforeEach(async () => { + await mintWrappedLpToken( + wrapper, + gauge, + lpToken, + initAmt, + AERO_USDC_eUSD_HOLDER, + bob, + bob.address + ) + await mintWrappedLpToken( + wrapper, + gauge, + lpToken, + initAmt, + AERO_USDC_eUSD_HOLDER, + charles, + charles.address + ) + }) + + it('withdraws to own account', async () => { + const initialBal = await wrapper.balanceOf(bob.address) + await wrapper.connect(bob).withdraw(await wrapper.balanceOf(bob.address), bob.address) + const finalBal = await wrapper.balanceOf(bob.address) + + expect(finalBal).to.closeTo(bn('0'), 10) + expect(await lpToken.balanceOf(bob.address)).to.closeTo(initialBal, 10) + }) + + it('withdraws all balance via multiple withdrawals', async () => { + const initialBalance = await wrapper.balanceOf(bob.address) + + const withdrawAmt = initialBalance.div(2) + await wrapper.connect(bob).withdraw(withdrawAmt, bob.address) + expect(await wrapper.balanceOf(bob.address)).to.closeTo(initialBalance.sub(withdrawAmt), 0) + + await advanceTime(1000) + + await wrapper.connect(bob).withdraw(withdrawAmt, bob.address) + expect(await wrapper.balanceOf(bob.address)).to.closeTo(bn('0'), 10) + }) + + it('handles complex withdrawal sequence', async () => { + let bobWithdrawn = bn('0') + let charlesWithdrawn = bn('0') + let donWithdrawn = bn('0') + + const firstWithdrawAmt = await wrapper.balanceOf(charles.address).then((e) => e.div(2)) + + charlesWithdrawn = charlesWithdrawn.add(firstWithdrawAmt) + + await wrapper.connect(charles).withdraw(firstWithdrawAmt, charles.address) + const newBalanceCharles = await lpToken.balanceOf(charles.address) + expect(newBalanceCharles).to.closeTo(firstWithdrawAmt, 10) + + // don deposits + await mintWrappedLpToken( + wrapper, + gauge, + lpToken, + initAmt, + AERO_USDC_eUSD_HOLDER, + don, + don.address + ) + + // bob withdraws SOME + bobWithdrawn = bobWithdrawn.add(bn('12345e6')) + await wrapper.connect(bob).withdraw(bn('12345e6'), bob.address) + + // don withdraws SOME + donWithdrawn = donWithdrawn.add(bn('123e6')) + await wrapper.connect(don).withdraw(bn('123e6'), don.address) + + // charles withdraws ALL + const charlesRemainingBalance = await wrapper.balanceOf(charles.address) + charlesWithdrawn = charlesWithdrawn.add(charlesRemainingBalance) + await wrapper.connect(charles).withdraw(charlesRemainingBalance, charles.address) + + // don withdraws ALL + const donRemainingBalance = await wrapper.balanceOf(don.address) + donWithdrawn = donWithdrawn.add(donRemainingBalance) + await wrapper.connect(don).withdraw(donRemainingBalance, don.address) + + // bob withdraws ALL + const bobRemainingBalance = await wrapper.balanceOf(bob.address) + bobWithdrawn = bobWithdrawn.add(bobRemainingBalance) + await wrapper.connect(bob).withdraw(bobRemainingBalance, bob.address) + + const bal = await wrapper.balanceOf(bob.address) + + expect(bal).to.closeTo(bn('0'), 10) + expect(await lpToken.balanceOf(bob.address)).to.closeTo(bobWithdrawn, 100) + expect(await lpToken.balanceOf(charles.address)).to.closeTo(charlesWithdrawn, 100) + expect(await lpToken.balanceOf(don.address)).to.closeTo(donWithdrawn, 100) + }) + + it('updates the totalSupply', async () => { + const totalSupplyBefore = await wrapper.totalSupply() + const withdrawAmt = bn('15000e6') + const expectedDiff = withdrawAmt + await wrapper.connect(bob).withdraw(withdrawAmt, bob.address) + + expect(await wrapper.totalSupply()).to.be.closeTo(totalSupplyBefore.sub(expectedDiff), 10) + }) + }) + + describe('Rewards', () => { + const initialAmount = fp('0.02') + + beforeEach(async () => { + aero = await ethers.getContractAt('ERC20Mock', AERO) + }) + + it('claims rewards from Aerodrome', async () => { + await mintWrappedLpToken( + wrapper, + gauge, + lpToken, + initialAmount, + AERO_USDC_eUSD_HOLDER, + bob, + bob.address + ) + + const initialAeroBal = await aero.balanceOf(wrapper.address) + + await advanceTime(1000) + + let expectedRewards = await gauge.earned(wrapper.address) + await wrapper.claimRewards() + expect(await gauge.earned(wrapper.address)).to.equal(0) // all claimed + + const updatedAeroBal = await aero.balanceOf(wrapper.address) + expect(updatedAeroBal).to.be.gt(initialAeroBal) + expect(updatedAeroBal.sub(initialAeroBal)).to.be.closeTo( + expectedRewards, + point1Pct(expectedRewards) + ) + + await advanceTime(1000) + + expectedRewards = await gauge.earned(wrapper.address) + await wrapper.claimRewards() + expect(await gauge.earned(wrapper.address)).to.equal(0) // all claimed + + const finalAeroBal = await aero.balanceOf(wrapper.address) + expect(finalAeroBal).to.be.gt(updatedAeroBal) + expect(finalAeroBal.sub(updatedAeroBal)).to.be.closeTo( + expectedRewards, + point1Pct(expectedRewards) + ) + }) + + it('distributes rewards to holder', async () => { + expect(await aero.balanceOf(bob.address)).to.equal(0) + expect(await aero.balanceOf(don.address)).to.equal(0) + + // deposit with bob + await mintWrappedLpToken( + wrapper, + gauge, + lpToken, + initialAmount, + AERO_USDC_eUSD_HOLDER, + bob, + bob.address + ) + + await advanceTime(1000) + + // sync rewards + await wrapper.connect(bob).claimRewards() + + let expectedRewardsBob = await wrapper.accumulatedRewards(bob.address) + + // bob can claim and get rewards + await wrapper.connect(bob).claimRewards() + expect(await aero.balanceOf(bob.address)).to.be.gt(0) + expect(await aero.balanceOf(bob.address)).to.be.closeTo( + expectedRewardsBob, + point1Pct(expectedRewardsBob) + ) + + // don does not have rewards + await wrapper.connect(don).claimRewards() + expect(await aero.balanceOf(don.address)).to.equal(0) + + // transfer some tokens to don + const balToTransfer = (await wrapper.balanceOf(bob.address)).div(2) + await wrapper.connect(bob).transfer(don.address, balToTransfer) + + await advanceTime(1000) + + // Now both have rewards + await wrapper.connect(bob).claimRewards() + expectedRewardsBob = await wrapper.accumulatedRewards(bob.address) + expect(await aero.balanceOf(bob.address)).to.be.closeTo( + expectedRewardsBob, + point1Pct(expectedRewardsBob) + ) + + // Don also gets rewards + await wrapper.connect(don).claimRewards() + const expectedRewardsDon = await wrapper.accumulatedRewards(don.address) + expect(await aero.balanceOf(don.address)).to.be.gt(0) + expect(await aero.balanceOf(don.address)).to.be.closeTo( + expectedRewardsDon, + point1Pct(expectedRewardsDon) + ) + }) + }) + + // TODO: Aerodrome exceptions? +}) diff --git a/test/plugins/individual-collateral/aerodrome/AerodromeStableCollateral.test.ts b/test/plugins/individual-collateral/aerodrome/AerodromeStableCollateral.test.ts new file mode 100644 index 0000000000..bd6c9e5185 --- /dev/null +++ b/test/plugins/individual-collateral/aerodrome/AerodromeStableCollateral.test.ts @@ -0,0 +1,465 @@ +import collateralTests from '../collateralTests' +import { CollateralFixtureContext, CollateralOpts, MintCollateralFunc } from '../pluginTestTypes' +import { ethers } from 'hardhat' +import { ContractFactory, BigNumberish, BigNumber } from 'ethers' +import { + IAeroPool, + MockV3Aggregator, + MockV3Aggregator__factory, + InvalidMockV3Aggregator, + AerodromeGaugeWrapper__factory, + TestICollateral, + AerodromeGaugeWrapper, + ERC20Mock, +} from '../../../../typechain' +import { networkConfig } from '../../../../common/configuration' +import { CollateralStatus, ZERO_ADDRESS } from '#/common/constants' +import { bn, fp } from '../../../../common/numbers' +import { expect } from 'chai' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { + AerodromePoolType, + USDC_USD_FEED, + USDC_HOLDER, + USDC_ORACLE_ERROR, + USDC_ORACLE_TIMEOUT, + PRICE_TIMEOUT, + MAX_TRADE_VOL, + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + AERO_USDC_eUSD_POOL, + AERO_USDC_eUSD_GAUGE, + AERO_USDC_eUSD_HOLDER, + AERO, + USDC, + eUSD, + eUSD_HOLDER, + eUSD_USD_FEED, + eUSD_ORACLE_ERROR, + eUSD_ORACLE_TIMEOUT, + ORACLE_ERROR, +} from './constants' +import { expectPrice } from '../../../utils/oracles' +import { mintWrappedLpToken, resetFork, getFeeds, pushAllFeedsForward } from './helpers' + +/* + Define interfaces +*/ + +interface AeroPoolTokenConfig { + token: string + feeds: string[] + oracleTimeouts: BigNumberish[] + oracleErrors: BigNumberish[] + holder: string +} + +interface AeroStablePoolEnumeration { + testName: string + pool: string + gauge: string + holder: string + toleranceDivisor: BigNumber + amountScaleDivisor: BigNumber + tokens: AeroPoolTokenConfig[] + oracleTimeout: BigNumberish + oracleError: BigNumberish +} + +interface AeroStableCollateralOpts extends CollateralOpts { + pool?: string + poolType?: AerodromePoolType + gauge?: string + feeds?: string[][] + oracleTimeouts?: BigNumberish[][] + oracleErrors?: BigNumberish[][] +} + +interface AerodromeCollateralFixtureContext extends CollateralFixtureContext { + feeds?: string[][] +} + +// ==== + +const config = networkConfig['8453'] // use Base fork + +// Test all Aerodrome Stable pools +const all: AeroStablePoolEnumeration[] = [ + { + testName: 'Aerodrome - USDC/eUSD Stable', + pool: AERO_USDC_eUSD_POOL, + gauge: AERO_USDC_eUSD_GAUGE, + holder: AERO_USDC_eUSD_HOLDER, + tokens: [ + { + token: USDC, + feeds: [USDC_USD_FEED], + oracleTimeouts: [USDC_ORACLE_TIMEOUT], + oracleErrors: [USDC_ORACLE_ERROR], + holder: USDC_HOLDER, + }, + { + token: eUSD, + feeds: [eUSD_USD_FEED], + oracleTimeouts: [eUSD_ORACLE_TIMEOUT], + oracleErrors: [eUSD_ORACLE_ERROR], + holder: eUSD_HOLDER, + }, + ], + oracleTimeout: PRICE_TIMEOUT, // max + oracleError: ORACLE_ERROR, // combined + amountScaleDivisor: bn('1e2'), + toleranceDivisor: bn('1e2'), + }, +] + +all.forEach((curr: AeroStablePoolEnumeration) => { + const defaultCollateralOpts: AeroStableCollateralOpts = { + erc20: ZERO_ADDRESS, + targetName: ethers.utils.formatBytes32String('USD'), + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: curr.tokens[0].feeds[0], // unused but cannot be zero + oracleTimeout: curr.oracleTimeout, // max of oracleTimeouts + oracleError: curr.oracleError, // combined oracle error + maxTradeVolume: MAX_TRADE_VOL, + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + pool: curr.pool, + poolType: AerodromePoolType.Stable, + gauge: curr.gauge, + feeds: [curr.tokens[0].feeds, curr.tokens[1].feeds], + oracleTimeouts: [curr.tokens[0].oracleTimeouts, curr.tokens[1].oracleTimeouts], + oracleErrors: [curr.tokens[0].oracleErrors, curr.tokens[1].oracleErrors], + } + + const deployCollateral = async ( + opts: AeroStableCollateralOpts = {} + ): Promise => { + let pool: IAeroPool + let wrapper: AerodromeGaugeWrapper + + if (!opts.erc20) { + const AerodromGaugeWrapperFactory = ( + await ethers.getContractFactory('AerodromeGaugeWrapper') + ) + + // Create wrapper + pool = await ethers.getContractAt('IAeroPool', curr.pool) + + wrapper = await AerodromGaugeWrapperFactory.deploy( + pool.address, + 'w' + (await pool.name()), + 'w' + (await pool.symbol()), + AERO, + curr.gauge + ) + + opts.erc20 = wrapper.address + } + + opts = { ...defaultCollateralOpts, ...opts } + + const AeroStableCollateralFactory: ContractFactory = await ethers.getContractFactory( + 'AerodromeStableCollateral' + ) + + const collateral = await AeroStableCollateralFactory.deploy( + { + erc20: opts.erc20, + targetName: opts.targetName, + priceTimeout: opts.priceTimeout, + chainlinkFeed: opts.chainlinkFeed, + oracleError: opts.oracleError, + oracleTimeout: opts.oracleTimeout, + maxTradeVolume: opts.maxTradeVolume, + defaultThreshold: opts.defaultThreshold, + delayUntilDefault: opts.delayUntilDefault, + }, + { + pool: opts.pool, + poolType: opts.poolType, + feeds: opts.feeds, + oracleTimeouts: opts.oracleTimeouts, + oracleErrors: opts.oracleErrors, + }, + { gasLimit: 2000000000 } + ) + await collateral.deployed() + + // Push forward chainlink feeds + await pushAllFeedsForward(collateral) + + // sometimes we are trying to test a negative test case and we want this to fail silently + // fortunately this syntax fails silently because our tools are terrible + await expect(collateral.refresh()) + + return collateral + } + + type Fixture = () => Promise + + const makeCollateralFixtureContext = ( + alice: SignerWithAddress, + opts: AeroStableCollateralOpts = {} + ): Fixture => { + const collateralOpts = { ...defaultCollateralOpts, ...opts } + + const makeCollateralFixtureContext = async () => { + const MockV3AggregatorFactory = ( + await ethers.getContractFactory('MockV3Aggregator') + ) + + // Substitute both feeds + const token0Feed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + collateralOpts.chainlinkFeed = token0Feed.address + + const token1Feed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + collateralOpts.feeds = [[token0Feed.address], [token1Feed.address]] + + const pool = await ethers.getContractAt('IAeroPool', curr.pool) + + const AerodromeGaugeWrapperFactory = ( + await ethers.getContractFactory('AerodromeGaugeWrapper') + ) + + const wrapper = await AerodromeGaugeWrapperFactory.deploy( + pool.address, + 'w' + (await pool.name()), + 'w' + (await pool.symbol()), + AERO, + curr.gauge + ) + + collateralOpts.erc20 = wrapper.address + + const collateral = await deployCollateral(collateralOpts) + const erc20 = await ethers.getContractAt( + 'AerodromeGaugeWrapper', + (await collateral.erc20()) as string + ) + + const rewardToken = await ethers.getContractAt('ERC20Mock', AERO) + + return { + alice, + collateral, + chainlinkFeed: token0Feed, + tok: erc20, + rewardToken, + } + } + + return makeCollateralFixtureContext + } + + /* + Define helper functions +*/ + + const mintCollateralTo: MintCollateralFunc = async ( + ctx: CollateralFixtureContext, + amount: BigNumberish, + user: SignerWithAddress, + recipient: string + ) => { + const gauge = await ethers.getContractAt('IAeroGauge', curr.gauge) + const pool = await ethers.getContractAt('IAeroPool', curr.pool) + + await mintWrappedLpToken( + ctx.tok as AerodromeGaugeWrapper, + gauge, + pool, + amount, + curr.holder, + user, + recipient + ) + } + + const reduceTargetPerRef = async (ctx: CollateralFixtureContext, pctDecrease: BigNumberish) => { + const allFeeds = await getFeeds(ctx.collateral) + const initialPrices = await Promise.all(allFeeds.map((f) => f.latestRoundData())) + for (const [i, feed] of allFeeds.entries()) { + const nextAnswer = initialPrices[i].answer.sub( + initialPrices[i].answer.mul(pctDecrease).div(100) + ) + await feed.updateAnswer(nextAnswer) + } + } + + const increaseTargetPerRef = async (ctx: CollateralFixtureContext, pctIncrease: BigNumberish) => { + // Update values in Oracles increase by 10% + const allFeeds = await getFeeds(ctx.collateral) + const initialPrices = await Promise.all(allFeeds.map((f) => f.latestRoundData())) + for (const [i, feed] of allFeeds.entries()) { + const nextAnswer = initialPrices[i].answer.add( + initialPrices[i].answer.mul(pctIncrease).div(100) + ) + await feed.updateAnswer(nextAnswer) + } + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + const increaseRefPerTok = async (ctx: CollateralFixtureContext, pctIncrease: BigNumberish) => {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + const reduceRefPerTok = async (ctx: CollateralFixtureContext, pctDecrease: BigNumberish) => {} + + // eslint-disable-next-line @typescript-eslint/no-empty-function + const collateralSpecificConstructorTests = () => {} + + const collateralSpecificStatusTests = () => { + it('prices change as feed price changes', async () => { + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + const feed0 = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + const feed1 = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + + const coll = await deployCollateral({ + pool: curr.pool, + gauge: curr.gauge, + feeds: [[feed0.address], [feed1.address]], + }) + + const initialRefPerTok = await coll.refPerTok() + const [low, high] = await coll.price() + + // Update values in Oracles increase by 10% + const allFeeds = await getFeeds(coll) + const initialPrices = await Promise.all(allFeeds.map((f) => f.latestRoundData())) + for (const [i, feed] of allFeeds.entries()) { + await feed.updateAnswer(initialPrices[i].answer.mul(110).div(100)).then((e) => e.wait()) + } + + const [newLow, newHigh] = await coll.price() + + // with 18 decimals of price precision a 1e-9 tolerance seems fine for a 10% change + expect(newLow).to.be.closeTo(low.mul(110).div(100), fp('1e-9')) + expect(newHigh).to.be.closeTo(high.mul(110).div(100), fp('1e-9')) + + // Check refPerTok remains the same + const finalRefPerTok = await coll.refPerTok() + expect(finalRefPerTok).to.equal(initialRefPerTok) + }) + + it('prices change as targetPerRef changes', async () => { + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + const feed0 = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + const feed1 = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + + const coll = await deployCollateral({ + pool: curr.pool, + gauge: curr.gauge, + feeds: [[feed0.address], [feed1.address]], + }) + + const tok = await ethers.getContractAt('IERC20Metadata', await coll.erc20()) + const tempCtx = { collateral: coll, chainlinkFeed: feed0, tok } + + const oracleError = await coll.oracleError() + const expectedPrice = await getExpectedPrice(tempCtx) + await expectPrice(coll.address, expectedPrice, oracleError, true, curr.toleranceDivisor) + + // Get refPerTok initial values + const initialRefPerTok = await coll.refPerTok() + const [oldLow, oldHigh] = await coll.price() + + // Update values in Oracles increase by 10-20% + await increaseTargetPerRef(tempCtx, 20) + + // Check new prices -- increase expected + const newPrice = await getExpectedPrice(tempCtx) + await expectPrice(coll.address, newPrice, oracleError, true, curr.toleranceDivisor) + const [newLow, newHigh] = await coll.price() + expect(oldLow).to.be.lt(newLow) + expect(oldHigh).to.be.lt(newHigh) + + // Check refPerTok remains the same + const finalRefPerTok = await coll.refPerTok() + expect(finalRefPerTok).to.equal(initialRefPerTok) + }) + + it('reverts if Chainlink feed reverts or runs out of gas, maintains status', async () => { + const InvalidMockV3AggregatorFactory = await ethers.getContractFactory( + 'InvalidMockV3Aggregator' + ) + const invalidChainlinkFeed = ( + await InvalidMockV3AggregatorFactory.deploy(6, bn('1e6')) + ) + + const invalidCollateral = await deployCollateral({ + pool: curr.pool, + gauge: curr.gauge, + feeds: [[invalidChainlinkFeed.address], [invalidChainlinkFeed.address]], + }) + + // Reverting with no reason + await invalidChainlinkFeed.setSimplyRevert(true) + await expect(invalidCollateral.refresh()).to.be.revertedWithoutReason() + expect(await invalidCollateral.status()).to.equal(CollateralStatus.SOUND) + + // Runnning out of gas (same error) + await invalidChainlinkFeed.setSimplyRevert(false) + await expect(invalidCollateral.refresh()).to.be.revertedWithoutReason() + expect(await invalidCollateral.status()).to.equal(CollateralStatus.SOUND) + }) + } + + const getExpectedPrice = async (ctx: CollateralFixtureContext) => { + const initRefPerTok = await ctx.collateral.refPerTok() + const coll = await ethers.getContractAt('AerodromeStableCollateral', ctx.collateral.address) + + const feed0 = await ethers.getContractAt('MockV3Aggregator', (await coll.tokenFeeds(0))[0]) + const decimals0 = await feed0.decimals() + const initData0 = await feed0.latestRoundData() + + const feed1 = await ethers.getContractAt('MockV3Aggregator', (await coll.tokenFeeds(1))[0]) + const decimals1 = await feed1.decimals() + const initData1 = await feed1.latestRoundData() + + const avgPrice = initData0.answer + .mul(bn(10).pow(18 - decimals0)) + .add(initData1.answer.mul(bn(10).pow(18 - decimals1))) + .div(2) + + return avgPrice.mul(initRefPerTok).div(fp('1')) + } + + /* + Run the test suite + */ + + const emptyFn = () => { + return + } + + const opts = { + deployCollateral, + collateralSpecificConstructorTests, + collateralSpecificStatusTests, + beforeEachRewardsTest: emptyFn, + makeCollateralFixtureContext, + mintCollateralTo, + reduceTargetPerRef, + increaseTargetPerRef, + reduceRefPerTok, + increaseRefPerTok, + getExpectedPrice, + itClaimsRewards: it, + itChecksTargetPerRefDefault: it, + itChecksTargetPerRefDefaultUp: it, + itChecksRefPerTokDefault: it.skip, + itChecksPriceChanges: it.skip, + itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it.skip, + itHasRevenueHiding: it.skip, + resetFork, + collateralName: curr.testName, + chainlinkDefaultAnswer: bn('1e8'), + itIsPricedByPeg: true, + toleranceDivisor: curr.toleranceDivisor, + amountScaleDivisor: curr.amountScaleDivisor, + targetNetwork: 'base', + } + + collateralTests(opts) +}) diff --git a/test/plugins/individual-collateral/aerodrome/constants.ts b/test/plugins/individual-collateral/aerodrome/constants.ts new file mode 100644 index 0000000000..d8a6e7dad3 --- /dev/null +++ b/test/plugins/individual-collateral/aerodrome/constants.ts @@ -0,0 +1,44 @@ +import { bn, fp } from '../../../../common/numbers' +import { networkConfig } from '../../../../common/configuration' +import { useEnv } from '#/utils/env' + +export const forkNetwork = useEnv('FORK_NETWORK') ?? 'base' + +// Base Addresses +export const AERO_USDC_eUSD_GAUGE = '0x793F22aB88dC91793E5Ce6ADbd7E733B0BD4733e' +export const AERO_USDC_eUSD_POOL = '0x7A034374C89C463DD65D8C9BCfe63BcBCED41f4F' +export const AERO_USDC_eUSD_HOLDER = '0xB6C8ea53ABA64a4BdE857D3b25d9DEbD0B149a0a' + +export const AERODROME_ROUTER = '0xcF77a3Ba9A5CA399B7c97c74d54e5b1Beb874E43' + +// Tokens +export const USDC = networkConfig['8453'].tokens.USDC! +export const eUSD = networkConfig['8453'].tokens.eUSD! +export const AERO = networkConfig['8453'].tokens.AERO! + +// USDC +export const USDC_USD_FEED = networkConfig['8453'].chainlinkFeeds.USDC! +export const USDC_ORACLE_TIMEOUT = bn('86400') +export const USDC_ORACLE_ERROR = fp('0.003') +export const USDC_HOLDER = '0x3304E22DDaa22bCdC5fCa2269b418046aE7b566A' + +// eUSD +export const eUSD_USD_FEED = networkConfig['8453'].chainlinkFeeds.eUSD! +export const eUSD_ORACLE_TIMEOUT = bn('86400') +export const eUSD_ORACLE_ERROR = fp('0.005') +export const eUSD_HOLDER = '0xb5E331615FdbA7DF49e05CdEACEb14Acdd5091c3' + +export const FORK_BLOCK = 19074500 + +// Common +export const FIX_ONE = 1n * 10n ** 18n +export const ORACLE_ERROR = fp('0.005') +export const PRICE_TIMEOUT = bn('604800') // 1 week +export const DEFAULT_THRESHOLD = fp('0.02') // 2% +export const DELAY_UNTIL_DEFAULT = bn('259200') // 72h +export const MAX_TRADE_VOL = fp('1e6') + +export enum AerodromePoolType { + Stable, + Volatile, +} diff --git a/test/plugins/individual-collateral/aerodrome/helpers.ts b/test/plugins/individual-collateral/aerodrome/helpers.ts new file mode 100644 index 0000000000..5a2de4312c --- /dev/null +++ b/test/plugins/individual-collateral/aerodrome/helpers.ts @@ -0,0 +1,103 @@ +import { ERC20Mock } from '@typechain/ERC20Mock' +import { + IAeroPool, + IAeroGauge, + AerodromeGaugeWrapper__factory, + AerodromeGaugeWrapper, + TestICollateral, + MockV3Aggregator, +} from '@typechain/index' +import { ethers } from 'hardhat' +import { + USDC, + eUSD, + AERO, + AERO_USDC_eUSD_POOL, + AERO_USDC_eUSD_GAUGE, + FORK_BLOCK, +} from './constants' +import { getResetFork } from '../helpers' +import { pushOracleForward } from '../../../utils/oracles' +import { whileImpersonating } from '#/test/utils/impersonation' +import { ZERO_ADDRESS } from '#/common/constants' + +interface WrappedAeroFixture { + token0: ERC20Mock + token1: ERC20Mock + wrapper: AerodromeGaugeWrapper + lpToken: IAeroPool +} + +export const makeWUSDCeUSD = async (sAMM_usdc_eUSD?: string): Promise => { + const lpToken = ( + await ethers.getContractAt('IAeroPool', sAMM_usdc_eUSD ?? AERO_USDC_eUSD_POOL) + ) + + const AerodromGaugeWrapperFactory = ( + await ethers.getContractFactory('AerodromeGaugeWrapper') + ) + + const wrapper = await AerodromGaugeWrapperFactory.deploy( + lpToken.address, + 'w' + (await lpToken.name()), + 'w' + (await lpToken.symbol()), + AERO, + AERO_USDC_eUSD_GAUGE + ) + const token0 = await ethers.getContractAt('ERC20Mock', USDC) + const token1 = await ethers.getContractAt('ERC20Mock', eUSD) + + return { token0, token1, wrapper, lpToken } +} + +export const mintLpToken = async ( + gauge: IAeroGauge, + lpToken: IAeroPool, + amount: BigNumberish, + holder: string, + recipient: string +) => { + await whileImpersonating(holder, async (signer) => { + await gauge.connect(signer).withdraw(amount) + await lpToken.connect(signer).transfer(recipient, amount) + }) +} + +export const mintWrappedLpToken = async ( + wrapper: AerodromeGaugeWrapper, + gauge: IAeroGauge, + lpToken: IAeroPool, + amount: BigNumberish, + holder: string, + user: SignerWithAddress, + recipient: string +) => { + await mintLpToken(gauge, lpToken, amount, holder, user.address) + await lpToken.connect(user).approve(wrapper.address, ethers.constants.MaxUint256) + await wrapper.connect(user).deposit(amount, recipient) +} + +export const getFeeds = async (coll: TestICollateral): Promise => { + const aeroStableColl = await ethers.getContractAt('AerodromeStableCollateral', coll.address) + + const feedAddrs = (await aeroStableColl.tokenFeeds(0)).concat(await aeroStableColl.tokenFeeds(1)) + const feeds: MockV3Aggregator[] = [] + + for (const feedAddr of feedAddrs) { + if (feedAddr != ZERO_ADDRESS) { + const oracle = await ethers.getContractAt('MockV3Aggregator', feedAddr) + feeds.push(oracle) + } + } + + return feeds +} + +export const pushAllFeedsForward = async (coll: TestICollateral) => { + const feeds = await getFeeds(coll) + for (const oracle of feeds) { + await pushOracleForward(oracle.address) + } +} + +export const resetFork = getResetFork(FORK_BLOCK) diff --git a/test/plugins/individual-collateral/ankr/AnkrEthCollateralTestSuite.test.ts b/test/plugins/individual-collateral/ankr/AnkrEthCollateralTestSuite.test.ts index 8a3a07a83f..ed9bae870c 100644 --- a/test/plugins/individual-collateral/ankr/AnkrEthCollateralTestSuite.test.ts +++ b/test/plugins/individual-collateral/ankr/AnkrEthCollateralTestSuite.test.ts @@ -291,6 +291,7 @@ const opts = { itChecksPriceChanges: it, itHasRevenueHiding: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, resetFork, collateralName: 'AnkrStakedETH', chainlinkDefaultAnswer, diff --git a/test/plugins/individual-collateral/cbeth/CBETHCollateral.test.ts b/test/plugins/individual-collateral/cbeth/CBETHCollateral.test.ts index cd35ee5c0c..6dea339bce 100644 --- a/test/plugins/individual-collateral/cbeth/CBETHCollateral.test.ts +++ b/test/plugins/individual-collateral/cbeth/CBETHCollateral.test.ts @@ -248,6 +248,7 @@ const opts = { itChecksPriceChanges: it, itHasRevenueHiding: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, resetFork, collateralName: 'CBEthCollateral', chainlinkDefaultAnswer, diff --git a/test/plugins/individual-collateral/cbeth/CBETHCollateralL2.test.ts b/test/plugins/individual-collateral/cbeth/CBETHCollateralL2.test.ts index a4a9c32425..7e1a28744c 100644 --- a/test/plugins/individual-collateral/cbeth/CBETHCollateralL2.test.ts +++ b/test/plugins/individual-collateral/cbeth/CBETHCollateralL2.test.ts @@ -279,6 +279,7 @@ const opts = { itChecksPriceChanges: it, itHasRevenueHiding: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, resetFork, collateralName: 'CBEthCollateralL2', chainlinkDefaultAnswer, diff --git a/test/plugins/individual-collateral/collateralTests.ts b/test/plugins/individual-collateral/collateralTests.ts index 47f89b767f..1d2fb32dfb 100644 --- a/test/plugins/individual-collateral/collateralTests.ts +++ b/test/plugins/individual-collateral/collateralTests.ts @@ -93,12 +93,14 @@ export default function fn( itChecksRefPerTokDefault, itChecksPriceChanges, itChecksNonZeroDefaultThreshold, + itChecksMainChainlinkOracleRevert, itHasRevenueHiding, itIsPricedByPeg, itHasOracleRefPerTok, resetFork, collateralName, chainlinkDefaultAnswer, + amountScaleDivisor, toleranceDivisor, targetNetwork, } = fixtures @@ -178,7 +180,8 @@ export default function fn( describe('functions', () => { it('returns the correct bal (18 decimals)', async () => { const decimals = await ctx.tok.decimals() - const amount = bn('20').mul(bn(10).pow(decimals)) + const scaleDivisor = amountScaleDivisor ?? bn(1) + const amount = bn('20').mul(bn(10).pow(decimals)).div(scaleDivisor) await mintCollateralTo(ctx, amount, alice, alice.address) const aliceBal = await collateral.bal(alice.address) @@ -201,7 +204,10 @@ export default function fn( }) itClaimsRewards('claims rewards (via collateral.claimRewards())', async () => { - const amount = bn('20').mul(bn(10).pow(await ctx.tok.decimals())) + const scaleDivisor = amountScaleDivisor ?? bn(1) + const amount = bn('20') + .mul(bn(10).pow(await ctx.tok.decimals())) + .div(scaleDivisor) await mintCollateralTo(ctx, amount, alice, ctx.collateral.address) await advanceBlocks(1000) await advanceToTimestamp((await getLatestBlockTimestamp()) + 12000) @@ -395,29 +401,32 @@ export default function fn( ) // within 1-part-in-1-thousand }) - it('reverts if Chainlink feed reverts or runs out of gas, maintains status', async () => { - const InvalidMockV3AggregatorFactory = await ethers.getContractFactory( - 'InvalidMockV3Aggregator' - ) - const invalidChainlinkFeed = ( - await InvalidMockV3AggregatorFactory.deploy(8, chainlinkDefaultAnswer) - ) + itChecksMainChainlinkOracleRevert( + 'reverts if Chainlink feed reverts or runs out of gas, maintains status', + async () => { + const InvalidMockV3AggregatorFactory = await ethers.getContractFactory( + 'InvalidMockV3Aggregator' + ) + const invalidChainlinkFeed = ( + await InvalidMockV3AggregatorFactory.deploy(8, chainlinkDefaultAnswer) + ) - const invalidCollateral = await deployCollateral({ - erc20: ctx.tok.address, - chainlinkFeed: invalidChainlinkFeed.address, - }) + const invalidCollateral = await deployCollateral({ + erc20: ctx.tok.address, + chainlinkFeed: invalidChainlinkFeed.address, + }) - // Reverting with no reason - await invalidChainlinkFeed.setSimplyRevert(true) - await expect(invalidCollateral.refresh()).to.be.revertedWithoutReason() - expect(await invalidCollateral.status()).to.equal(CollateralStatus.SOUND) + // Reverting with no reason + await invalidChainlinkFeed.setSimplyRevert(true) + await expect(invalidCollateral.refresh()).to.be.revertedWithoutReason() + expect(await invalidCollateral.status()).to.equal(CollateralStatus.SOUND) - // Runnning out of gas (same error) - await invalidChainlinkFeed.setSimplyRevert(false) - await expect(invalidCollateral.refresh()).to.be.revertedWithoutReason() - expect(await invalidCollateral.status()).to.equal(CollateralStatus.SOUND) - }) + // Runnning out of gas (same error) + await invalidChainlinkFeed.setSimplyRevert(false) + await expect(invalidCollateral.refresh()).to.be.revertedWithoutReason() + expect(await invalidCollateral.status()).to.equal(CollateralStatus.SOUND) + } + ) it('decays price over priceTimeout period', async () => { await collateral.refresh() @@ -669,7 +678,9 @@ export default function fn( let govParams: IGovParams let govRoles: IGovRoles - const config: IConfig = { + let scaleDivisor: BigNumber + + const config = { dist: { rTokenDist: bn(0), // 0% RToken rsrDist: bn(10000), // 100% RSR @@ -723,6 +734,7 @@ export default function fn( throw new Error(`Missing network configuration for ${hre.network.name}`) } ;[, owner, addr1] = await ethers.getSigners() + scaleDivisor = amountScaleDivisor ?? bn(1) }) beforeEach(async () => { @@ -743,7 +755,7 @@ export default function fn( collateralERC20 = await ethers.getContractAt('IERC20Metadata', await collateral.erc20()) await mintCollateralTo( ctx, - toBNDecimals(fp('1'), await collateralERC20.decimals()), + toBNDecimals(fp('1').div(scaleDivisor), await collateralERC20.decimals()), addr1, addr1.address ) @@ -836,7 +848,10 @@ export default function fn( it('redeems', async () => { await rToken.connect(addr1).redeem(supply) expect(await rToken.totalSupply()).to.equal(0) - const initialCollBal = toBNDecimals(fp('1'), await collateralERC20.decimals()) + const initialCollBal = toBNDecimals( + fp('1').div(scaleDivisor), + await collateralERC20.decimals() + ) expect(await collateralERC20.balanceOf(addr1.address)).to.be.closeTo( initialCollBal, initialCollBal.div(bn('1e5')) // 1-part-in-100k @@ -880,7 +895,7 @@ export default function fn( const router = await (await ethers.getContractFactory('DutchTradeRouter')).deploy() await rsr.connect(addr1).approve(router.address, MAX_UINT256) // Send excess collateral to the RToken trader via forwardRevenue() - let mintAmt = toBNDecimals(fp('1e-6'), await collateralERC20.decimals()) + let mintAmt = toBNDecimals(fp('1e-6'), await collateralERC20.decimals()).div(scaleDivisor) mintAmt = mintAmt.gt('100000') ? mintAmt : bn('100000') // fewest tokens distributor will transfer await mintCollateralTo(ctx, mintAmt, addr1, backingManager.address) await backingManager.forwardRevenue([collateralERC20.address]) diff --git a/test/plugins/individual-collateral/compoundv3/CometTestSuite.test.ts b/test/plugins/individual-collateral/compoundv3/CometTestSuite.test.ts index c0062e93db..5b5c7ba6f0 100644 --- a/test/plugins/individual-collateral/compoundv3/CometTestSuite.test.ts +++ b/test/plugins/individual-collateral/compoundv3/CometTestSuite.test.ts @@ -384,6 +384,7 @@ allTests.forEach((curr: CTokenV3Enumeration) => { itChecksRefPerTokDefault: it.skip, // implemented in this file itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it.skip, // implemented in this file itIsPricedByPeg: true, resetFork: getResetFork(getForkBlock(curr.tokenName)), diff --git a/test/plugins/individual-collateral/dsr/SDaiCollateralTestSuite.test.ts b/test/plugins/individual-collateral/dsr/SDaiCollateralTestSuite.test.ts index 2919f0ebc4..52549b7041 100644 --- a/test/plugins/individual-collateral/dsr/SDaiCollateralTestSuite.test.ts +++ b/test/plugins/individual-collateral/dsr/SDaiCollateralTestSuite.test.ts @@ -215,6 +215,7 @@ const opts = { itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it.skip, resetFork, collateralName: 'SDaiCollateral', diff --git a/test/plugins/individual-collateral/ethena/USDeFiatCollateral.test.ts b/test/plugins/individual-collateral/ethena/USDeFiatCollateral.test.ts index 9624aaa70a..6a6528d9f9 100644 --- a/test/plugins/individual-collateral/ethena/USDeFiatCollateral.test.ts +++ b/test/plugins/individual-collateral/ethena/USDeFiatCollateral.test.ts @@ -211,6 +211,7 @@ const opts = { itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, collateralName: 'USDe Fiat Collateral', chainlinkDefaultAnswer, diff --git a/test/plugins/individual-collateral/ethx/ETHxCollateral.test.ts b/test/plugins/individual-collateral/ethx/ETHxCollateral.test.ts index 10b4da87c3..b3c3804801 100644 --- a/test/plugins/individual-collateral/ethx/ETHxCollateral.test.ts +++ b/test/plugins/individual-collateral/ethx/ETHxCollateral.test.ts @@ -282,6 +282,7 @@ const opts = { itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, resetFork, collateralName: 'Stader ETHx', diff --git a/test/plugins/individual-collateral/flux-finance/FTokenFiatCollateral.test.ts b/test/plugins/individual-collateral/flux-finance/FTokenFiatCollateral.test.ts index 180a889352..eabfa12690 100644 --- a/test/plugins/individual-collateral/flux-finance/FTokenFiatCollateral.test.ts +++ b/test/plugins/individual-collateral/flux-finance/FTokenFiatCollateral.test.ts @@ -258,6 +258,7 @@ all.forEach((curr: FTokenEnumeration) => { itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, resetFork, collateralName: curr.testName, diff --git a/test/plugins/individual-collateral/frax-eth/SFrxEthTestSuite.test.ts b/test/plugins/individual-collateral/frax-eth/SFrxEthTestSuite.test.ts index b15e1df41e..b25608382a 100644 --- a/test/plugins/individual-collateral/frax-eth/SFrxEthTestSuite.test.ts +++ b/test/plugins/individual-collateral/frax-eth/SFrxEthTestSuite.test.ts @@ -311,6 +311,7 @@ const opts = { itChecksPriceChanges: it, itHasRevenueHiding: it.skip, // implemented in this file itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, resetFork, collateralName: 'SFraxEthCollateral', chainlinkDefaultAnswer, diff --git a/test/plugins/individual-collateral/frax/SFraxCollateralTestSuite.test.ts b/test/plugins/individual-collateral/frax/SFraxCollateralTestSuite.test.ts index 2ed9bc5e4d..e9ec2b3e2b 100644 --- a/test/plugins/individual-collateral/frax/SFraxCollateralTestSuite.test.ts +++ b/test/plugins/individual-collateral/frax/SFraxCollateralTestSuite.test.ts @@ -195,6 +195,7 @@ const opts = { itChecksTargetPerRefDefault: it, itChecksTargetPerRefDefaultUp: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itHasRevenueHiding: it.skip, diff --git a/test/plugins/individual-collateral/lido/L2LidoStakedEthTestSuite.test.ts b/test/plugins/individual-collateral/lido/L2LidoStakedEthTestSuite.test.ts index 184b1090aa..7ac7956e28 100644 --- a/test/plugins/individual-collateral/lido/L2LidoStakedEthTestSuite.test.ts +++ b/test/plugins/individual-collateral/lido/L2LidoStakedEthTestSuite.test.ts @@ -278,6 +278,7 @@ const opts = { itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, resetFork: getResetFork(FORK_BLOCK_BASE), collateralName: 'L2LidoStakedETH', diff --git a/test/plugins/individual-collateral/lido/LidoStakedEthTestSuite.test.ts b/test/plugins/individual-collateral/lido/LidoStakedEthTestSuite.test.ts index 366c8c81c2..09bb47a51f 100644 --- a/test/plugins/individual-collateral/lido/LidoStakedEthTestSuite.test.ts +++ b/test/plugins/individual-collateral/lido/LidoStakedEthTestSuite.test.ts @@ -269,6 +269,7 @@ const opts = { itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, resetFork, collateralName: 'LidoStakedETH', diff --git a/test/plugins/individual-collateral/meta-morpho/MetaMorphoFiatCollateral.test.ts b/test/plugins/individual-collateral/meta-morpho/MetaMorphoFiatCollateral.test.ts index f8138befb4..05429c1535 100644 --- a/test/plugins/individual-collateral/meta-morpho/MetaMorphoFiatCollateral.test.ts +++ b/test/plugins/individual-collateral/meta-morpho/MetaMorphoFiatCollateral.test.ts @@ -175,6 +175,7 @@ const makeFiatCollateralTestSuite = ( itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, resetFork: getResetFork(FORK_BLOCK), targetNetwork: defaultCollateralOpts.forkNetwork, diff --git a/test/plugins/individual-collateral/meta-morpho/MetaMorphoSelfReferentialCollateral.test.ts b/test/plugins/individual-collateral/meta-morpho/MetaMorphoSelfReferentialCollateral.test.ts index d243ec85aa..44b009bd61 100644 --- a/test/plugins/individual-collateral/meta-morpho/MetaMorphoSelfReferentialCollateral.test.ts +++ b/test/plugins/individual-collateral/meta-morpho/MetaMorphoSelfReferentialCollateral.test.ts @@ -171,6 +171,7 @@ const makeFiatCollateralTestSuite = ( itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it.skip, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, resetFork: getResetFork(FORK_BLOCK), targetNetwork: defaultCollateralOpts.forkNetwork, diff --git a/test/plugins/individual-collateral/morpho-aave/MorphoAAVEFiatCollateral.test.ts b/test/plugins/individual-collateral/morpho-aave/MorphoAAVEFiatCollateral.test.ts index 9a9b94fad7..27f971c04b 100644 --- a/test/plugins/individual-collateral/morpho-aave/MorphoAAVEFiatCollateral.test.ts +++ b/test/plugins/individual-collateral/morpho-aave/MorphoAAVEFiatCollateral.test.ts @@ -368,6 +368,7 @@ const makeAaveFiatCollateralTestSuite = ( itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, resetFork: getResetFork(FORK_BLOCK), collateralName, diff --git a/test/plugins/individual-collateral/morpho-aave/MorphoAAVENonFiatCollateral.test.ts b/test/plugins/individual-collateral/morpho-aave/MorphoAAVENonFiatCollateral.test.ts index 937ec99e70..e846ec7e87 100644 --- a/test/plugins/individual-collateral/morpho-aave/MorphoAAVENonFiatCollateral.test.ts +++ b/test/plugins/individual-collateral/morpho-aave/MorphoAAVENonFiatCollateral.test.ts @@ -229,6 +229,7 @@ const makeAaveNonFiatCollateralTestSuite = ( itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, itIsPricedByPeg: true, resetFork: getResetFork(FORK_BLOCK), diff --git a/test/plugins/individual-collateral/morpho-aave/MorphoAAVESelfReferentialCollateral.test.ts b/test/plugins/individual-collateral/morpho-aave/MorphoAAVESelfReferentialCollateral.test.ts index 81404fe208..71100d2a38 100644 --- a/test/plugins/individual-collateral/morpho-aave/MorphoAAVESelfReferentialCollateral.test.ts +++ b/test/plugins/individual-collateral/morpho-aave/MorphoAAVESelfReferentialCollateral.test.ts @@ -229,6 +229,7 @@ const opts = { itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it.skip, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, resetFork: getResetFork(FORK_BLOCK), collateralName: 'MorphoAAVEV2SelfReferentialCollateral - WETH', diff --git a/test/plugins/individual-collateral/mountain/USDMCollateral.test.ts b/test/plugins/individual-collateral/mountain/USDMCollateral.test.ts index 570c60345f..3605c491a5 100644 --- a/test/plugins/individual-collateral/mountain/USDMCollateral.test.ts +++ b/test/plugins/individual-collateral/mountain/USDMCollateral.test.ts @@ -311,6 +311,7 @@ const opts = { itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it.skip, // implemented in this file collateralName: 'USDM Collateral', chainlinkDefaultAnswer, diff --git a/test/plugins/individual-collateral/pirex-eth/ApxEthCollateral.test.ts b/test/plugins/individual-collateral/pirex-eth/ApxEthCollateral.test.ts index db143ad603..e49008c64e 100644 --- a/test/plugins/individual-collateral/pirex-eth/ApxEthCollateral.test.ts +++ b/test/plugins/individual-collateral/pirex-eth/ApxEthCollateral.test.ts @@ -378,6 +378,7 @@ const opts = { itChecksRefPerTokDefault: it.skip, // implemented in this file itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it.skip, // implemented in this file resetFork, collateralName: 'ApxETH', diff --git a/test/plugins/individual-collateral/pluginTestTypes.ts b/test/plugins/individual-collateral/pluginTestTypes.ts index aa70a23c10..d65722bf84 100644 --- a/test/plugins/individual-collateral/pluginTestTypes.ts +++ b/test/plugins/individual-collateral/pluginTestTypes.ts @@ -106,6 +106,9 @@ export interface CollateralTestSuiteFixtures // toggle on or off: tests that check that defaultThreshold is not zero itChecksNonZeroDefaultThreshold: Mocha.TestFunction | Mocha.PendingTestFunction + // toggle on or off: tests that check when the main chainlink feed reverts (not always used) + itChecksMainChainlinkOracleRevert: Mocha.TestFunction | Mocha.PendingTestFunction + // does the peg price matter for the results of tryPrice()? itIsPricedByPeg?: boolean @@ -121,6 +124,9 @@ export interface CollateralTestSuiteFixtures // the default answer that will come from the chainlink feed after deployment chainlinkDefaultAnswer: BigNumberish + // the scale divisor that will be used for amounts in tests + amountScaleDivisor?: BigNumber + // the default tolerance divisor that will be used in expectPrice checks toleranceDivisor?: BigNumber diff --git a/test/plugins/individual-collateral/rocket-eth/RethCollateralTestSuite.test.ts b/test/plugins/individual-collateral/rocket-eth/RethCollateralTestSuite.test.ts index f766a3bc08..48017d2648 100644 --- a/test/plugins/individual-collateral/rocket-eth/RethCollateralTestSuite.test.ts +++ b/test/plugins/individual-collateral/rocket-eth/RethCollateralTestSuite.test.ts @@ -276,6 +276,7 @@ const opts = { itChecksRefPerTokDefault: it, itChecksPriceChanges: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, resetFork, collateralName: 'RocketPoolETH', diff --git a/test/plugins/individual-collateral/stargate/StargateUSDCTestSuite.test.ts_DEPRECATED b/test/plugins/individual-collateral/stargate/StargateUSDCTestSuite.test.ts_DEPRECATED index 009ffdf1a8..f3e64455d8 100644 --- a/test/plugins/individual-collateral/stargate/StargateUSDCTestSuite.test.ts_DEPRECATED +++ b/test/plugins/individual-collateral/stargate/StargateUSDCTestSuite.test.ts_DEPRECATED @@ -316,6 +316,7 @@ export const stableOpts = { itChecksTargetPerRefDefaultUp: it, itChecksRefPerTokDefault: it, itChecksNonZeroDefaultThreshold: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, itIsPricedByPeg: true, chainlinkDefaultAnswer: 1e8, diff --git a/test/plugins/individual-collateral/yearnv2/YearnV2CurveFiatCollateral.test.ts b/test/plugins/individual-collateral/yearnv2/YearnV2CurveFiatCollateral.test.ts index 22017205a9..9b34573b70 100644 --- a/test/plugins/individual-collateral/yearnv2/YearnV2CurveFiatCollateral.test.ts +++ b/test/plugins/individual-collateral/yearnv2/YearnV2CurveFiatCollateral.test.ts @@ -260,6 +260,7 @@ tests.forEach((test: CurveFiatTest) => { itChecksTargetPerRefDefault: it, itChecksTargetPerRefDefaultUp: it, itChecksRefPerTokDefault: it, + itChecksMainChainlinkOracleRevert: it, itHasRevenueHiding: it, itClaimsRewards: it.skip, isMetapool: false,