From 95986587040d70cbf26faad2b8a7985cd7ec3842 Mon Sep 17 00:00:00 2001 From: "Julian M. Rodriguez" <56316686+julianmrodri@users.noreply.github.com> Date: Wed, 26 Jun 2024 12:51:38 -0300 Subject: [PATCH] Usdm plugin and scripts (#1146) Co-authored-by: Taylor Brent --- .github/workflows/tests.yml | 2 +- common/configuration.ts | 7 + contracts/plugins/assets/mountain/README.md | 31 ++ .../assets/mountain/USDMCollateral.sol | 59 ++++ .../assets/mountain/vendor/IChronicle.sol | 59 ++++ .../42161-tmp-assets-collateral.json | 30 ++ scripts/addresses/42161-tmp-deployments.json | 38 +++ .../42161-tmp-assets-collateral.json | 8 +- scripts/deploy.ts | 1 + .../phase2-assets/collaterals/deploy_usdm.ts | 106 ++++++ .../collateral-plugins/verify_usdm.ts | 60 ++++ scripts/verify_etherscan.ts | 3 +- test/plugins/OracleDeprecation.test.ts | 22 +- .../mountain/USDMCollateral.test.ts | 322 ++++++++++++++++++ .../mountain/constants.ts | 18 + .../individual-collateral/mountain/helpers.ts | 30 ++ 16 files changed, 790 insertions(+), 6 deletions(-) create mode 100644 contracts/plugins/assets/mountain/README.md create mode 100644 contracts/plugins/assets/mountain/USDMCollateral.sol create mode 100644 contracts/plugins/assets/mountain/vendor/IChronicle.sol create mode 100644 scripts/addresses/42161-tmp-assets-collateral.json create mode 100644 scripts/addresses/42161-tmp-deployments.json create mode 100644 scripts/deployment/phase2-assets/collaterals/deploy_usdm.ts create mode 100644 scripts/verification/collateral-plugins/verify_usdm.ts create mode 100644 test/plugins/individual-collateral/mountain/USDMCollateral.test.ts create mode 100644 test/plugins/individual-collateral/mountain/constants.ts create mode 100644 test/plugins/individual-collateral/mountain/helpers.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cb4093c2f..a33308486 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -156,7 +156,7 @@ jobs: restore-keys: | hardhat-network-fork-${{ runner.os }}- hardhat-network-fork- - - run: npx hardhat test ./test/plugins/individual-collateral/{aave-v3,compoundv3,curve/cvx}/*.test.ts + - run: npx hardhat test ./test/plugins/individual-collateral/{aave-v3,compoundv3,curve/cvx,mountain}/*.test.ts env: NODE_OPTIONS: '--max-old-space-size=8192' TS_NODE_SKIP_IGNORE: true diff --git a/common/configuration.ts b/common/configuration.ts index 34bd87fbd..febedecbe 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -104,6 +104,10 @@ export interface ITokens { bbUSDT?: string steakPYUSD?: string Re7WETH?: string + + // Mountain + USDM?: string + wUSDM?: string } export type ITokensKeys = Array @@ -541,6 +545,8 @@ export const networkConfig: { [key: string]: INetworkConfig } = { saArbUSDCn: '', // TODO our wrapper. remove from deployment script after placing here aArbUSDT: '0x6ab707aca953edaefbc4fd23ba73294241490620', saArbUSDT: '', // TODO our wrapper. remove from deployment script after placing here + USDM: '0x59d9356e565ab3a36dd77763fc0d87feaf85508c', + wUSDM: '0x57f5e098cad7a3d1eed53991d4d66c45c9af7812', }, chainlinkFeeds: { ARB: '0xb2A824043730FE05F3DA2efaFa1CBbe83fa548D6', @@ -550,6 +556,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { USDT: '0x3f3f5dF88dC9F13eac63DF89EC16ef6e7E25DdE7', RSR: '0xcfF9349ec6d027f20fC9360117fef4a1Ad38B488', crvUSD: '0x0a32255dd4BB6177C994bAAc73E0606fDD568f66', + wUSDM: '0xdC6720c996Fad27256c7fd6E0a271e2A4687eF18', }, GNOSIS_EASY_AUCTION: '0xcD033976a011F41D2AB6ef47984041568F818E73', // our deployment COMET_REWARDS: '0x88730d254A2f7e6AC8388c3198aFd694bA9f7fae', diff --git a/contracts/plugins/assets/mountain/README.md b/contracts/plugins/assets/mountain/README.md new file mode 100644 index 000000000..03ebfc35f --- /dev/null +++ b/contracts/plugins/assets/mountain/README.md @@ -0,0 +1,31 @@ +# Mountain USDM Collateral Plugin + +## Summary + +This plugin allows `wUSDM` holders to use their tokens as collateral in the Reserve Protocol. + +`wUSDM` is an unowned, immutable, ERC4626-wrapper around the USDM token. + +Since it is ERC4626, the redeemable USDM amount can be obtained by dividing `wUSDM.totalAssets()` by `wUSDM.totalSupply()`. + +`USDM` contract: + +`wUSDM` contract: + +## Oracles - Important! + +A Chronicle oracle is available for `wUSDM`, Even though Chronicle oracles provide a compatible interface for reading prices, they require the reading contract to be **whitelisted** by Chronicle. It is important to provide the Chronicle team the collateral plugin address as soon as it is deployed to the network so they can whitelist it. This has to be done **before** the plugin is used by any RToken. + +## Implementation + +### Units + +| tok | ref | target | UoA | +| ----- | ---- | ------ | --- | +| wUSDM | USDM | USD | USD | + +### Functions + +#### refPerTok {ref/tok} + +`return shiftl_toFix(erc4626.convertToAssets(oneShare), -refDecimals)` diff --git a/contracts/plugins/assets/mountain/USDMCollateral.sol b/contracts/plugins/assets/mountain/USDMCollateral.sol new file mode 100644 index 000000000..d2f318c75 --- /dev/null +++ b/contracts/plugins/assets/mountain/USDMCollateral.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "../../../libraries/Fixed.sol"; +import "../ERC4626FiatCollateral.sol"; + +/** + * @title USDM Collateral + * @notice Collateral plugin for USDM (Mountain Protocol) + * tok = wUSDM (ERC4626 vault) + * ref = USDM + * tar = USD + * UoA = USD + * + * Note: Uses a Chronicle Oracle, which requires the plugin address to be whitelisted + */ + +contract USDMCollateral is ERC4626FiatCollateral { + using OracleLib for AggregatorV3Interface; + using FixLib for uint192; + + // solhint-disable no-empty-blocks + + /// @param config.chainlinkFeed - {UoA/tok} - Chronicle oracle - Requires whitelisting! + constructor(CollateralConfig memory config, uint192 revenueHiding) + ERC4626FiatCollateral(config, revenueHiding) + { + require(config.defaultThreshold != 0, "defaultThreshold zero"); + } + + /// 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 + /// @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 + ) + { + // {UoA/tok} + uint192 p = chainlinkFeed.price(oracleTimeout); + uint192 err = p.mul(oracleError, CEIL); + + low = p - err; + high = p + err; + // assert(low <= high); obviously true just by inspection + + // {target/ref} = {UoA/ref} = {UoA/tok} / {ref/tok} + pegPrice = p.div(underlyingRefPerTok()); + } +} diff --git a/contracts/plugins/assets/mountain/vendor/IChronicle.sol b/contracts/plugins/assets/mountain/vendor/IChronicle.sol new file mode 100644 index 000000000..66989bd7b --- /dev/null +++ b/contracts/plugins/assets/mountain/vendor/IChronicle.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +/// Toll (whitelist) +interface IToll { + /// @notice Thrown by protected function if caller not tolled. + /// @param caller The caller's address. + error NotTolled(address caller); + + /// @notice Emitted when toll granted to address. + /// @param caller The caller's address. + /// @param who The address toll got granted to. + event TollGranted(address indexed caller, address indexed who); + + /// @notice Grants address `who` toll. + /// @dev Only callable by auth'ed address. + /// @param who The address to grant toll. + function kiss(address who) external; + + /// @notice Renounces address `who`'s toll. + /// @dev Only callable by auth'ed address. + /// @param who The address to renounce toll. + function diss(address who) external; + + /// @notice Returns whether address `who` is tolled. + /// @param who The address to check. + /// @return True if `who` is tolled, false otherwise. + function tolled(address who) external view returns (bool); +} + +/** + * @title IChronicle + * + * @notice Interface for Chronicle Protocol's oracle products + */ +interface IChronicle is IToll { + /// @notice Returns the oracle's current value. + /// @dev Reverts if no value set. + /// @return value The oracle's current value. + function read() external view returns (uint256 value); + + /// @notice Returns the oracle's current value and its age. + /// @dev Reverts if no value set. + /// @return value The oracle's current value. + /// @return age The value's age. + function readWithAge() external view returns (uint256 value, uint256 age); + + /// Chainlink compatibility + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ); +} diff --git a/scripts/addresses/42161-tmp-assets-collateral.json b/scripts/addresses/42161-tmp-assets-collateral.json new file mode 100644 index 000000000..2c8f59431 --- /dev/null +++ b/scripts/addresses/42161-tmp-assets-collateral.json @@ -0,0 +1,30 @@ +{ + "assets": { + "COMP": "0x6882560A919714A742afd2A2a0af6b4D8d20cF22", + "ARB": "0x21fBa52dA03e1F964fa521532f8B8951fC212055" + }, + "collateral": { + "DAI": "0x6FE56A3EEa3fEc93601a94D26bEa1876bD48192F", + "USDC": "0xa96aE05dFa869F4FCC4142E8D4E4F2706FEe2B57", + "USDT": "0x3Ac8F000D75a2EA4a9a36c6844410926bc0c32f7", + "saArbUSDCn": "0x7be9Bc50734820516693A376238Cc6Bf029BA682", + "saArbUSDT": "0x529D7e23Ce63efdcE41dA2a41296Fd7399157F5b", + "cUSDCv3": "0x8a5DfEa5cdA35AB374ac558951A3dF1437A6FcA6", + "cvxCrvUSDUSDT": "0xf729b03AcbD60c8aF9B449d51444445815a56d0e", + "cvxCrvUSDUSDC": "0x57547D29cf0D5B4d31c6c71Ec73b3A8c8416ade6", + "wUSDM": "0xA185a9fd314b61f33F740760a59cc46b31297e30" + }, + "erc20s": { + "COMP": "0x354A6dA3fcde098F8389cad84b0182725c6C91dE", + "DAI": "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", + "USDC": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + "USDT": "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9", + "saArbUSDCn": "0x030cDeCBDcA6A34e8De3f49d1798d5f70E3a3414", + "saArbUSDT": "0xffef97179f58a582dEf73e6d2e4BcD2BDC8ca128", + "cUSDCv3": "0xd54804250E9C561AEa9Dee34e9cf2342f767ACC5", + "ARB": "0x912ce59144191c1204e64559fe8253a0e49e6548", + "cvxCrvUSDUSDT": "0xf74d4C9b0F49fb70D8Ff6706ddF39e3a16D61E67", + "cvxCrvUSDUSDC": "0xBFEE9F3E015adC754066424AEd535313dc764116", + "wUSDM": "0x57f5e098cad7a3d1eed53991d4d66c45c9af7812" + } +} \ No newline at end of file diff --git a/scripts/addresses/42161-tmp-deployments.json b/scripts/addresses/42161-tmp-deployments.json new file mode 100644 index 000000000..7c70ad0ba --- /dev/null +++ b/scripts/addresses/42161-tmp-deployments.json @@ -0,0 +1,38 @@ +{ + "prerequisites": { + "RSR": "0xCa5Ca9083702c56b481D1eec86F1776FDbd2e594", + "RSR_FEED": "0xcfF9349ec6d027f20fC9360117fef4a1Ad38B488", + "GNOSIS_EASY_AUCTION": "0xcD033976a011F41D2AB6ef47984041568F818E73" + }, + "tradingLib": "0x348644F24FA34c40a8E3C4Cf9aF14f8a96aD63fC", + "cvxMiningLib": "", + "facade": "0x387A0C36681A22F728ab54426356F4CAa6bB48a9", + "facets": { + "actFacet": "0xE774CCF1431c3DEe7Fa4c20f67534b61289CAa45", + "readFacet": "0x15175d35F3d88548B49600B4ee8067253A2e4e66" + }, + "facadeWriteLib": "0x042D85e9eb1F4372ffA362240E0630229CaA1904", + "basketLib": "0x53f1Df4E5591Ae35Bf738742981669c3767241FA", + "facadeWrite": "0xe2B652E538543d02f985A5E422645A704633956d", + "deployer": "0xfd7eb6B208E1fa7B14E26A1fb10fFC17Cf695d68", + "rsrAsset": "0x7182e3A6E29936C8B14c4fa6f63a62D0b1D0f767", + "implementations": { + "main": "0xf7a9D27c3B60c78c6F6e2c2d6ED6E8B94b352461", + "trading": { + "gnosisTrade": "0xD42620d04fCe65B1F5E8Facc894a2e34D764FEc9", + "dutchTrade": "0x8b4374005291B8FCD14C4E947604b2FB3C660A73" + }, + "components": { + "assetRegistry": "0xA9df960Af018178C0138CD5780c768A0a0A7e61f", + "backingManager": "0xD85Fac03804a3e44D29c494f3761D11A2262cBBe", + "basketHandler": "0x157b0C032192F5714BD68bf33dF96C122EA5e1d6", + "broker": "0xa24E0D3E77Ec4849A288C72F9d9bC4dF84B26558", + "distributor": "0x5Ef74A083Ac932b5f050bf41cDe1F67c659b4b88", + "furnace": "0x8A11D590B32186E1236B5E75F2d8D72c280dc880", + "rsrTrader": "0xaeCa35F0cB9d12D68adC4d734D4383593F109654", + "rTokenTrader": "0xaeCa35F0cB9d12D68adC4d734D4383593F109654", + "rToken": "0xC8f487B34251Eb76761168B70Dc10fA38B0Bd90b", + "stRSR": "0x437b525F96A2Da0A4b165efe27c61bea5c8d3CD4" + } + } +} diff --git a/scripts/addresses/arbitrum-3.4.0/42161-tmp-assets-collateral.json b/scripts/addresses/arbitrum-3.4.0/42161-tmp-assets-collateral.json index dd71b7f88..2c8f59431 100644 --- a/scripts/addresses/arbitrum-3.4.0/42161-tmp-assets-collateral.json +++ b/scripts/addresses/arbitrum-3.4.0/42161-tmp-assets-collateral.json @@ -11,7 +11,8 @@ "saArbUSDT": "0x529D7e23Ce63efdcE41dA2a41296Fd7399157F5b", "cUSDCv3": "0x8a5DfEa5cdA35AB374ac558951A3dF1437A6FcA6", "cvxCrvUSDUSDT": "0xf729b03AcbD60c8aF9B449d51444445815a56d0e", - "cvxCrvUSDUSDC": "0x57547D29cf0D5B4d31c6c71Ec73b3A8c8416ade6" + "cvxCrvUSDUSDC": "0x57547D29cf0D5B4d31c6c71Ec73b3A8c8416ade6", + "wUSDM": "0xA185a9fd314b61f33F740760a59cc46b31297e30" }, "erc20s": { "COMP": "0x354A6dA3fcde098F8389cad84b0182725c6C91dE", @@ -23,6 +24,7 @@ "cUSDCv3": "0xd54804250E9C561AEa9Dee34e9cf2342f767ACC5", "ARB": "0x912ce59144191c1204e64559fe8253a0e49e6548", "cvxCrvUSDUSDT": "0xf74d4C9b0F49fb70D8Ff6706ddF39e3a16D61E67", - "cvxCrvUSDUSDC": "0xBFEE9F3E015adC754066424AEd535313dc764116" + "cvxCrvUSDUSDC": "0xBFEE9F3E015adC754066424AEd535313dc764116", + "wUSDM": "0x57f5e098cad7a3d1eed53991d4d66c45c9af7812" } -} +} \ No newline at end of file diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 07e0b496f..65f82bd7c 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -110,6 +110,7 @@ async function main() { 'phase2-assets/collaterals/deploy_ctokenv3_usdc_collateral.ts', 'phase2-assets/collaterals/deploy_convex_crvusd_usdc_collateral.ts', 'phase2-assets/collaterals/deploy_convex_crvusd_usdt_collateral.ts', + 'phase2-assets/collaterals/deploy_usdm.ts', 'phase2-assets/assets/deploy_arb.ts' ) } diff --git a/scripts/deployment/phase2-assets/collaterals/deploy_usdm.ts b/scripts/deployment/phase2-assets/collaterals/deploy_usdm.ts new file mode 100644 index 000000000..a48508311 --- /dev/null +++ b/scripts/deployment/phase2-assets/collaterals/deploy_usdm.ts @@ -0,0 +1,106 @@ +import fs from 'fs' +import hre from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { arbitrumL2Chains, networkConfig } from '../../../../common/configuration' +import { fp } from '../../../../common/numbers' +import { expect } from 'chai' +import { CollateralStatus } from '../../../../common/constants' +import { + getDeploymentFile, + getAssetCollDeploymentFilename, + IAssetCollDeployments, + getDeploymentFilename, + fileExists, +} from '../../common' +import { USDMCollateral } from '../../../../typechain' +import { ContractFactory } from 'ethers' +import { + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + PRICE_TIMEOUT, + ORACLE_TIMEOUT, + ORACLE_ERROR, +} from '../../../../test/plugins/individual-collateral/mountain/constants' + +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 USDM Collateral - wUSDM **************************/ + let collateral: USDMCollateral + + // Only on Arbitrum for now + if (arbitrumL2Chains.includes(hre.network.name)) { + const USDMCollateralFactory: ContractFactory = await hre.ethers.getContractFactory( + 'USDMCollateral' + ) + + collateral = await USDMCollateralFactory.connect(deployer).deploy( + { + priceTimeout: PRICE_TIMEOUT.toString(), + chainlinkFeed: networkConfig[chainId].chainlinkFeeds.wUSDM, + oracleError: ORACLE_ERROR.toString(), // 1% + erc20: networkConfig[chainId].tokens.wUSDM, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: ORACLE_TIMEOUT.toString(), // 24 hr + targetName: hre.ethers.utils.formatBytes32String('USD'), + defaultThreshold: DEFAULT_THRESHOLD.toString(), + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), // 24h + }, + fp('1e-6') + ) + } else { + throw new Error(`Unsupported chainId: ${chainId}`) + } + + await collateral.deployed() + + console.log( + `Deployed USDM (wUSDM) Collateral to ${hre.network.name} (${chainId}): ${collateral.address}` + ) + // await (await collateral.refresh()).wait() + // expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + console.log( + '🚨 The wUSDM collateral requires chronicle to whitelist the collateral plugin after deployment 🚨' + ) + + console.log( + '🚨 After that, we need to return to this plugin and refresh() it and confirm it is SOUND 🚨' + ) + + assetCollDeployments.collateral.wUSDM = collateral.address + assetCollDeployments.erc20s.wUSDM = networkConfig[chainId].tokens.wUSDM + 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/collateral-plugins/verify_usdm.ts b/scripts/verification/collateral-plugins/verify_usdm.ts new file mode 100644 index 000000000..f67ec6460 --- /dev/null +++ b/scripts/verification/collateral-plugins/verify_usdm.ts @@ -0,0 +1,60 @@ +import hre from 'hardhat' +import { getChainId } from '../../../common/blockchain-utils' +import { developmentChains, networkConfig } from '../../../common/configuration' +import { fp } from '../../../common/numbers' +import { + getDeploymentFile, + getAssetCollDeploymentFilename, + IAssetCollDeployments, +} from '../../deployment/common' +import { verifyContract } from '../../deployment/utils' +import { + PRICE_TIMEOUT, + ORACLE_ERROR, + ORACLE_TIMEOUT, + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, +} from '../../../test/plugins/individual-collateral/mountain/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) + + /******** Verify wUSDM COllateral **************************/ + await verifyContract( + chainId, + deployments.collateral.wUSDM, + [ + { + priceTimeout: PRICE_TIMEOUT.toString(), + chainlinkFeed: networkConfig[chainId].chainlinkFeeds.wUSDM, + oracleError: ORACLE_ERROR.toString(), + erc20: networkConfig[chainId].tokens.wUSDM, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: ORACLE_TIMEOUT.toString(), + targetName: hre.ethers.utils.formatBytes32String('USD'), + defaultThreshold: DEFAULT_THRESHOLD.toString(), + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), + }, + fp('1e-6'), + ], + 'contracts/plugins/assets/mountain/USDMCollateral.sol:USDMCollateral' + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verify_etherscan.ts b/scripts/verify_etherscan.ts index a5ca75c15..811916c60 100644 --- a/scripts/verify_etherscan.ts +++ b/scripts/verify_etherscan.ts @@ -93,7 +93,8 @@ async function main() { 'collateral-plugins/verify_aave_v3_usdc.ts', 'collateral-plugins/verify_cusdcv3.ts', 'collateral-plugins/verify_convex_crvusd_usdc.ts', - 'collateral-plugins/verify_convex_crvusd_usdt.ts' + 'collateral-plugins/verify_convex_crvusd_usdt.ts', + 'collateral-plugins/verify_usdm.ts' ) } diff --git a/test/plugins/OracleDeprecation.test.ts b/test/plugins/OracleDeprecation.test.ts index d76d16df0..d3cb32133 100644 --- a/test/plugins/OracleDeprecation.test.ts +++ b/test/plugins/OracleDeprecation.test.ts @@ -44,7 +44,11 @@ describe('Chainlink Oracle', () => { await rToken.connect(wallet).issue(amt) }) - describe('Chainlink deprecates an asset', () => { + // Expected behavior on deprecation + // - Chainlink: latestRoundData() reverts and aggregator == address(0) + // - Redstone: latestRoundData() does not revert, only signal is outdated price + // - Chronicle: latestRoundData() does not revert, but price is set to 0 + describe('Chainlink/Chronicle deprecates an asset', () => { it('Refresh should mark the asset as IFFY', async () => { const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') const [, aUSDCCollateral] = fixture.bySymbol.ausdc @@ -60,5 +64,21 @@ describe('Chainlink Oracle', () => { 'StalePrice' ) }) + + it('Price = 0 should mark the asset as IFFY (Chronicle)', async () => { + const MockV3AggregatorFactory = await ethers.getContractFactory('MockV3Aggregator') + const [, aUSDCCollateral] = fixture.bySymbol.ausdc + const chainLinkOracle = MockV3AggregatorFactory.attach(await aUSDCCollateral.chainlinkFeed()) + await aUSDCCollateral.refresh() + await aUSDCCollateral.tryPrice() + expect(await aUSDCCollateral.status()).to.equal(0) + await chainLinkOracle.updateAnswer(0) + await aUSDCCollateral.refresh() + expect(await aUSDCCollateral.status()).to.equal(1) + await expect(aUSDCCollateral.tryPrice()).to.be.revertedWithCustomError( + aUSDCCollateral, + 'InvalidPrice' + ) + }) }) }) diff --git a/test/plugins/individual-collateral/mountain/USDMCollateral.test.ts b/test/plugins/individual-collateral/mountain/USDMCollateral.test.ts new file mode 100644 index 000000000..49205edd0 --- /dev/null +++ b/test/plugins/individual-collateral/mountain/USDMCollateral.test.ts @@ -0,0 +1,322 @@ +import collateralTests from '../collateralTests' +import { CollateralFixtureContext, CollateralOpts, MintCollateralFunc } from '../pluginTestTypes' +import { resetFork, mintWUSDM, mintUSDM } from './helpers' +import { ethers } from 'hardhat' +import { expect } from 'chai' +import { ContractFactory, BigNumberish, BigNumber } from 'ethers' +import { + ERC20Mock, + IERC20Metadata, + MockV3Aggregator, + MockV3Aggregator__factory, + TestICollateral, +} from '../../../../typechain' +import { expectUnpriced, pushOracleForward } from '../../../utils/oracles' +import { bn, fp, toBNDecimals } from '../../../../common/numbers' +import { + BN_SCALE_FACTOR, + ONE_ADDRESS, + ZERO_ADDRESS, + CollateralStatus, +} from '../../../../common/constants' +import { whileImpersonating } from '../../../utils/impersonation' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { + ARB_USDM, + ARB_WUSDM, + ARB_WUSDM_USD_PRICE_FEED, + ORACLE_ERROR, + ORACLE_TIMEOUT, + PRICE_TIMEOUT, + MAX_TRADE_VOL, + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + ARB_CHRONICLE_FEED_AUTH, +} from './constants' + +/* + Define deployment functions +*/ + +export const defaultUSDMCollateralOpts: CollateralOpts = { + erc20: ARB_WUSDM, + targetName: ethers.utils.formatBytes32String('USD'), + rewardERC20: ZERO_ADDRESS, + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: ARB_WUSDM_USD_PRICE_FEED, + oracleTimeout: ORACLE_TIMEOUT, + oracleError: ORACLE_ERROR, + maxTradeVolume: MAX_TRADE_VOL, + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + revenueHiding: fp('0'), +} + +export const deployCollateral = async (opts: CollateralOpts = {}): Promise => { + opts = { ...defaultUSDMCollateralOpts, ...opts } + + const USDMCollateralFactory: ContractFactory = await ethers.getContractFactory('USDMCollateral') + const collateral = await USDMCollateralFactory.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, + }, + opts.revenueHiding, + { gasLimit: 2000000000 } + ) + await collateral.deployed() + + // It might revert if using real Chronicle oracle and not whitelisted (skip refresh()) + try { + // Push forward feed + await pushOracleForward(opts.chainlinkFeed!) + + // 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()) + } catch { + expect(await collateral.chainlinkFeed()).to.equal(ARB_WUSDM_USD_PRICE_FEED) + } + + return collateral +} + +const chainlinkDefaultAnswer = bn('1.03e8') + +type Fixture = () => Promise + +const makeCollateralFixtureContext = ( + alice: SignerWithAddress, + opts: CollateralOpts = {} +): Fixture => { + const collateralOpts = { ...defaultUSDMCollateralOpts, ...opts } + + const makeCollateralFixtureContext = async () => { + const MockV3AggregatorFactory = ( + await ethers.getContractFactory('MockV3Aggregator') + ) + + const chainlinkFeed = ( + await MockV3AggregatorFactory.deploy(8, chainlinkDefaultAnswer) + ) + collateralOpts.chainlinkFeed = chainlinkFeed.address + + const wusdm = (await ethers.getContractAt('IERC20Metadata', ARB_WUSDM)) as IERC20Metadata + const rewardToken = (await ethers.getContractAt('ERC20Mock', ZERO_ADDRESS)) as ERC20Mock + const collateral = await deployCollateral(collateralOpts) + const tok = await ethers.getContractAt('IERC20Metadata', await collateral.erc20()) + + return { + alice, + collateral, + chainlinkFeed, + tok, + wusdm, + rewardToken, + } + } + + return makeCollateralFixtureContext +} + +const mintCollateralTo: MintCollateralFunc = async ( + ctx: CollateralFixtureContext, + amount: BigNumberish, + user: SignerWithAddress, + recipient: string +) => { + await mintWUSDM(ctx.tok, user, amount, recipient) +} + +const reduceTargetPerRef = async (ctx: CollateralFixtureContext, pctDecrease: BigNumberish) => { + const lastRound = await ctx.chainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.sub(lastRound.answer.mul(pctDecrease).div(100)) + await ctx.chainlinkFeed.updateAnswer(nextAnswer) +} + +const increaseTargetPerRef = async (ctx: CollateralFixtureContext, pctIncrease: BigNumberish) => { + const lastRound = await ctx.chainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.add(lastRound.answer.mul(pctIncrease).div(100)) + await ctx.chainlinkFeed.updateAnswer(nextAnswer) +} + +// prettier-ignore +const reduceRefPerTok = async ( + ctx: CollateralFixtureContext, + pctDecrease: BigNumberish +) => { + const usdm = await ethers.getContractAt("IERC20Metadata", ARB_USDM) + const currentBal = await usdm.balanceOf(ctx.tok.address) + const removeBal = currentBal.mul(pctDecrease).div(100) + await whileImpersonating(ctx.tok.address, async (wusdmSigner) => { + await usdm.connect(wusdmSigner).transfer(ONE_ADDRESS, removeBal) + }) + + // push chainlink oracle forward so that tryPrice() still works and keeps peg + const latestRoundData = await ctx.chainlinkFeed.latestRoundData() + const nextAnswer = latestRoundData.answer.sub(latestRoundData.answer.mul(pctDecrease).div(100)) + await ctx.chainlinkFeed.updateAnswer(nextAnswer) +} + +// prettier-ignore +const increaseRefPerTok = async ( + ctx: CollateralFixtureContext, + pctIncrease: BigNumberish + +) => { + + const usdm = await ethers.getContractAt("IERC20Metadata", ARB_USDM) + + const currentBal = await usdm.balanceOf(ctx.tok.address) + const addBal = currentBal.mul(pctIncrease).div(100) + await mintUSDM(usdm, ctx.alice!, addBal, ctx.tok.address) + + // push chainlink oracle forward so that tryPrice() still works and keeps peg + const latestRoundData = await ctx.chainlinkFeed.latestRoundData() + const nextAnswer = latestRoundData.answer.add(latestRoundData.answer.mul(pctIncrease).div(100)) + await ctx.chainlinkFeed.updateAnswer(nextAnswer) +} + +const getExpectedPrice = async (ctx: CollateralFixtureContext): Promise => { + const clData = await ctx.chainlinkFeed.latestRoundData() + const clDecimals = await ctx.chainlinkFeed.decimals() + + return clData.answer.mul(bn(10).pow(18 - clDecimals)) +} + +/* + Define collateral-specific tests +*/ + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const collateralSpecificConstructorTests = () => {} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const collateralSpecificStatusTests = () => { + it('does revenue hiding correctly', async () => { + const [, alice] = await ethers.getSigners() + const tempCtx = await makeCollateralFixtureContext(alice, { + erc20: ARB_WUSDM, + revenueHiding: fp('0.01'), + })() + + // Set correct price to maintain peg + const newPrice = fp('1') + .mul(await tempCtx.collateral.underlyingRefPerTok()) + .div(BN_SCALE_FACTOR) + await tempCtx.chainlinkFeed.updateAnswer(toBNDecimals(newPrice, 8)) + await tempCtx.collateral.refresh() + expect(await tempCtx.collateral.status()).to.equal(CollateralStatus.SOUND) + + // Should remain SOUND after a 1% decrease + let refPerTok = await tempCtx.collateral.refPerTok() + await reduceRefPerTok(tempCtx, 1) + await tempCtx.collateral.refresh() + expect(await tempCtx.collateral.status()).to.equal(CollateralStatus.SOUND) + + // refPerTok should be unchanged + expect(await tempCtx.collateral.refPerTok()).to.be.closeTo(refPerTok, refPerTok.div(bn('1e3'))) // within 1-part-in-1-thousand + + // Should become DISABLED if drops another 1% + refPerTok = await tempCtx.collateral.refPerTok() + await reduceRefPerTok(tempCtx, bn(1)) + await tempCtx.collateral.refresh() + expect(await tempCtx.collateral.status()).to.equal(CollateralStatus.DISABLED) + + // refPerTok should have fallen 1% + refPerTok = refPerTok.sub(refPerTok.div(100)) + expect(await tempCtx.collateral.refPerTok()).to.be.closeTo(refPerTok, refPerTok.div(bn('1e3'))) // within 1-part-in-1-thousand + }) + + it('whitelisted Chronicle oracle works correctly', async () => { + await resetFork() // need fresh refPerTok() to maintain peg + + const collateral = await deployCollateral(defaultUSDMCollateralOpts) // using real Chronicle oracle + const chronicleFeed = await ethers.getContractAt('IChronicle', await collateral.chainlinkFeed()) + + // Oracle reverts when attempting to read price from Plugin (specific error - non-empty) + await whileImpersonating(collateral.address, async (pluginSigner) => { + await expect(chronicleFeed.connect(pluginSigner).read()).to.be.revertedWithCustomError( + chronicleFeed, + 'NotTolled' + ) + await expect( + chronicleFeed.connect(pluginSigner).latestRoundData() + ).to.be.revertedWithCustomError(chronicleFeed, 'NotTolled') + }) + + // Plugin is unpriced if not whitelisted + await expectUnpriced(collateral.address) + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + // Refresh sets collateral to IFFY if not whitelisted + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.IFFY) + + // Whitelist plugin in Chronicle oracle + await whileImpersonating(ARB_CHRONICLE_FEED_AUTH, async (authSigner) => { + await expect(chronicleFeed.connect(authSigner).kiss(collateral.address)).to.emit( + chronicleFeed, + 'TollGranted' + ) + }) + + // Plugin can now read + await whileImpersonating(collateral.address, async (pluginSigner) => { + await expect(chronicleFeed.connect(pluginSigner).read()).to.not.be.reverted + await expect(chronicleFeed.connect(pluginSigner).latestRoundData()).to.not.be.reverted + }) + + // Should have a price now + const [low, high] = await collateral.price() + expect(low).to.be.closeTo(fp('1.02'), fp('0.01')) // close to $1.03 (chainlink answer in this block) + expect(high).to.be.closeTo(fp('1.04'), fp('0.01')) + expect(high).to.be.gt(low) + + // Refresh sets it back to SOUND now that it's whitelisted + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + }) +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const beforeEachRewardsTest = async () => {} + +/* + Run the test suite +*/ + +const opts = { + deployCollateral, + collateralSpecificConstructorTests, + collateralSpecificStatusTests, + beforeEachRewardsTest, + makeCollateralFixtureContext, + mintCollateralTo, + reduceTargetPerRef, + increaseTargetPerRef, + reduceRefPerTok, + increaseRefPerTok, + getExpectedPrice, + itClaimsRewards: it.skip, + itChecksTargetPerRefDefault: it, + itChecksTargetPerRefDefaultUp: it, + itChecksRefPerTokDefault: it, + itChecksPriceChanges: it, + itChecksNonZeroDefaultThreshold: it, + itHasRevenueHiding: it.skip, // implemented in this file + collateralName: 'USDM Collateral', + chainlinkDefaultAnswer, + itIsPricedByPeg: true, + resetFork, + targetNetwork: 'arbitrum', +} + +collateralTests(opts) diff --git a/test/plugins/individual-collateral/mountain/constants.ts b/test/plugins/individual-collateral/mountain/constants.ts new file mode 100644 index 000000000..240a6414f --- /dev/null +++ b/test/plugins/individual-collateral/mountain/constants.ts @@ -0,0 +1,18 @@ +import { bn, fp } from '../../../../common/numbers' +import { networkConfig } from '../../../../common/configuration' + +// Mainnet Addresses +export const ARB_USDM = networkConfig['42161'].tokens.USDM as string +export const ARB_WUSDM = networkConfig['42161'].tokens.wUSDM as string +export const ARB_WUSDM_USD_PRICE_FEED = networkConfig['42161'].chainlinkFeeds.wUSDM as string +export const ARB_CHRONICLE_FEED_AUTH = '0x39aBD7819E5632Fa06D2ECBba45Dca5c90687EE3' +export const ARB_WUSDM_HOLDER = '0x8c60248a6ca9b6c5620279d40c12eb81e03cd667' +export const ARB_USDM_HOLDER = '0x4bd135524897333bec344e50ddd85126554e58b4' +export const PRICE_TIMEOUT = bn('604800') // 1 week +export const ORACLE_TIMEOUT = bn(86400) // 24 hours in seconds +export const ORACLE_ERROR = fp('0.01') // 1% +export const DEFAULT_THRESHOLD = ORACLE_ERROR.add(fp('0.01')) // 1% + ORACLE_ERROR +export const DELAY_UNTIL_DEFAULT = bn(86400) +export const MAX_TRADE_VOL = bn(1000) + +export const FORK_BLOCK_ARBITRUM = 213549300 diff --git a/test/plugins/individual-collateral/mountain/helpers.ts b/test/plugins/individual-collateral/mountain/helpers.ts new file mode 100644 index 000000000..bb08c8ae3 --- /dev/null +++ b/test/plugins/individual-collateral/mountain/helpers.ts @@ -0,0 +1,30 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { IERC20Metadata } from '../../../../typechain' +import { whileImpersonating } from '../../../utils/impersonation' +import { BigNumberish } from 'ethers' +import { FORK_BLOCK_ARBITRUM, ARB_WUSDM_HOLDER, ARB_USDM_HOLDER } from './constants' +import { getResetFork } from '../helpers' + +export const mintWUSDM = async ( + wusdm: IERC20Metadata, + account: SignerWithAddress, + amount: BigNumberish, + recipient: string +) => { + await whileImpersonating(ARB_WUSDM_HOLDER, async (whale) => { + await wusdm.connect(whale).transfer(recipient, amount) + }) +} + +export const mintUSDM = async ( + usdm: IERC20Metadata, + account: SignerWithAddress, + amount: BigNumberish, + recipient: string +) => { + await whileImpersonating(ARB_USDM_HOLDER, async (whale) => { + await usdm.connect(whale).transfer(recipient, amount) + }) +} + +export const resetFork = getResetFork(FORK_BLOCK_ARBITRUM)