diff --git a/contracts/facade/factories/DemurrageCollateralFactory.sol b/contracts/facade/factories/CollateralFactory.sol similarity index 55% rename from contracts/facade/factories/DemurrageCollateralFactory.sol rename to contracts/facade/factories/CollateralFactory.sol index 220b75377..a16ff02c3 100644 --- a/contracts/facade/factories/DemurrageCollateralFactory.sol +++ b/contracts/facade/factories/CollateralFactory.sol @@ -2,16 +2,21 @@ pragma solidity 0.8.19; import "../../plugins/assets/DemurrageCollateral.sol"; +import "../../plugins/assets/UnpricedCollateral.sol"; /** - * @title DemurrageCollateralFactory + * @title CollateralFactory */ -contract DemurrageCollateralFactory { +contract CollateralFactory { event DemurrageCollateralDeployed(address indexed collateral); + event UnpricedCollateralDeployed(address indexed collateral); - // collateral address => fee per second + /// @notice collateral address => fee per second mapping(address => uint192) public demurrageDeployments; + /// @notice erc20 address => collateral address + mapping(address => address) public unpricedDeployments; + bytes32 public constant USD = bytes32("USD"); function deployNewDemurrageCollateral( @@ -26,4 +31,13 @@ contract DemurrageCollateralFactory { demurrageDeployments[newCollateral] = demurrageConfig.fee; emit DemurrageCollateralDeployed(newCollateral); } + + function deployNewUnpricedCollateral(IERC20Metadata _erc20) + external + returns (address newCollateral) + { + newCollateral = address(new UnpricedCollateral(_erc20)); + unpricedDeployments[address(_erc20)] = newCollateral; + emit UnpricedCollateralDeployed(newCollateral); + } } diff --git a/contracts/plugins/assets/UnpricedCollateral.sol b/contracts/plugins/assets/UnpricedCollateral.sol new file mode 100644 index 000000000..0bbe9c7cb --- /dev/null +++ b/contracts/plugins/assets/UnpricedCollateral.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.19; + +import "../../interfaces/IAsset.sol"; +import "./Asset.sol"; + +/** + * @title UnpricedCollateral + * @notice Collateral plugin for tokens that are missing a price oracle + * + * Warning: This plugin CANNOT be used in an RToken that needs to rebalance + * It should only go into immutable RTokens that cannot have their basket changed + * + * - tok = X + * - ref = X + * - target = X + * - UoA = USD + */ +contract UnpricedCollateral is ICollateral, VersionedAsset { + using FixLib for uint192; + + CollateralStatus public constant status = CollateralStatus.SOUND; + + uint192 public constant refPerTok = FIX_ONE; + + uint192 public constant targetPerRef = FIX_ONE; + + uint192 public constant savedPegPrice = 0; + + uint192 public constant maxTradeVolume = 0; + + uint48 public constant lastSave = 0; + + // === Immutables === + + IERC20Metadata public immutable erc20; + + uint8 public immutable erc20Decimals; + + bytes32 public immutable targetName; + + constructor(IERC20Metadata _erc20) { + require(address(_erc20) != address(0), "missing erc20"); + erc20 = _erc20; + erc20Decimals = _erc20.decimals(); + targetName = bytes32(bytes(_erc20.symbol())); + } + + // solhint-disable no-empty-blocks + + /// Should not revert + /// Refresh saved prices + function refresh() public virtual override {} + + function price() public view virtual override returns (uint192 low, uint192 high) { + return (0, FIX_MAX); + } + + /// @return {tok} The balance of the ERC20 in whole tokens + function bal(address account) external view virtual returns (uint192) { + return shiftl_toFix(erc20.balanceOf(account), -int8(erc20Decimals), FLOOR); + } + + /// @return If the asset is an instance of ICollateral or not + function isCollateral() external pure virtual returns (bool) { + return true; + } + + /// Claim rewards earned by holding a balance of the ERC20 token + /// @custom:delegate-call + function claimRewards() external virtual {} + + // solhint-enable no-empty-blocks +} diff --git a/test/scenario/UnpricedCollateral.test.ts b/test/scenario/UnpricedCollateral.test.ts new file mode 100644 index 000000000..bb5a374d2 --- /dev/null +++ b/test/scenario/UnpricedCollateral.test.ts @@ -0,0 +1,223 @@ +import { loadFixture, setStorageAt } from '@nomicfoundation/hardhat-network-helpers' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { expect } from 'chai' +import { ContractFactory } from 'ethers' +import { ethers, upgrades } from 'hardhat' +import { IConfig } from '../../common/configuration' +import { bn, fp, toBNDecimals } from '../../common/numbers' +import { MAX_UINT192 } from '../../common/constants' +import { getTrade } from '../utils/trades' +import { + BasketLibP1, + ERC20MockDecimals, + IAssetRegistry, + TestIBackingManager, + TestIBasketHandler, + TestIMain, + TestIRevenueTrader, + TestIRToken, + RTokenAsset, + UnpricedCollateral, +} from '../../typechain' +import { advanceTime } from '../utils/time' +import { defaultFixtureNoBasket, IMPLEMENTATION, Implementation } from '../fixtures' +import { CollateralStatus } from '../../common/constants' + +const describeP1 = IMPLEMENTATION == Implementation.P1 ? describe : describe.skip + +describeP1(`Unpriced Collateral - P${IMPLEMENTATION}`, () => { + const amt = fp('1') + + let owner: SignerWithAddress + let addr1: SignerWithAddress + + let tokens: ERC20MockDecimals[] + let collateral: UnpricedCollateral[] + let decimals: number[] + + let config: IConfig + + let main: TestIMain + let backingManager: TestIBackingManager + let rToken: TestIRToken + let assetRegistry: IAssetRegistry + let bh: TestIBasketHandler + let rTokenAsset: RTokenAsset + let rsrTrader: TestIRevenueTrader + let rsr: ERC20MockDecimals + + describe('Unpriced Collateral', () => { + beforeEach(async () => { + ;[owner, addr1] = await ethers.getSigners() + + // Deploy fixture + let erc20s: ERC20MockDecimals[] + ;({ + assetRegistry, + backingManager, + config, + main, + rToken, + erc20s, + rTokenAsset, + rsrTrader, + rsr, + } = await loadFixture(defaultFixtureNoBasket)) + + // Setup Factories + const BasketLibFactory: ContractFactory = await ethers.getContractFactory('BasketLibP1') + const basketLib: BasketLibP1 = await BasketLibFactory.deploy() + const BasketHandlerFactory: ContractFactory = await ethers.getContractFactory( + 'BasketHandlerP1', + { libraries: { BasketLibP1: basketLib.address } } + ) + const UnpricedCollateralFactory: ContractFactory = await ethers.getContractFactory( + 'UnpricedCollateral' + ) + const ERC20Factory: ContractFactory = await ethers.getContractFactory('ERC20MockDecimals') + + // Replace with reweightable basket handler + bh = await ethers.getContractAt( + 'TestIBasketHandler', + ( + await upgrades.deployProxy( + BasketHandlerFactory, + [main.address, config.warmupPeriod, true, true], + { + initializer: 'init', + kind: 'uups', + } + ) + ).address + ) + await setStorageAt(main.address, 204, bh.address) + await setStorageAt(rToken.address, 355, bh.address) + await setStorageAt(backingManager.address, 302, bh.address) + await setStorageAt(assetRegistry.address, 201, bh.address) + + decimals = [6, 8, 9, 18] + + tokens = ( + await Promise.all([ + ERC20Factory.deploy('NAME1', 'TKN1', decimals[0]), + ERC20Factory.deploy('NAME2', 'TKN2', decimals[1]), + ERC20Factory.deploy('NAME3', 'TKN3', decimals[2]), + ERC20Factory.deploy('NAME4', 'TKN4', decimals[3]), + ]) + ) + + collateral = ( + await Promise.all(tokens.map((t) => UnpricedCollateralFactory.deploy(t.address))) + ) + + for (let i = 0; i < collateral.length; i++) { + await assetRegistry.connect(owner).register(collateral[i].address) + await tokens[i].mint(addr1.address, amt) + await tokens[i].connect(addr1).approve(rToken.address, amt) + } + + // Append a priced collateral + tokens.push(erc20s[0]) + await erc20s[0].connect(addr1).mint(addr1.address, amt) + await erc20s[0].connect(addr1).approve(rToken.address, amt) + + // Set basket to 4 UnpricedCollateral + 1 FiatCollateral, all unique targets + await bh.connect(owner).setPrimeBasket( + tokens.map((t) => t.address), + [fp('1'), fp('1'), fp('1'), fp('1'), fp('1')] + ) + await bh.connect(owner).refreshBasket() + await advanceTime(Number(config.warmupPeriod) + 1) + expect(await rToken.totalSupply()).to.equal(0) + expect(await bh.status()).to.equal(CollateralStatus.SOUND) + expect(await bh.fullyCollateralized()).to.equal(true) + }) + + it('collateral interface is correct', async () => { + for (let i = 0; i < collateral.length; i++) { + expect(await collateral[i].erc20()).to.equal(tokens[i].address) + expect(await collateral[i].erc20Decimals()).to.equal(decimals[i]) + expect(await collateral[i].targetName()).to.equal( + ethers.utils.formatBytes32String(`${await tokens[i].symbol()}`) + ) + expect(await collateral[i].status()).to.equal(CollateralStatus.SOUND) + expect(await collateral[i].refPerTok()).to.equal(fp('1')) + expect(await collateral[i].targetPerRef()).to.equal(fp('1')) + expect(await collateral[i].savedPegPrice()).to.equal(fp('0')) + expect(await collateral[i].maxTradeVolume()).to.equal(fp('0')) + expect(await collateral[i].lastSave()).to.equal(0) + expect(await collateral[i].bal(addr1.address)).to.equal( + (await tokens[i].balanceOf(addr1.address)).mul(bn('10').pow(18 - decimals[i])) + ) + + const [low, high] = await collateral[i].price() + expect(low).to.equal(0) + expect(high).to.equal(MAX_UINT192) + } + }) + + it('should issue and redeem RTokens correctly', async () => { + // Issue + await rToken.connect(addr1).issue(amt) + expect(await rToken.totalSupply()).to.equal(amt) + expect(await bh.status()).to.equal(CollateralStatus.SOUND) + expect(await bh.fullyCollateralized()).to.equal(true) + for (let i = 0; i < collateral.length; i++) { + expect(await tokens[i].balanceOf(backingManager.address)).to.equal( + toBNDecimals(amt, decimals[i]) + ) + } + + // Redeem + await rToken.connect(addr1).redeem(amt) + expect(await rToken.totalSupply()).to.equal(0) + expect(await bh.status()).to.equal(CollateralStatus.SOUND) + expect(await bh.fullyCollateralized()).to.equal(true) + for (let i = 0; i < collateral.length; i++) { + expect(await tokens[i].balanceOf(backingManager.address)).to.equal(0) + expect(await tokens[i].balanceOf(addr1.address)).to.equal(amt) + } + }) + + it('should not be able to rebalance because never uncollateralized', async () => { + await rToken.connect(addr1).issue(amt) + await expect(backingManager.rebalance(0)).to.be.revertedWith('already collateralized') + await expect(rTokenAsset.tryPrice()).to.be.revertedWith('invalid price') + }) + + it('even IF it were to become undercollateralized by way of a hacked token, rebalance STILL should not haircut', async () => { + await rToken.connect(addr1).issue(amt) + expect(await rToken.basketsNeeded()).to.equal(amt) + + await tokens[0].burn(backingManager.address, 1) + expect(await bh.fullyCollateralized()).to.equal(false) + await expect(backingManager.rebalance(0)).to.be.revertedWith('BUs unpriced') + await expect(rTokenAsset.tryPrice()).to.be.revertedWith('invalid price') + }) + + it('can coexist with appreciating collateral and process RSR revenue auctions like normal', async () => { + const initialStRSRBalance = await rsr.balanceOf(await main.stRSR()) + + // Simulate appreciation + await tokens[4].mint(backingManager.address, amt) + await backingManager.forwardRevenue([tokens[4].address]) + await rsrTrader.manageTokens([tokens[4].address], [0]) + expect(await rsrTrader.tradesOpen()).to.equal(1) + + // Bid in dutch auction + await advanceTime(config.dutchAuctionLength.toNumber() - 5) + const t = await ethers.getContractAt( + 'DutchTrade', + ( + await getTrade(rsrTrader, tokens[4].address) + ).address + ) + await rsr.connect(addr1).mint(addr1.address, amt) + await rsr.connect(addr1).approve(t.address, amt) + await t.connect(addr1).bid() + + // RSR should have been rewarded + expect(await rsr.balanceOf(await main.stRSR())).to.be.gt(initialStRSRBalance) + }) + }) +})