From 0c04a2ffff4deb57cb88082109da89001bb95c93 Mon Sep 17 00:00:00 2001 From: "Julian M. Rodriguez" <56316686+julianmrodri@users.noreply.github.com> Date: Tue, 30 Jul 2024 20:15:19 -0300 Subject: [PATCH] ApxEth plugin and scripts (#1143) Co-authored-by: Taylor Brent --- .github/workflows/tests.yml | 2 +- common/configuration.ts | 6 + contracts/plugins/assets/lido/README.md | 2 +- .../assets/pirex-eth/ApxEthCollateral.sol | 74 ++++ contracts/plugins/assets/pirex-eth/README.md | 29 ++ .../assets/pirex-eth/vendor/IApxETH.sol | 13 + contracts/plugins/mocks/ApxEthMock.sol | 14 + .../addresses/1-tmp-assets-collateral.json | 4 +- .../1-tmp-assets-collateral.json | 2 + scripts/deploy.ts | 1 + .../collaterals/deploy_apxeth.ts | 95 +++++ .../collateral-plugins/verify_apxeth.ts | 63 +++ scripts/verify_etherscan.ts | 1 + .../frax-eth/SFrxEthTestSuite.test.ts | 2 +- .../pirex-eth/ApxEthCollateral.test.ts | 388 ++++++++++++++++++ .../pirex-eth/constants.ts | 27 ++ .../pirex-eth/helpers.ts | 32 ++ test/utils/oracles.ts | 11 +- 18 files changed, 760 insertions(+), 6 deletions(-) create mode 100644 contracts/plugins/assets/pirex-eth/ApxEthCollateral.sol create mode 100644 contracts/plugins/assets/pirex-eth/README.md create mode 100644 contracts/plugins/assets/pirex-eth/vendor/IApxETH.sol create mode 100644 contracts/plugins/mocks/ApxEthMock.sol create mode 100644 scripts/deployment/phase2-assets/collaterals/deploy_apxeth.ts create mode 100644 scripts/verification/collateral-plugins/verify_apxeth.ts create mode 100644 test/plugins/individual-collateral/pirex-eth/ApxEthCollateral.test.ts create mode 100644 test/plugins/individual-collateral/pirex-eth/constants.ts create mode 100644 test/plugins/individual-collateral/pirex-eth/helpers.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a33308486e..8bbd2a89fd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: - run: yarn install --immutable - run: yarn devchain & env: - MAINNET_RPC_URL: https://mainnet.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161 + MAINNET_RPC_URL: https://eth-mainnet.alchemyapi.io/v2/${{ secrets.ALCHEMY_MAINNET_KEY }} FORK_NETWORK: mainnet - run: yarn deploy:run --network localhost env: diff --git a/common/configuration.ts b/common/configuration.ts index b408263a1a..d5164fa638 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -105,6 +105,9 @@ export interface ITokens { steakPYUSD?: string Re7WETH?: string + pxETH?: string + apxETH?: string + // Ethena USDe?: string sUSDe?: string @@ -225,6 +228,8 @@ export const networkConfig: { [key: string]: INetworkConfig } = { stETH: '0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84', wstETH: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', rETH: '0xae78736Cd615f374D3085123A210448E74Fc6393', + pxETH: '0x04C154b66CB340F3Ae24111CC767e0184Ed00Cc6', + apxETH: '0x9Ba021B0a9b958B5E75cE9f6dff97C7eE52cb3E6', cUSDCv3: '0xc3d688B66703497DAA19211EEdff47f25384cdc3', wcUSDCv3: '0x27F2f159Fe990Ba83D57f39Fd69661764BEbf37a', ONDO: '0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3', @@ -277,6 +282,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { cbETH: '0xf017fcb346a1885194689ba23eff2fe6fa5c483b', // cbETH/ETH frxETH: '0xc58f3385fbc1c8ad2c0c9a061d7c13b141d7a5df', // frxETH/ETH pyUSD: '0x8f1dF6D7F2db73eECE86a18b4381F4707b918FB1', + apxETH: '0x19219BC90F48DeE4d5cF202E09c438FAacFd8Bea', // apxETH/ETH USDe: '0xa569d910839Ae8865Da8F8e70FfFb0cBA869F961', }, AAVE_INCENTIVES: '0xd784927Ff2f95ba542BfC824c8a8a98F3495f6b5', diff --git a/contracts/plugins/assets/lido/README.md b/contracts/plugins/assets/lido/README.md index 66232ed135..44c80caa7f 100644 --- a/contracts/plugins/assets/lido/README.md +++ b/contracts/plugins/assets/lido/README.md @@ -2,7 +2,7 @@ ## Summary -This plugin allows `wstETH` holders use their tokens as collateral in the Reverse Protocol. +This plugin allows `wstETH` holders use their tokens as collateral in the Reserve Protocol. As described in the [Lido Site](https://docs.lido.fi/guides/steth-integration-guide#wsteth) , `wstETH` is a LSD (Liquid staking derivatives) which enables users to sell or transfer stacked ETH even before withdrawal being enabled. diff --git a/contracts/plugins/assets/pirex-eth/ApxEthCollateral.sol b/contracts/plugins/assets/pirex-eth/ApxEthCollateral.sol new file mode 100644 index 0000000000..471416b100 --- /dev/null +++ b/contracts/plugins/assets/pirex-eth/ApxEthCollateral.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "../../../libraries/Fixed.sol"; +import "../AppreciatingFiatCollateral.sol"; +import "../OracleLib.sol"; +import "./vendor/IApxETH.sol"; + +/** + * @title apxETH Collateral + * @notice Collateral plugin for Dinero apxETH (Pirex-ETH) + * tok = apxETH + * ref = pxETH (pegged to ETH 1:1) + * tar = ETH + * UoA = USD + */ +contract ApxEthCollateral is AppreciatingFiatCollateral { + using OracleLib for AggregatorV3Interface; + using FixLib for uint192; + + AggregatorV3Interface public immutable targetPerTokChainlinkFeed; + uint48 public immutable targetPerTokChainlinkTimeout; + + /// @param config.chainlinkFeed {UoA/target} price of ETH in USD terms + /// @param _targetPerTokChainlinkFeed {target/tok} price of apxETH in ETH terms + constructor( + CollateralConfig memory config, + uint192 revenueHiding, + AggregatorV3Interface _targetPerTokChainlinkFeed, + uint48 _targetPerTokChainlinkTimeout + ) AppreciatingFiatCollateral(config, revenueHiding) { + require(config.defaultThreshold != 0, "defaultThreshold zero"); + require(address(_targetPerTokChainlinkFeed) != address(0), "missing targetPerTok feed"); + require(_targetPerTokChainlinkTimeout != 0, "targetPerTokChainlinkTimeout zero"); + + targetPerTokChainlinkFeed = _targetPerTokChainlinkFeed; + targetPerTokChainlinkTimeout = _targetPerTokChainlinkTimeout; + maxOracleTimeout = uint48(Math.max(maxOracleTimeout, _targetPerTokChainlinkTimeout)); + } + + /// Can revert, used by other contract functions in order to catch errors + /// @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 + override + returns ( + uint192 low, + uint192 high, + uint192 pegPrice + ) + { + uint192 targetPerTok = targetPerTokChainlinkFeed.price(targetPerTokChainlinkTimeout); + + // {UoA/tok} = {UoA/target} * {target/tok} + uint192 p = chainlinkFeed.price(oracleTimeout).mul(targetPerTok); + uint192 err = p.mul(oracleError, CEIL); + + high = p + err; + low = p - err; + // assert(low <= high); obviously true just by inspection + + // {target/ref} = {target/tok} / {ref/tok} + pegPrice = targetPerTok.div(underlyingRefPerTok()); + } + + /// @return {ref/tok} Quantity of whole reference units per whole collateral tokens + function underlyingRefPerTok() public view override returns (uint192) { + return _safeWrap(IApxETH(address(erc20)).assetsPerShare()); + } +} diff --git a/contracts/plugins/assets/pirex-eth/README.md b/contracts/plugins/assets/pirex-eth/README.md new file mode 100644 index 0000000000..5974b25b35 --- /dev/null +++ b/contracts/plugins/assets/pirex-eth/README.md @@ -0,0 +1,29 @@ +# Pirex apxETH (pxETH) Collateral Plugin + +## Summary + +This plugin allows `apxETH` holders use their tokens as collateral in the Reserve Protocol. + +As described in the [Dinero Site](https://dineroismoney.com/docs/pirex-eth-overview), Pirex ETH is an Ethereum liquid staking solution that consists of two tokens, `pxETH` and `apxETH`. + +Upon depositing ETH into the Dinero protocol through Pirex ETH, users receive `pxETH` - a liquid wrapper for staked ETH. However, the pxETH token itself does not earn any rewards. Users can deposit to Dinero's auto-compounding vaults to obtain `apxETH`, which is focused on maximizing their staking yields. Each `apxETH` benefits from staking rewards from more than one staked ETH, amplifying the yield for apxETH users. + +`apxETH` will accrue revenue from **staking rewards** into itself by **increasing** the exchange rate of `pxETH` per `apxETH`. + +`pxETH` contract: + +`apxETH` contract: + +## Implementation + +### Units + +| tok | ref | target | UoA | +| ------ | ----- | ------ | --- | +| apxETH | pxETH | ETH | USD | + +### Functions + +#### refPerTok {ref/tok} + +This function returns rate of `pxETH/apxETH`, getting from [assetsPerShare()](https://etherscan.io/token/0x9Ba021B0a9b958B5E75cE9f6dff97C7eE52cb3E6#readContract) function in wstETH contract. diff --git a/contracts/plugins/assets/pirex-eth/vendor/IApxETH.sol b/contracts/plugins/assets/pirex-eth/vendor/IApxETH.sol new file mode 100644 index 0000000000..bb80aa89e8 --- /dev/null +++ b/contracts/plugins/assets/pirex-eth/vendor/IApxETH.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; + +// External interface for apxETH +interface IApxETH is IERC20Metadata { + function assetsPerShare() external view returns (uint256); + + function setWithdrawalPenalty(uint256 penalty) external; + + function notifyRewardAmount() external; +} diff --git a/contracts/plugins/mocks/ApxEthMock.sol b/contracts/plugins/mocks/ApxEthMock.sol new file mode 100644 index 0000000000..2638ed2261 --- /dev/null +++ b/contracts/plugins/mocks/ApxEthMock.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "./ERC20Mock.sol"; + +contract ApxEthMock is ERC20Mock { + uint256 public assetsPerShare; + + constructor() ERC20Mock("Mock ApxETH", "ApxEth") {} + + function setAssetsPerShare(uint256 mockValue) external { + assetsPerShare = mockValue; + } +} diff --git a/scripts/addresses/1-tmp-assets-collateral.json b/scripts/addresses/1-tmp-assets-collateral.json index 96b670d82e..7a276f5fbe 100644 --- a/scripts/addresses/1-tmp-assets-collateral.json +++ b/scripts/addresses/1-tmp-assets-collateral.json @@ -59,6 +59,7 @@ "cvxCrvUSDUSDC": "0x9Fc0F31e2D26C437461a9eEBfe858d17e2611Ea5", "cvxCrvUSDUSDT": "0x69c6597690B8Df61D15F201519C03725bdec40c1", "sfrxETH": "0x4c891fCa6319d492866672E3D2AfdAAA5bDcfF67", + "apxETH": "0x05ffDaAA2aF48e1De1CE34d633db018a28e3B3F5", "sUSDe": "0x35081Ca24319835e5f759163F7e75eaB753e0b7E" }, "erc20s": { @@ -119,6 +120,7 @@ "sfrxETH": "0xac3E018457B222d93114458476f3E3416Abbe38F", "CRV": "0xD533a949740bb3306d119CC777fa900bA034cd52", "CVX": "0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B", + "apxETH": "0x9Ba021B0a9b958B5E75cE9f6dff97C7eE52cb3E6", "sUSDe": "0x9D39A5DE30e57443BfF2A8307A4256c8797A3497" } -} \ No newline at end of file +} diff --git a/scripts/addresses/mainnet-3.4.0/1-tmp-assets-collateral.json b/scripts/addresses/mainnet-3.4.0/1-tmp-assets-collateral.json index 9ab05c4160..7a276f5fbe 100644 --- a/scripts/addresses/mainnet-3.4.0/1-tmp-assets-collateral.json +++ b/scripts/addresses/mainnet-3.4.0/1-tmp-assets-collateral.json @@ -59,6 +59,7 @@ "cvxCrvUSDUSDC": "0x9Fc0F31e2D26C437461a9eEBfe858d17e2611Ea5", "cvxCrvUSDUSDT": "0x69c6597690B8Df61D15F201519C03725bdec40c1", "sfrxETH": "0x4c891fCa6319d492866672E3D2AfdAAA5bDcfF67", + "apxETH": "0x05ffDaAA2aF48e1De1CE34d633db018a28e3B3F5", "sUSDe": "0x35081Ca24319835e5f759163F7e75eaB753e0b7E" }, "erc20s": { @@ -119,6 +120,7 @@ "sfrxETH": "0xac3E018457B222d93114458476f3E3416Abbe38F", "CRV": "0xD533a949740bb3306d119CC777fa900bA034cd52", "CVX": "0x4e3FBD56CD56c3e72c1403e103b45Db9da5B9D2B", + "apxETH": "0x9Ba021B0a9b958B5E75cE9f6dff97C7eE52cb3E6", "sUSDe": "0x9D39A5DE30e57443BfF2A8307A4256c8797A3497" } } diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 5df8f30748..07e7ab8559 100644 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -83,6 +83,7 @@ async function main() { 'phase2-assets/collaterals/deploy_steakpyusd.ts', 'phase2-assets/collaterals/deploy_bbusdt.ts', 'phase2-assets/collaterals/deploy_re7weth.ts', + 'phase2-assets/collaterals/deploy_apxeth.ts', 'phase2-assets/collaterals/deploy_USDe.ts', 'phase2-assets/assets/deploy_crv.ts', 'phase2-assets/assets/deploy_cvx.ts' diff --git a/scripts/deployment/phase2-assets/collaterals/deploy_apxeth.ts b/scripts/deployment/phase2-assets/collaterals/deploy_apxeth.ts new file mode 100644 index 0000000000..22df12b046 --- /dev/null +++ b/scripts/deployment/phase2-assets/collaterals/deploy_apxeth.ts @@ -0,0 +1,95 @@ +import fs from 'fs' +import hre from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { 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 { priceTimeout, combinedError } from '../../utils' +import { ApxEthCollateral } from '../../../../typechain' +import { + ETH_ORACLE_ERROR, + ETH_ORACLE_TIMEOUT, + APXETH_ORACLE_ERROR, + APXETH_ORACLE_TIMEOUT, + DELAY_UNTIL_DEFAULT, +} from '../../../../test/plugins/individual-collateral/pirex-eth/constants' +import { ContractFactory } from 'ethers' + +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 ApxETH Collateral - apxETH **************************/ + + const ApxEthCollateralFactoryCollateralFactory: ContractFactory = + await hre.ethers.getContractFactory('ApxEthCollateral') + + const oracleError = combinedError(ETH_ORACLE_ERROR, APXETH_ORACLE_ERROR) // 0.5% & 1% + + const collateral = await ApxEthCollateralFactoryCollateralFactory.connect( + deployer + ).deploy( + { + priceTimeout: priceTimeout.toString(), + chainlinkFeed: networkConfig[chainId].chainlinkFeeds.ETH, + oracleError: oracleError.toString(), + erc20: networkConfig[chainId].tokens.apxETH, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: ETH_ORACLE_TIMEOUT.toString(), // 1 hr, + targetName: hre.ethers.utils.formatBytes32String('ETH'), + defaultThreshold: fp('0.02').add(APXETH_ORACLE_ERROR).toString(), // 3% + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), // 72h + }, + fp('1e-4').toString(), // revenueHiding = 0.01% + networkConfig[chainId].chainlinkFeeds.apxETH, // targetPerTokChainlinkFeed + APXETH_ORACLE_TIMEOUT.toString() // targetPerTokChainlinkTimeout - 24h + ) + await collateral.deployed() + await (await collateral.refresh()).wait() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + console.log(`Deployed ApxETH to ${hre.network.name} (${chainId}): ${collateral.address}`) + + assetCollDeployments.collateral.apxETH = collateral.address + assetCollDeployments.erc20s.apxETH = networkConfig[chainId].tokens.apxETH + 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_apxeth.ts b/scripts/verification/collateral-plugins/verify_apxeth.ts new file mode 100644 index 0000000000..c133541886 --- /dev/null +++ b/scripts/verification/collateral-plugins/verify_apxeth.ts @@ -0,0 +1,63 @@ +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 { + ETH_ORACLE_TIMEOUT, + ETH_ORACLE_ERROR, + DELAY_UNTIL_DEFAULT, + APXETH_ORACLE_ERROR, + APXETH_ORACLE_TIMEOUT, +} from '../../../test/plugins/individual-collateral/pirex-eth/constants' +import { priceTimeout, verifyContract, combinedError } from '../../deployment/utils' + +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 ApxETH - apxETH **************************/ + const oracleError = combinedError(ETH_ORACLE_ERROR, APXETH_ORACLE_ERROR) // 0.5% & 1% + await verifyContract( + chainId, + deployments.collateral.apxETH, + [ + { + priceTimeout: priceTimeout.toString(), + chainlinkFeed: networkConfig[chainId].chainlinkFeeds.ETH, + oracleError: oracleError.toString(), // 0.5% & 1%, + erc20: networkConfig[chainId].tokens.apxETH, + maxTradeVolume: fp('1e6').toString(), // $1m, + oracleTimeout: ETH_ORACLE_TIMEOUT.toString(), // 1 hr, + targetName: hre.ethers.utils.formatBytes32String('ETH'), + defaultThreshold: fp('0.02').add(APXETH_ORACLE_ERROR).toString(), // 3% + delayUntilDefault: DELAY_UNTIL_DEFAULT.toString(), // 72h + }, + fp('1e-4'), // revenueHiding = 0.01% + networkConfig[chainId].chainlinkFeeds.apxETH, // targetPerTokChainlinkFeed + APXETH_ORACLE_TIMEOUT.toString(), // targetPerTokChainlinkTimeout + ], + 'contracts/plugins/assets/pirex-eth/ApxEthCollateral.sol:ApxEthCollateral' + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verify_etherscan.ts b/scripts/verify_etherscan.ts index c24bf7b29b..1aed44c0fe 100644 --- a/scripts/verify_etherscan.ts +++ b/scripts/verify_etherscan.ts @@ -77,6 +77,7 @@ async function main() { 'collateral-plugins/verify_sfrax_eth.ts', 'collateral-plugins/verify_steakusdc.ts', 'collateral-plugins/verify_re7weth.ts', + 'collateral-plugins/verify_apxeth.ts', 'collateral-plugins/verify_USDe.ts' ) } else if (chainId == '8453' || chainId == '84531') { diff --git a/test/plugins/individual-collateral/frax-eth/SFrxEthTestSuite.test.ts b/test/plugins/individual-collateral/frax-eth/SFrxEthTestSuite.test.ts index ca3c443e88..b15e1df41e 100644 --- a/test/plugins/individual-collateral/frax-eth/SFrxEthTestSuite.test.ts +++ b/test/plugins/individual-collateral/frax-eth/SFrxEthTestSuite.test.ts @@ -309,7 +309,7 @@ const opts = { itChecksTargetPerRefDefaultUp: it.skip, itChecksRefPerTokDefault: it.skip, itChecksPriceChanges: it, - itHasRevenueHiding: it.skip, // implemnted in this file + itHasRevenueHiding: it.skip, // implemented in this file itChecksNonZeroDefaultThreshold: it, resetFork, collateralName: 'SFraxEthCollateral', diff --git a/test/plugins/individual-collateral/pirex-eth/ApxEthCollateral.test.ts b/test/plugins/individual-collateral/pirex-eth/ApxEthCollateral.test.ts new file mode 100644 index 0000000000..db143ad603 --- /dev/null +++ b/test/plugins/individual-collateral/pirex-eth/ApxEthCollateral.test.ts @@ -0,0 +1,388 @@ +import collateralTests from '../collateralTests' +import { CollateralFixtureContext, CollateralOpts, MintCollateralFunc } from '../pluginTestTypes' +import { resetFork, mintAPXETH, mintPxETH } from './helpers' +import hre, { ethers } from 'hardhat' +import { expect } from 'chai' +import { ContractFactory, BigNumberish, BigNumber } from 'ethers' +import { + ERC20Mock, + MockV3Aggregator, + MockV3Aggregator__factory, + TestICollateral, + IApxETH, + ApxEthMock, + WETH9, +} from '../../../../typechain' +import { pushOracleForward } from '../../../utils/oracles' +import { advanceBlocks, advanceTime, getLatestBlockTimestamp } from '../../../utils/time' +import { bn, fp } from '../../../../common/numbers' +import { CollateralStatus, ZERO_ADDRESS, MAX_UINT48 } from '../../../../common/constants' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { + ETH_ORACLE_ERROR, + ETH_ORACLE_TIMEOUT, + PRICE_TIMEOUT, + MAX_TRADE_VOL, + DEFAULT_THRESHOLD, + DELAY_UNTIL_DEFAULT, + WETH, + PXETH, + APXETH, + ETH_USD_PRICE_FEED, + APXETH_ETH_PRICE_FEED, + PIREX_ETH, + APXETH_ORACLE_TIMEOUT, +} from './constants' +import { whileImpersonating } from '#/test/utils/impersonation' + +/* + Define interfaces +*/ + +interface ApxEthCollateralFixtureContext extends CollateralFixtureContext { + weth: WETH9 + pxEth: ERC20Mock + apxEth: IApxETH + targetPerTokChainlinkFeed: MockV3Aggregator +} + +interface ApxEthCollateralFixtureContextMock extends ApxEthCollateralFixtureContext { + apxEthMock: ApxEthMock +} + +interface ApxEthCollateralOpts extends CollateralOpts { + targetPerTokChainlinkFeed?: string + targetPerTokChainlinkTimeout?: BigNumberish +} + +/* + Define deployment functions +*/ + +export const defaultApxEthCollateralOpts: ApxEthCollateralOpts = { + erc20: APXETH, + targetName: ethers.utils.formatBytes32String('ETH'), + rewardERC20: ZERO_ADDRESS, + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: ETH_USD_PRICE_FEED, + oracleTimeout: ETH_ORACLE_TIMEOUT, + oracleError: ETH_ORACLE_ERROR, + maxTradeVolume: MAX_TRADE_VOL, + defaultThreshold: DEFAULT_THRESHOLD, + delayUntilDefault: DELAY_UNTIL_DEFAULT, + targetPerTokChainlinkFeed: APXETH_ETH_PRICE_FEED, + targetPerTokChainlinkTimeout: APXETH_ORACLE_TIMEOUT, + revenueHiding: fp('0'), +} + +export const deployCollateral = async ( + opts: ApxEthCollateralOpts = {} +): Promise => { + opts = { ...defaultApxEthCollateralOpts, ...opts } + + const ApxEthCollateralFactory: ContractFactory = await ethers.getContractFactory( + 'ApxEthCollateral' + ) + + const collateral = await ApxEthCollateralFactory.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, + opts.targetPerTokChainlinkFeed, + opts.targetPerTokChainlinkTimeout, + { gasLimit: 2000000000 } + ) + await collateral.deployed() + + // Push forward chainlink feed + await pushOracleForward(opts.chainlinkFeed!) + await pushOracleForward(opts.targetPerTokChainlinkFeed!) + + // 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 +} + +const chainlinkDefaultAnswer = bn('1600e8') +const refPerTokChainlinkDefaultAnswer = fp('1.0283') + +type Fixture = () => Promise + +const makeCollateralFixtureContext = ( + alice: SignerWithAddress, + opts: ApxEthCollateralOpts = {} +): Fixture => { + const collateralOpts = { ...defaultApxEthCollateralOpts, ...opts } + + const makeCollateralFixtureContext = async () => { + const MockV3AggregatorFactory = ( + await ethers.getContractFactory('MockV3Aggregator') + ) + + const chainlinkFeed = ( + await MockV3AggregatorFactory.deploy(8, chainlinkDefaultAnswer) + ) + + const targetPerTokChainlinkFeed = ( + await MockV3AggregatorFactory.deploy(18, refPerTokChainlinkDefaultAnswer) + ) + collateralOpts.chainlinkFeed = chainlinkFeed.address + collateralOpts.targetPerTokChainlinkFeed = targetPerTokChainlinkFeed.address + + const weth = (await ethers.getContractAt('WETH9', WETH)) as WETH9 + const pxEth = (await ethers.getContractAt('ERC20Mock', PXETH)) as ERC20Mock + const apxEth = (await ethers.getContractAt('IApxETH', APXETH)) as IApxETH + const rewardToken = (await ethers.getContractAt('ERC20Mock', ZERO_ADDRESS)) as ERC20Mock + const collateral = await deployCollateral(collateralOpts) + + return { + alice, + collateral, + chainlinkFeed, + weth, + pxEth, + apxEth, + targetPerTokChainlinkFeed, + tok: apxEth, + rewardToken, + } + } + + return makeCollateralFixtureContext +} + +const deployCollateralApxEthMockContext = async ( + opts: ApxEthCollateralOpts = {} +): Promise => { + const collateralOpts = { ...defaultApxEthCollateralOpts, ...opts } + + const MockV3AggregatorFactory = ( + await ethers.getContractFactory('MockV3Aggregator') + ) + const chainlinkFeed = await MockV3AggregatorFactory.deploy(8, bn('1e8')) + collateralOpts.chainlinkFeed = chainlinkFeed.address + + const MockFactory = await ethers.getContractFactory('ApxEthMock') + const erc20 = (await MockFactory.deploy()) as ApxEthMock + const currentAPS = await (await ethers.getContractAt('IApxETH', APXETH)).assetsPerShare() + await erc20.setAssetsPerShare(currentAPS) + + const targetPerTokChainlinkFeed = ( + await MockV3AggregatorFactory.deploy(18, refPerTokChainlinkDefaultAnswer) + ) + collateralOpts.targetPerTokChainlinkFeed = targetPerTokChainlinkFeed.address + + const weth = (await ethers.getContractAt('WETH9', WETH)) as WETH9 + const pxEth = (await ethers.getContractAt('ERC20Mock', PXETH)) as ERC20Mock + const apxEth = (await ethers.getContractAt('IApxETH', APXETH)) as IApxETH + + collateralOpts.erc20 = erc20.address + const collateral = await deployCollateral(collateralOpts) + + return { + weth, + collateral, + chainlinkFeed, + targetPerTokChainlinkFeed, + pxEth, + apxEth, + apxEthMock: erc20, + tok: erc20, + } +} + +/* + Define helper functions +*/ + +const mintCollateralTo: MintCollateralFunc = async ( + ctx: ApxEthCollateralFixtureContext, + amount: BigNumberish, + user: SignerWithAddress, + recipient: string +) => { + await mintAPXETH(ctx.apxEth, user, amount, recipient) +} + +const changeTargetPerRef = async ( + ctx: ApxEthCollateralFixtureContext, + percentChange: BigNumber +) => { + // We leave the actual refPerTok exchange where it is and just change {target/tok} + { + const lastRound = await ctx.targetPerTokChainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.add(lastRound.answer.mul(percentChange).div(100)) + await ctx.targetPerTokChainlinkFeed.updateAnswer(nextAnswer) + } +} + +const reduceTargetPerRef = async ( + ctx: ApxEthCollateralFixtureContext, + pctDecrease: BigNumberish +) => { + await changeTargetPerRef(ctx, bn(pctDecrease).mul(-1)) +} + +const increaseTargetPerRef = async ( + ctx: ApxEthCollateralFixtureContext, + pctDecrease: BigNumberish +) => { + await changeTargetPerRef(ctx, bn(pctDecrease)) +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const reduceRefPerTok = async (ctx: ApxEthCollateralFixtureContext, pctDecrease: BigNumberish) => { + await hre.network.provider.send('evm_mine', []) +} + +// prettier-ignore +const increaseRefPerTok = async ( + ctx: ApxEthCollateralFixtureContext, + pctIncrease: BigNumberish +) => { + const currentBal = await ctx.pxEth.balanceOf(ctx.apxEth.address) + const addBal = currentBal.mul(pctIncrease).div(100) + + await mintPxETH(ctx.pxEth, ctx.alice!, addBal, ctx.apxEth.address) + await advanceBlocks(86400 / 12) + await advanceTime(86400) + + // Notify rewards + await whileImpersonating(PIREX_ETH, async (pirexETHSigner) => { + await ctx.apxEth.connect(pirexETHSigner).notifyRewardAmount() + }) + + // push chainlink oracles forward so that tryPrice() still works + const latestRoundData = await ctx.chainlinkFeed.latestRoundData() + await ctx.chainlinkFeed.updateAnswer(latestRoundData.answer) + + // Adjust apxETH/ETH price as well + const lastRound = await ctx.targetPerTokChainlinkFeed.latestRoundData() + const nextAnswer = lastRound.answer.add(lastRound.answer.mul(pctIncrease).div(100)) + await ctx.targetPerTokChainlinkFeed.updateAnswer(nextAnswer) +} + +const getExpectedPrice = async (ctx: ApxEthCollateralFixtureContext): Promise => { + const clData = await ctx.chainlinkFeed.latestRoundData() + const clDecimals = await ctx.chainlinkFeed.decimals() + + const clRptData = await ctx.targetPerTokChainlinkFeed.latestRoundData() + const clRptDecimals = await ctx.targetPerTokChainlinkFeed.decimals() + + return clData.answer + .mul(bn(10).pow(18 - clDecimals)) + .mul(clRptData.answer.mul(bn(10).pow(18 - clRptDecimals))) + .div(fp('1')) +} + +/* + Define collateral-specific tests +*/ + +const collateralSpecificConstructorTests = () => { + it('does not allow missing targetPerTok chainlink feed', async () => { + await expect( + deployCollateral({ targetPerTokChainlinkFeed: ethers.constants.AddressZero }) + ).to.be.revertedWith('missing targetPerTok feed') + }) + + it('does not allow targetPerTok oracle timeout at 0', async () => { + await expect(deployCollateral({ targetPerTokChainlinkTimeout: 0 })).to.be.revertedWith( + 'targetPerTokChainlinkTimeout zero' + ) + }) +} + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const collateralSpecificStatusTests = () => { + it('does revenue hiding correctly', async () => { + const { collateral, apxEthMock } = await deployCollateralApxEthMockContext({ + revenueHiding: fp('0.01'), + }) + + const currentAPS = await (await ethers.getContractAt('IApxETH', APXETH)).assetsPerShare() + + // Should remain SOUND after a 1% decrease + let refPerTok = await collateral.refPerTok() + const newAPS = currentAPS.sub(currentAPS.div(100)) + await apxEthMock.setAssetsPerShare(newAPS) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + + // refPerTok should be unchanged + expect(await 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 collateral.refPerTok() + await apxEthMock.setAssetsPerShare(newAPS.sub(newAPS.div(100))) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + + // refPerTok should have fallen 1% + refPerTok = refPerTok.sub(refPerTok.div(100)) + expect(await collateral.refPerTok()).to.be.closeTo(refPerTok, refPerTok.div(bn('1e3'))) // within 1-part-in-1-thousand + }) + + it('enters DISABLED state when refPerTok() decreases', async () => { + const { collateral, apxEthMock } = await deployCollateralApxEthMockContext({ + revenueHiding: fp('0.01'), + }) + + // Check initial state + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + expect(await collateral.whenDefault()).to.equal(MAX_UINT48) + await expect(collateral.refresh()).to.not.emit(collateral, 'CollateralStatusChanged') + + // Should default instantly after 10% drop + const currentAPS = await apxEthMock.assetsPerShare() + await apxEthMock.setAssetsPerShare(currentAPS.sub(currentAPS.mul(10).div(100))) + await expect(collateral.refresh()).to.emit(collateral, 'CollateralStatusChanged') + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + expect(await collateral.whenDefault()).to.equal(await getLatestBlockTimestamp()) + }) +} + +// 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.skip, // implemented in this file + itChecksPriceChanges: it, + itChecksNonZeroDefaultThreshold: it, + itHasRevenueHiding: it.skip, // implemented in this file + resetFork, + collateralName: 'ApxETH', + chainlinkDefaultAnswer, + itIsPricedByPeg: true, +} + +collateralTests(opts) diff --git a/test/plugins/individual-collateral/pirex-eth/constants.ts b/test/plugins/individual-collateral/pirex-eth/constants.ts new file mode 100644 index 0000000000..d1c53206aa --- /dev/null +++ b/test/plugins/individual-collateral/pirex-eth/constants.ts @@ -0,0 +1,27 @@ +import { bn, fp } from '../../../../common/numbers' +import { networkConfig } from '../../../../common/configuration' + +// Mainnet Addresses +export const RSR = networkConfig['31337'].tokens.RSR as string +export const ETH_USD_PRICE_FEED = networkConfig['31337'].chainlinkFeeds.ETH as string +export const APXETH = networkConfig['31337'].tokens.apxETH as string +export const PXETH = networkConfig['31337'].tokens.pxETH as string + +export const APXETH_ETH_PRICE_FEED = networkConfig['31337'].chainlinkFeeds.apxETH as string +export const APXETH_OWNER = '0xA52Fd396891E7A74b641a2Cb1A6999Fcf56B077e' +export const PIREX_ETH = '0xD664b74274DfEB538d9baC494F3a4760828B02b0' +export const WETH = networkConfig['31337'].tokens.WETH as string +export const APXETH_WHALE = '0xa5cCBD739e7f5662b95D269ee9A48a37cBFb88Bc' +export const PXETH_WHALE = '0x1cd5b73d12CB23b2835C873E4FaFfE83bBCef208' + +export const PRICE_TIMEOUT = bn('604800') // 1 week +export const ETH_ORACLE_TIMEOUT = bn(3600) // 1 hour in seconds +export const ETH_ORACLE_ERROR = fp('0.005') +export const APXETH_ORACLE_ERROR = fp('0.01') // 1% +export const APXETH_ORACLE_TIMEOUT = bn(86400) // 24h + +export const DEFAULT_THRESHOLD = fp('0.05') // 5% +export const DELAY_UNTIL_DEFAULT = bn(259200) // 72h +export const MAX_TRADE_VOL = bn(1000) + +export const FORK_BLOCK = 19868380 diff --git a/test/plugins/individual-collateral/pirex-eth/helpers.ts b/test/plugins/individual-collateral/pirex-eth/helpers.ts new file mode 100644 index 0000000000..9232f82eb9 --- /dev/null +++ b/test/plugins/individual-collateral/pirex-eth/helpers.ts @@ -0,0 +1,32 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { IApxETH, ERC20Mock } from '../../../../typechain' +import { whileImpersonating } from '../../../utils/impersonation' +import { BigNumberish } from 'ethers' +import { FORK_BLOCK, APXETH_WHALE, PXETH_WHALE } from './constants' +import { getResetFork } from '../helpers' + +export const mintAPXETH = async ( + apxETH: IApxETH, + account: SignerWithAddress, + amount: BigNumberish, + recipient: string +) => { + // transfer from an apxETH whale instead of depositing + await whileImpersonating(APXETH_WHALE, async (apxEthWhale) => { + await apxETH.connect(apxEthWhale).transfer(recipient, amount) + }) +} + +export const mintPxETH = async ( + pxETH: ERC20Mock, + account: SignerWithAddress, + amount: BigNumberish, + recipient: string +) => { + // transfer from a pxETH whale instead of depositing + await whileImpersonating(PXETH_WHALE, async (pxEthWhale) => { + await pxETH.connect(pxEthWhale).transfer(recipient, amount) + }) +} + +export const resetFork = getResetFork(FORK_BLOCK) diff --git a/test/utils/oracles.ts b/test/utils/oracles.ts index 6dde6dec02..eb91dc91db 100644 --- a/test/utils/oracles.ts +++ b/test/utils/oracles.ts @@ -4,7 +4,7 @@ import { BigNumber } from 'ethers' import { ethers, network } from 'hardhat' import { expect } from 'chai' import { fp, bn, divCeil } from '../../common/numbers' -import { MAX_UINT192 } from '../../common/constants' +import { MAX_UINT192, ONE_ADDRESS } from '../../common/constants' import { getLatestBlockTimestamp } from './time' import { whileImpersonating } from './impersonation' @@ -144,7 +144,14 @@ export const overrideOracle = async (oracleAddress: string): Promise