diff --git a/common/numbers.ts b/common/numbers.ts index 6d53f464d1..d49a2a6606 100644 --- a/common/numbers.ts +++ b/common/numbers.ts @@ -16,7 +16,9 @@ export const pow10 = (exponent: BigNumberish): BigNumber => { // Convert `x` to a new BigNumber with decimals = `decimals`. // Input should have SCALE_DECIMALS (18) decimal places, and `decimals` should be less than 18. export const toBNDecimals = (x: BigNumberish, decimals: number): BigNumber => { - return BigNumber.from(x).div(pow10(SCALE_DECIMALS - decimals)) + return decimals < SCALE_DECIMALS + ? BigNumber.from(x).div(pow10(SCALE_DECIMALS - decimals)) + : BigNumber.from(x).mul(pow10(decimals - SCALE_DECIMALS)) } // Convert to the BigNumber representing a Fix from a BigNumberish. diff --git a/test/plugins/individual-collateral/collateralTests.ts b/test/plugins/individual-collateral/collateralTests.ts index e077f3567f..da6b098630 100644 --- a/test/plugins/individual-collateral/collateralTests.ts +++ b/test/plugins/individual-collateral/collateralTests.ts @@ -1,25 +1,30 @@ import { expect } from 'chai' import hre, { ethers } from 'hardhat' import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' +import { anyValue } from '@nomicfoundation/hardhat-chai-matchers/withArgs' import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' -import { BigNumber } from 'ethers' +import { BigNumber, ContractFactory } from 'ethers' import { useEnv } from '#/utils/env' import { getChainId } from '../../../common/blockchain-utils' -import { networkConfig } from '../../../common/configuration' -import { bn, fp } from '../../../common/numbers' -import { - IERC20Metadata, - InvalidMockV3Aggregator, - MockV3Aggregator, - TestICollateral, -} from '../../../typechain' +import { bn, fp, toBNDecimals } from '../../../common/numbers' +import { DefaultFixture, Fixture, getDefaultFixture, ORACLE_TIMEOUT } from './fixtures' +import { expectInIndirectReceipt } from '../../../common/events' +import { whileImpersonating } from '../../utils/impersonation' +import { IGovParams, IGovRoles, IRTokenSetup, networkConfig } from '../../../common/configuration' import { advanceTime, advanceBlocks, + getLatestBlockNumber, getLatestBlockTimestamp, setNextBlockTimestamp, } from '../../utils/time' -import { MAX_UINT48, MAX_UINT192 } from '../../../common/constants' +import { + MAX_UINT48, + MAX_UINT192, + MAX_UINT256, + TradeKind, + ZERO_ADDRESS, +} from '../../../common/constants' import { CollateralFixtureContext, CollateralTestSuiteFixtures, @@ -31,10 +36,24 @@ import { expectPrice, expectUnpriced, } from '../../utils/oracles' +import { + ERC20Mock, + FacadeWrite, + IAssetRegistry, + IERC20Metadata, + InvalidMockV3Aggregator, + MockV3Aggregator, + TestIBackingManager, + TestIBasketHandler, + TestICollateral, + TestIDeployer, + TestIMain, + TestIRevenueTrader, + TestIRToken, +} from '../../../typechain' import snapshotGasCost from '../../utils/snapshotGasCost' -import { IMPLEMENTATION, Implementation } from '../../fixtures' +import { IMPLEMENTATION, Implementation, ORACLE_ERROR, PRICE_TIMEOUT } from '../../fixtures' -// const describeFork = useEnv('FORK') ? describe : describe.skip const getDescribeFork = (targetNetwork = 'mainnet') => { return useEnv('FORK') && useEnv('FORK_NETWORK') === targetNetwork ? describe : describe.skip } @@ -605,5 +624,368 @@ export default function fn( }) }) }) + + describe('integration tests', () => { + before(resetFork) + + let ctx: X + let owner: SignerWithAddress + let addr1: SignerWithAddress + + let chainId: number + + let defaultFixture: Fixture + + let supply: BigNumber + + // Tokens/Assets + let pairedColl: TestICollateral + let pairedERC20: ERC20Mock + let collateralERC20: IERC20Metadata + let collateral: TestICollateral + + // Core Contracts + let main: TestIMain + let rToken: TestIRToken + let assetRegistry: IAssetRegistry + let backingManager: TestIBackingManager + let basketHandler: TestIBasketHandler + let rTokenTrader: TestIRevenueTrader + + let deployer: TestIDeployer + let facadeWrite: FacadeWrite + let govParams: IGovParams + let govRoles: IGovRoles + + const config = { + dist: { + rTokenDist: bn(100), // 100% RToken + rsrDist: bn(0), // 0% RSR + }, + minTradeVolume: bn('0'), // $0 + rTokenMaxTradeVolume: MAX_UINT192, // +inf + shortFreeze: bn('259200'), // 3 days + longFreeze: bn('2592000'), // 30 days + rewardRatio: bn('1069671574938'), // approx. half life of 90 days + unstakingDelay: bn('1209600'), // 2 weeks + withdrawalLeak: fp('0'), // 0%; always refresh + warmupPeriod: bn('60'), // (the delay _after_ SOUND was regained) + tradingDelay: bn('0'), // (the delay _after_ default has been confirmed) + batchAuctionLength: bn('900'), // 15 minutes + dutchAuctionLength: bn('1800'), // 30 minutes + backingBuffer: fp('0'), // 0% + maxTradeSlippage: fp('0.01'), // 1% + issuanceThrottle: { + amtRate: fp('1e6'), // 1M RToken + pctRate: fp('0.05'), // 5% + }, + redemptionThrottle: { + amtRate: fp('1e6'), // 1M RToken + pctRate: fp('0.05'), // 5% + }, + } + + interface IntegrationFixture { + ctx: X + protocol: DefaultFixture + } + + const integrationFixture: Fixture = + async function (): Promise { + return { + ctx: await loadFixture( + makeCollateralFixtureContext(owner, { maxTradeVolume: MAX_UINT192 }) + ), + protocol: await loadFixture(defaultFixture), + } + } + + before(async () => { + defaultFixture = await getDefaultFixture(collateralName) + chainId = await getChainId(hre) + if (useEnv('FORK_NETWORK').toLowerCase() === 'base') chainId = 8453 + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + ;[, owner, addr1] = await ethers.getSigners() + }) + + beforeEach(async () => { + let protocol: DefaultFixture + ;({ ctx, protocol } = await loadFixture(integrationFixture)) + ;({ collateral } = ctx) + ;({ deployer, facadeWrite, govParams } = protocol) + + supply = fp('1') + + // Create a paired collateral of the same targetName + pairedColl = await makePairedCollateral(await collateral.targetName()) + await pairedColl.refresh() + expect(await pairedColl.status()).to.equal(CollateralStatus.SOUND) + pairedERC20 = await ethers.getContractAt('ERC20Mock', await pairedColl.erc20()) + + // Prep collateral + collateralERC20 = await ethers.getContractAt('IERC20Metadata', await collateral.erc20()) + await mintCollateralTo( + ctx, + toBNDecimals(fp('1'), await collateralERC20.decimals()), + addr1, + addr1.address + ) + + // Set primary basket + const rTokenSetup: IRTokenSetup = { + assets: [], + primaryBasket: [collateral.address, pairedColl.address], + weights: [fp('0.5e-4'), fp('0.5e-4')], + backups: [], + beneficiaries: [], + } + + // Deploy RToken via FacadeWrite + const receipt = await ( + await facadeWrite.connect(owner).deployRToken( + { + name: 'RTKN RToken', + symbol: 'RTKN', + mandate: 'mandate', + params: config, + }, + rTokenSetup + ) + ).wait() + + // Get Main + const mainAddr = expectInIndirectReceipt(receipt, deployer.interface, 'RTokenCreated').args + .main + main = await ethers.getContractAt('TestIMain', mainAddr) + + // Get core contracts + assetRegistry = ( + await ethers.getContractAt('IAssetRegistry', await main.assetRegistry()) + ) + backingManager = ( + await ethers.getContractAt('TestIBackingManager', await main.backingManager()) + ) + basketHandler = ( + await ethers.getContractAt('TestIBasketHandler', await main.basketHandler()) + ) + rToken = await ethers.getContractAt('TestIRToken', await main.rToken()) + rTokenTrader = ( + await ethers.getContractAt('TestIRevenueTrader', await main.rTokenTrader()) + ) + + // Set initial governance roles + govRoles = { + owner: owner.address, + guardian: ZERO_ADDRESS, + pausers: [], + shortFreezers: [], + longFreezers: [], + } + // Setup owner and unpause + await facadeWrite.connect(owner).setupGovernance( + rToken.address, + false, // do not deploy governance + true, // unpaused + govParams, // mock values, not relevant + govRoles + ) + + // Advance past warmup period + await setNextBlockTimestamp( + (await getLatestBlockTimestamp()) + (await basketHandler.warmupPeriod()) + ) + + // Should issue + await collateralERC20.connect(addr1).approve(rToken.address, MAX_UINT256) + await pairedERC20.connect(addr1).approve(rToken.address, MAX_UINT256) + await rToken.connect(addr1).issue(supply) + }) + + it('can be put into an RToken basket', async () => { + await assetRegistry.refresh() + expect(await basketHandler.status()).to.equal(CollateralStatus.SOUND) + }) + + it('issues', async () => { + // Issuance in beforeEach + expect(await rToken.totalSupply()).to.equal(supply) + }) + + it('redeems', async () => { + await rToken.connect(addr1).redeem(supply) + expect(await rToken.totalSupply()).to.equal(0) + const initialCollBal = toBNDecimals(fp('1'), await collateralERC20.decimals()) + expect(await collateralERC20.balanceOf(addr1.address)).to.be.closeTo( + initialCollBal, + initialCollBal.div(bn('1e5')) // 1-part-in-100k + ) + }) + + it('rebalances out of the collateral', async () => { + // Remove collateral from basket + await basketHandler.connect(owner).setPrimeBasket([pairedERC20.address], [fp('1e-4')]) + await expect(basketHandler.connect(owner).refreshBasket()) + .to.emit(basketHandler, 'BasketSet') + .withArgs(anyValue, [pairedERC20.address], [fp('1e-4')], false) + await setNextBlockTimestamp( + (await getLatestBlockTimestamp()) + config.warmupPeriod.toNumber() + ) + + // Run rebalancing auction + await expect(backingManager.rebalance(TradeKind.DUTCH_AUCTION)) + .to.emit(backingManager, 'TradeStarted') + .withArgs(anyValue, collateralERC20.address, pairedERC20.address, anyValue, anyValue) + const tradeAddr = await backingManager.trades(collateralERC20.address) + expect(tradeAddr).to.not.equal(ZERO_ADDRESS) + const trade = await ethers.getContractAt('DutchTrade', tradeAddr) + expect(await trade.sell()).to.equal(collateralERC20.address) + expect(await trade.buy()).to.equal(pairedERC20.address) + const buyAmt = await trade.bidAmount(await trade.endBlock()) + await pairedERC20.connect(addr1).approve(trade.address, buyAmt) + await advanceBlocks((await trade.endBlock()).sub(await getLatestBlockNumber()).sub(1)) + const pairedBal = await pairedERC20.balanceOf(backingManager.address) + await expect(trade.connect(addr1).bid()).to.emit(backingManager, 'TradeSettled') + expect(await pairedERC20.balanceOf(backingManager.address)).to.be.gt(pairedBal) + expect(await backingManager.tradesOpen()).to.equal(0) + }) + + it('forwards revenue and sells in a revenue auction', async () => { + // Send excess collateral to the RToken trader via forwardRevenue() + const mintAmt = toBNDecimals(fp('1e-6'), await collateralERC20.decimals()) + await mintCollateralTo( + ctx, + mintAmt.gt('150') ? mintAmt : bn('150'), + addr1, + backingManager.address + ) + await backingManager.forwardRevenue([collateralERC20.address]) + expect(await collateralERC20.balanceOf(rTokenTrader.address)).to.be.gt(0) + + // Run revenue auction + await expect( + rTokenTrader.manageTokens([collateralERC20.address], [TradeKind.DUTCH_AUCTION]) + ) + .to.emit(rTokenTrader, 'TradeStarted') + .withArgs(anyValue, collateralERC20.address, rToken.address, anyValue, anyValue) + const tradeAddr = await rTokenTrader.trades(collateralERC20.address) + expect(tradeAddr).to.not.equal(ZERO_ADDRESS) + const trade = await ethers.getContractAt('DutchTrade', tradeAddr) + expect(await trade.sell()).to.equal(collateralERC20.address) + expect(await trade.buy()).to.equal(rToken.address) + const buyAmt = await trade.bidAmount(await trade.endBlock()) + await rToken.connect(addr1).approve(trade.address, buyAmt) + await advanceBlocks((await trade.endBlock()).sub(await getLatestBlockNumber()).sub(1)) + await expect(trade.connect(addr1).bid()).to.emit(rTokenTrader, 'TradeSettled') + expect(await rTokenTrader.tradesOpen()).to.equal(0) + }) + + // === Integration Test Helpers === + + const makePairedCollateral = async (target: string): Promise => { + const onBase = useEnv('FORK_NETWORK').toLowerCase() == 'base' + const MockV3AggregatorFactory: ContractFactory = await ethers.getContractFactory( + 'MockV3Aggregator' + ) + const chainlinkFeed: MockV3Aggregator = ( + await MockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + + if (target == ethers.utils.formatBytes32String('USD')) { + // USD + const erc20 = await ethers.getContractAt( + 'IERC20Metadata', + onBase ? networkConfig[chainId].tokens.USDbC! : networkConfig[chainId].tokens.USDC! + ) + const whale = onBase + ? '0xb4885bc63399bf5518b994c1d0c153334ee579d0' + : '0x40ec5b33f54e0e8a33a975908c5ba1c14e5bbbdf' + await whileImpersonating(whale, async (signer) => { + await erc20 + .connect(signer) + .transfer(addr1.address, await erc20.balanceOf(signer.address)) + }) + const FiatCollateralFactory: ContractFactory = await ethers.getContractFactory( + 'FiatCollateral' + ) + return await FiatCollateralFactory.deploy({ + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: chainlinkFeed.address, + oracleError: ORACLE_ERROR, + erc20: erc20.address, + maxTradeVolume: MAX_UINT192, + oracleTimeout: ORACLE_TIMEOUT, + targetName: ethers.utils.formatBytes32String('USD'), + defaultThreshold: fp('0.01'), // 1% + delayUntilDefault: bn('86400'), // 24h, + }) + } else if (target == ethers.utils.formatBytes32String('ETH')) { + // ETH + const erc20 = await ethers.getContractAt( + 'IERC20Metadata', + networkConfig[chainId].tokens.WETH! + ) + const whale = onBase + ? '0xb4885bc63399bf5518b994c1d0c153334ee579d0' + : '0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E' + await whileImpersonating(whale, async (signer) => { + await erc20 + .connect(signer) + .transfer(addr1.address, await erc20.balanceOf(signer.address)) + }) + const SelfReferentialFactory: ContractFactory = await ethers.getContractFactory( + 'SelfReferentialCollateral' + ) + return await SelfReferentialFactory.deploy({ + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: chainlinkFeed.address, + oracleError: ORACLE_ERROR, + erc20: erc20.address, + maxTradeVolume: MAX_UINT192, + oracleTimeout: ORACLE_TIMEOUT, + targetName: ethers.utils.formatBytes32String('ETH'), + defaultThreshold: fp('0'), // 0% + delayUntilDefault: bn('0'), // 0, + }) + } else if (target == ethers.utils.formatBytes32String('BTC')) { + // No official WBTC on base yet + if (onBase) throw new Error('no WBTC on base') + // BTC + const targetUnitOracle: MockV3Aggregator = ( + await MockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + const erc20 = await ethers.getContractAt( + 'IERC20Metadata', + networkConfig[chainId].tokens.WBTC! + ) + await whileImpersonating('0xccf4429db6322d5c611ee964527d42e5d685dd6a', async (signer) => { + await erc20 + .connect(signer) + .transfer(addr1.address, await erc20.balanceOf(signer.address)) + }) + const NonFiatFactory: ContractFactory = await ethers.getContractFactory( + 'NonFiatCollateral' + ) + return await NonFiatFactory.deploy( + { + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: chainlinkFeed.address, + oracleError: ORACLE_ERROR, + erc20: erc20.address, + maxTradeVolume: MAX_UINT192, + oracleTimeout: ORACLE_TIMEOUT, + targetName: ethers.utils.formatBytes32String('BTC'), + defaultThreshold: fp('0.01'), // 1% + delayUntilDefault: bn('86400'), // 24h, + }, + targetUnitOracle.address, + ORACLE_TIMEOUT + ) + } else { + throw new Error(`Unknown target: ${target}`) + } + } + }) }) } diff --git a/test/plugins/individual-collateral/curve/collateralTests.ts b/test/plugins/individual-collateral/curve/collateralTests.ts index 3f9628fc7c..cd7d2d4e69 100644 --- a/test/plugins/individual-collateral/curve/collateralTests.ts +++ b/test/plugins/individual-collateral/curve/collateralTests.ts @@ -4,29 +4,67 @@ import { CurveCollateralTestSuiteFixtures, } from './pluginTestTypes' import { CollateralStatus } from '../pluginTestTypes' -import { ethers } from 'hardhat' -import { ERC20Mock, InvalidMockV3Aggregator } from '../../../../typechain' -import { BigNumber } from 'ethers' -import { bn, fp } from '../../../../common/numbers' -import { MAX_UINT48, MAX_UINT192, ZERO_ADDRESS, ONE_ADDRESS } from '../../../../common/constants' +import hre, { ethers } from 'hardhat' +import { anyValue } from '@nomicfoundation/hardhat-chai-matchers/withArgs' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { BigNumber, ContractFactory } from 'ethers' +import { getChainId } from '../../../../common/blockchain-utils' +import { bn, fp, toBNDecimals } from '../../../../common/numbers' +import { DefaultFixture, Fixture, getDefaultFixture, ORACLE_TIMEOUT } from '../fixtures' +import { expectInIndirectReceipt } from '../../../../common/events' +import { whileImpersonating } from '../../../utils/impersonation' +import { + MAX_UINT48, + MAX_UINT192, + MAX_UINT256, + TradeKind, + ZERO_ADDRESS, + ONE_ADDRESS, +} from '../../../../common/constants' import { expect } from 'chai' import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' import { useEnv } from '#/utils/env' import { expectDecayedPrice, expectExactPrice, expectUnpriced } from '../../../utils/oracles' +import { + IGovParams, + IGovRoles, + IRTokenSetup, + networkConfig, +} from '../../../../common/configuration' import { advanceBlocks, advanceTime, + getLatestBlockNumber, getLatestBlockTimestamp, setNextBlockTimestamp, } from '#/test/utils/time' +import { + ERC20Mock, + FacadeWrite, + IAssetRegistry, + IERC20Metadata, + InvalidMockV3Aggregator, + MockV3Aggregator, + TestIBackingManager, + TestIBasketHandler, + TestICollateral, + TestIDeployer, + TestIMain, + TestIRevenueTrader, + TestIRToken, +} from '../../../../typechain' import snapshotGasCost from '../../../utils/snapshotGasCost' -import { IMPLEMENTATION, Implementation } from '../../../fixtures' +import { IMPLEMENTATION, Implementation, ORACLE_ERROR, PRICE_TIMEOUT } from '../../../fixtures' const describeGas = IMPLEMENTATION == Implementation.P1 && useEnv('REPORT_GAS') ? describe.only : describe.skip const describeFork = useEnv('FORK') ? describe : describe.skip +const getDescribeFork = (targetNetwork = 'mainnet') => { + return useEnv('FORK') && useEnv('FORK_NETWORK') === targetNetwork ? describe : describe.skip +} + export default function fn( fixtures: CurveCollateralTestSuiteFixtures ) { @@ -735,5 +773,360 @@ export default function fn( }) }) }) + + // Only run full protocol integration tests on mainnet + // Protocol integration fixture not currently set up to deploy onto base + getDescribeFork('mainnet')('integration tests', () => { + before(resetFork) + + let ctx: X + let owner: SignerWithAddress + let addr1: SignerWithAddress + + let chainId: number + + let defaultFixture: Fixture + + let supply: BigNumber + + // Tokens/Assets + let pairedColl: TestICollateral + let pairedERC20: ERC20Mock + let collateralERC20: IERC20Metadata + let collateral: TestICollateral + + // Core Contracts + let main: TestIMain + let rToken: TestIRToken + let assetRegistry: IAssetRegistry + let backingManager: TestIBackingManager + let basketHandler: TestIBasketHandler + let rTokenTrader: TestIRevenueTrader + + let deployer: TestIDeployer + let facadeWrite: FacadeWrite + let govParams: IGovParams + let govRoles: IGovRoles + + const config = { + dist: { + rTokenDist: bn(100), // 100% RToken + rsrDist: bn(0), // 0% RSR + }, + minTradeVolume: bn('0'), // $0 + rTokenMaxTradeVolume: MAX_UINT192, // +inf + shortFreeze: bn('259200'), // 3 days + longFreeze: bn('2592000'), // 30 days + rewardRatio: bn('1069671574938'), // approx. half life of 90 days + unstakingDelay: bn('1209600'), // 2 weeks + withdrawalLeak: fp('0'), // 0%; always refresh + warmupPeriod: bn('60'), // (the delay _after_ SOUND was regained) + tradingDelay: bn('0'), // (the delay _after_ default has been confirmed) + batchAuctionLength: bn('900'), // 15 minutes + dutchAuctionLength: bn('1800'), // 30 minutes + backingBuffer: fp('0'), // 0% + maxTradeSlippage: fp('0.01'), // 1% + issuanceThrottle: { + amtRate: fp('1e6'), // 1M RToken + pctRate: fp('0.05'), // 5% + }, + redemptionThrottle: { + amtRate: fp('1e6'), // 1M RToken + pctRate: fp('0.05'), // 5% + }, + } + + interface IntegrationFixture { + ctx: X + protocol: DefaultFixture + } + + const integrationFixture: Fixture = + async function (): Promise { + return { + ctx: await loadFixture( + makeCollateralFixtureContext(owner, { maxTradeVolume: MAX_UINT192 }) + ), + protocol: await loadFixture(defaultFixture), + } + } + + before(async () => { + defaultFixture = await getDefaultFixture(collateralName) + chainId = await getChainId(hre) + if (!networkConfig[chainId]) { + throw new Error(`Missing network configuration for ${hre.network.name}`) + } + ;[, owner, addr1] = await ethers.getSigners() + }) + + beforeEach(async () => { + let protocol: DefaultFixture + ;({ ctx, protocol } = await loadFixture(integrationFixture)) + ;({ collateral } = ctx) + ;({ deployer, facadeWrite, govParams } = protocol) + + supply = fp('1') + + // Create a paired collateral of the same targetName + pairedColl = await makePairedCollateral(await collateral.targetName()) + await pairedColl.refresh() + expect(await pairedColl.status()).to.equal(CollateralStatus.SOUND) + pairedERC20 = await ethers.getContractAt('ERC20Mock', await pairedColl.erc20()) + + // Prep collateral + collateralERC20 = await ethers.getContractAt('IERC20Metadata', await collateral.erc20()) + await mintCollateralTo( + ctx, + toBNDecimals(fp('1'), await collateralERC20.decimals()), + addr1, + addr1.address + ) + + // Set primary basket + const rTokenSetup: IRTokenSetup = { + assets: [], + primaryBasket: [collateral.address, pairedColl.address], + weights: [fp('0.5e-4'), fp('0.5e-4')], + backups: [], + beneficiaries: [], + } + + // Deploy RToken via FacadeWrite + const receipt = await ( + await facadeWrite.connect(owner).deployRToken( + { + name: 'RTKN RToken', + symbol: 'RTKN', + mandate: 'mandate', + params: config, + }, + rTokenSetup + ) + ).wait() + + // Get Main + const mainAddr = expectInIndirectReceipt(receipt, deployer.interface, 'RTokenCreated').args + .main + main = await ethers.getContractAt('TestIMain', mainAddr) + + // Get core contracts + assetRegistry = ( + await ethers.getContractAt('IAssetRegistry', await main.assetRegistry()) + ) + backingManager = ( + await ethers.getContractAt('TestIBackingManager', await main.backingManager()) + ) + basketHandler = ( + await ethers.getContractAt('TestIBasketHandler', await main.basketHandler()) + ) + rToken = await ethers.getContractAt('TestIRToken', await main.rToken()) + rTokenTrader = ( + await ethers.getContractAt('TestIRevenueTrader', await main.rTokenTrader()) + ) + + // Set initial governance roles + govRoles = { + owner: owner.address, + guardian: ZERO_ADDRESS, + pausers: [], + shortFreezers: [], + longFreezers: [], + } + // Setup owner and unpause + await facadeWrite.connect(owner).setupGovernance( + rToken.address, + false, // do not deploy governance + true, // unpaused + govParams, // mock values, not relevant + govRoles + ) + + // Advance past warmup period + await setNextBlockTimestamp( + (await getLatestBlockTimestamp()) + (await basketHandler.warmupPeriod()) + ) + + // Should issue + await collateralERC20.connect(addr1).approve(rToken.address, MAX_UINT256) + await pairedERC20.connect(addr1).approve(rToken.address, MAX_UINT256) + await rToken.connect(addr1).issue(supply) + }) + + it('can be put into an RToken basket', async () => { + await assetRegistry.refresh() + expect(await basketHandler.status()).to.equal(CollateralStatus.SOUND) + }) + + it('issues', async () => { + // Issuance in beforeEach + expect(await rToken.totalSupply()).to.equal(supply) + }) + + it('redeems', async () => { + await rToken.connect(addr1).redeem(supply) + expect(await rToken.totalSupply()).to.equal(0) + const initialCollBal = toBNDecimals(fp('1'), await collateralERC20.decimals()) + expect(await collateralERC20.balanceOf(addr1.address)).to.be.closeTo( + initialCollBal, + initialCollBal.div(bn('1e5')) // 1-part-in-100k + ) + }) + + it('rebalances out of the collateral', async () => { + // Remove collateral from basket + await basketHandler.connect(owner).setPrimeBasket([pairedERC20.address], [fp('1e-4')]) + await expect(basketHandler.connect(owner).refreshBasket()) + .to.emit(basketHandler, 'BasketSet') + .withArgs(anyValue, [pairedERC20.address], [fp('1e-4')], false) + await setNextBlockTimestamp( + (await getLatestBlockTimestamp()) + config.warmupPeriod.toNumber() + ) + + // Run rebalancing auction + await expect(backingManager.rebalance(TradeKind.DUTCH_AUCTION)) + .to.emit(backingManager, 'TradeStarted') + .withArgs(anyValue, collateralERC20.address, pairedERC20.address, anyValue, anyValue) + const tradeAddr = await backingManager.trades(collateralERC20.address) + expect(tradeAddr).to.not.equal(ZERO_ADDRESS) + const trade = await ethers.getContractAt('DutchTrade', tradeAddr) + expect(await trade.sell()).to.equal(collateralERC20.address) + expect(await trade.buy()).to.equal(pairedERC20.address) + const buyAmt = await trade.bidAmount(await trade.endBlock()) + await pairedERC20.connect(addr1).approve(trade.address, buyAmt) + await advanceBlocks((await trade.endBlock()).sub(await getLatestBlockNumber()).sub(1)) + const pairedBal = await pairedERC20.balanceOf(backingManager.address) + await expect(trade.connect(addr1).bid()).to.emit(backingManager, 'TradeSettled') + expect(await pairedERC20.balanceOf(backingManager.address)).to.be.gt(pairedBal) + expect(await backingManager.tradesOpen()).to.equal(0) + }) + + it('forwards revenue and sells in a revenue auction', async () => { + // Send excess collateral to the RToken trader via forwardRevenue() + const mintAmt = toBNDecimals(fp('1e-6'), await collateralERC20.decimals()) + await mintCollateralTo( + ctx, + mintAmt.gt('150') ? mintAmt : bn('150'), + addr1, + backingManager.address + ) + await backingManager.forwardRevenue([collateralERC20.address]) + expect(await collateralERC20.balanceOf(rTokenTrader.address)).to.be.gt(0) + + // Run revenue auction + await expect( + rTokenTrader.manageTokens([collateralERC20.address], [TradeKind.DUTCH_AUCTION]) + ) + .to.emit(rTokenTrader, 'TradeStarted') + .withArgs(anyValue, collateralERC20.address, rToken.address, anyValue, anyValue) + const tradeAddr = await rTokenTrader.trades(collateralERC20.address) + expect(tradeAddr).to.not.equal(ZERO_ADDRESS) + const trade = await ethers.getContractAt('DutchTrade', tradeAddr) + expect(await trade.sell()).to.equal(collateralERC20.address) + expect(await trade.buy()).to.equal(rToken.address) + const buyAmt = await trade.bidAmount(await trade.endBlock()) + await rToken.connect(addr1).approve(trade.address, buyAmt) + await advanceBlocks((await trade.endBlock()).sub(await getLatestBlockNumber()).sub(1)) + await expect(trade.connect(addr1).bid()).to.emit(rTokenTrader, 'TradeSettled') + expect(await rTokenTrader.tradesOpen()).to.equal(0) + }) + + // === Integration Test Helpers === + + const makePairedCollateral = async (target: string): Promise => { + const MockV3AggregatorFactory: ContractFactory = await ethers.getContractFactory( + 'MockV3Aggregator' + ) + const chainlinkFeed: MockV3Aggregator = ( + await MockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + + if (target == ethers.utils.formatBytes32String('USD')) { + // USD + const erc20 = await ethers.getContractAt( + 'IERC20Metadata', + networkConfig[chainId].tokens.USDC! + ) + await whileImpersonating('0x40ec5b33f54e0e8a33a975908c5ba1c14e5bbbdf', async (signer) => { + await erc20 + .connect(signer) + .transfer(addr1.address, await erc20.balanceOf(signer.address)) + }) + const FiatCollateralFactory: ContractFactory = await ethers.getContractFactory( + 'FiatCollateral' + ) + return await FiatCollateralFactory.deploy({ + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: chainlinkFeed.address, + oracleError: ORACLE_ERROR, + erc20: erc20.address, + maxTradeVolume: MAX_UINT192, + oracleTimeout: ORACLE_TIMEOUT, + targetName: ethers.utils.formatBytes32String('USD'), + defaultThreshold: fp('0.01'), // 1% + delayUntilDefault: bn('86400'), // 24h, + }) + } else if (target == ethers.utils.formatBytes32String('ETH')) { + // ETH + const erc20 = await ethers.getContractAt( + 'IERC20Metadata', + networkConfig[chainId].tokens.WETH! + ) + await whileImpersonating('0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E', async (signer) => { + await erc20 + .connect(signer) + .transfer(addr1.address, await erc20.balanceOf(signer.address)) + }) + const SelfReferentialFactory: ContractFactory = await ethers.getContractFactory( + 'SelfReferentialCollateral' + ) + return await SelfReferentialFactory.deploy({ + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: chainlinkFeed.address, + oracleError: ORACLE_ERROR, + erc20: erc20.address, + maxTradeVolume: MAX_UINT192, + oracleTimeout: ORACLE_TIMEOUT, + targetName: ethers.utils.formatBytes32String('ETH'), + defaultThreshold: fp('0'), // 0% + delayUntilDefault: bn('0'), // 0, + }) + } else if (target == ethers.utils.formatBytes32String('BTC')) { + // BTC + const targetUnitOracle: MockV3Aggregator = ( + await MockV3AggregatorFactory.deploy(8, bn('1e8')) + ) + const erc20 = await ethers.getContractAt( + 'IERC20Metadata', + networkConfig[chainId].tokens.WBTC! + ) + await whileImpersonating('0xccf4429db6322d5c611ee964527d42e5d685dd6a', async (signer) => { + await erc20 + .connect(signer) + .transfer(addr1.address, await erc20.balanceOf(signer.address)) + }) + const NonFiatFactory: ContractFactory = await ethers.getContractFactory( + 'NonFiatCollateral' + ) + return await NonFiatFactory.deploy( + { + priceTimeout: PRICE_TIMEOUT, + chainlinkFeed: chainlinkFeed.address, + oracleError: ORACLE_ERROR, + erc20: erc20.address, + maxTradeVolume: MAX_UINT192, + oracleTimeout: ORACLE_TIMEOUT, + targetName: ethers.utils.formatBytes32String('BTC'), + defaultThreshold: fp('0.01'), // 1% + delayUntilDefault: bn('86400'), // 24h, + }, + targetUnitOracle.address, + ORACLE_TIMEOUT + ) + } else { + throw new Error(`Unknown target: ${target}`) + } + } + }) }) } diff --git a/test/plugins/individual-collateral/fixtures.ts b/test/plugins/individual-collateral/fixtures.ts index 19897cfd91..ae2e4a6da3 100644 --- a/test/plugins/individual-collateral/fixtures.ts +++ b/test/plugins/individual-collateral/fixtures.ts @@ -3,6 +3,7 @@ import hre, { ethers } from 'hardhat' import { getChainId } from '../../../common/blockchain-utils' import { IImplementations, IGovParams, networkConfig } from '../../../common/configuration' import { bn, fp } from '../../../common/numbers' +import { useEnv } from '#/utils/env' import { Implementation, IMPLEMENTATION, ORACLE_ERROR, PRICE_TIMEOUT } from '../../fixtures' import { Asset, @@ -41,8 +42,7 @@ interface RSRFixture { rsr: ERC20Mock } -async function rsrFixture(): Promise { - const chainId = await getChainId(hre) +async function rsrFixture(chainId: number): Promise { const rsr: ERC20Mock = ( await ethers.getContractAt('ERC20Mock', networkConfig[chainId].tokens.RSR || '') ) @@ -74,9 +74,10 @@ export interface DefaultFixture extends RSRAndModuleFixture { export const getDefaultFixture = async function (salt: string) { const defaultFixture: Fixture = async function (): Promise { - const { rsr } = await rsrFixture() + let chainId = await getChainId(hre) + if (useEnv('FORK_NETWORK').toLowerCase() == 'base') chainId = 8453 + const { rsr } = await rsrFixture(chainId) const { gnosis } = await gnosisFixture() - const chainId = await getChainId(hre) if (!networkConfig[chainId]) { throw new Error(`Missing network configuration for ${hre.network.name}`) }