From e92cde849b465c1db5ccd6d2847b5d9585d4c05d Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 15 Oct 2024 21:51:49 -0400 Subject: [PATCH 1/7] UnpricedCollateral --- .../plugins/assets/UnpricedCollateral.sol | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 contracts/plugins/assets/UnpricedCollateral.sol diff --git a/contracts/plugins/assets/UnpricedCollateral.sol b/contracts/plugins/assets/UnpricedCollateral.sol new file mode 100644 index 000000000..ef587dbf2 --- /dev/null +++ b/contracts/plugins/assets/UnpricedCollateral.sol @@ -0,0 +1,75 @@ +// 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, bytes32 _targetName) { + require(address(_erc20) != address(0), "missing erc20"); + require(_targetName != bytes32(0), "targetName missing"); + erc20 = _erc20; + erc20Decimals = _erc20.decimals(); + targetName = _targetName; + } + + // 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 +} From cc3a80765b9bc528cab0772ff17cfe9148d25470 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 15 Oct 2024 21:52:02 -0400 Subject: [PATCH 2/7] add to factory --- ...CollateralFactory.sol => CollateralFactory.sol} | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) rename contracts/facade/factories/{DemurrageCollateralFactory.sol => CollateralFactory.sol} (60%) diff --git a/contracts/facade/factories/DemurrageCollateralFactory.sol b/contracts/facade/factories/CollateralFactory.sol similarity index 60% rename from contracts/facade/factories/DemurrageCollateralFactory.sol rename to contracts/facade/factories/CollateralFactory.sol index 04adddcab..ba60fcd80 100644 --- a/contracts/facade/factories/DemurrageCollateralFactory.sol +++ b/contracts/facade/factories/CollateralFactory.sol @@ -2,12 +2,14 @@ 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 OraclelessCollateralDeployed(address indexed collateral); bytes32 public constant USD = bytes32("USD"); @@ -22,4 +24,12 @@ contract DemurrageCollateralFactory { newCollateral = address(new DemurrageCollateral(config, demurrageConfig)); emit DemurrageCollateralDeployed(newCollateral); } + + function deployNewUnpricedCollateral(IERC20Metadata _erc20, bytes32 _targetName) + external + returns (address newCollateral) + { + newCollateral = address(new UnpricedCollateral(_erc20, _targetName)); + emit OraclelessCollateralDeployed(newCollateral); + } } From 879f44f22da1499c215e1e35c963a9cab9bf228a Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 15 Oct 2024 21:52:58 -0400 Subject: [PATCH 3/7] nit --- contracts/facade/factories/CollateralFactory.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/facade/factories/CollateralFactory.sol b/contracts/facade/factories/CollateralFactory.sol index ba60fcd80..3090a815e 100644 --- a/contracts/facade/factories/CollateralFactory.sol +++ b/contracts/facade/factories/CollateralFactory.sol @@ -9,7 +9,7 @@ import "../../plugins/assets/UnpricedCollateral.sol"; */ contract CollateralFactory { event DemurrageCollateralDeployed(address indexed collateral); - event OraclelessCollateralDeployed(address indexed collateral); + event UnpricedCollateralDeployed(address indexed collateral); bytes32 public constant USD = bytes32("USD"); @@ -30,6 +30,6 @@ contract CollateralFactory { returns (address newCollateral) { newCollateral = address(new UnpricedCollateral(_erc20, _targetName)); - emit OraclelessCollateralDeployed(newCollateral); + emit UnpricedCollateralDeployed(newCollateral); } } From bae1c19a12facb8ff27a7c6edd4b1e9c08b34dc0 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 15 Oct 2024 21:54:48 -0400 Subject: [PATCH 4/7] reduce to just 1 parameter --- contracts/facade/factories/CollateralFactory.sol | 4 ++-- contracts/plugins/assets/UnpricedCollateral.sol | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/contracts/facade/factories/CollateralFactory.sol b/contracts/facade/factories/CollateralFactory.sol index 3090a815e..30fdf0446 100644 --- a/contracts/facade/factories/CollateralFactory.sol +++ b/contracts/facade/factories/CollateralFactory.sol @@ -25,11 +25,11 @@ contract CollateralFactory { emit DemurrageCollateralDeployed(newCollateral); } - function deployNewUnpricedCollateral(IERC20Metadata _erc20, bytes32 _targetName) + function deployNewUnpricedCollateral(IERC20Metadata _erc20) external returns (address newCollateral) { - newCollateral = address(new UnpricedCollateral(_erc20, _targetName)); + newCollateral = address(new UnpricedCollateral(_erc20)); emit UnpricedCollateralDeployed(newCollateral); } } diff --git a/contracts/plugins/assets/UnpricedCollateral.sol b/contracts/plugins/assets/UnpricedCollateral.sol index ef587dbf2..0bbe9c7cb 100644 --- a/contracts/plugins/assets/UnpricedCollateral.sol +++ b/contracts/plugins/assets/UnpricedCollateral.sol @@ -39,12 +39,11 @@ contract UnpricedCollateral is ICollateral, VersionedAsset { bytes32 public immutable targetName; - constructor(IERC20Metadata _erc20, bytes32 _targetName) { + constructor(IERC20Metadata _erc20) { require(address(_erc20) != address(0), "missing erc20"); - require(_targetName != bytes32(0), "targetName missing"); erc20 = _erc20; erc20Decimals = _erc20.decimals(); - targetName = _targetName; + targetName = bytes32(bytes(_erc20.symbol())); } // solhint-disable no-empty-blocks From 39eebf20b5d88de3b161882c9f50abaf48289f4b Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Mon, 21 Oct 2024 21:00:57 -0400 Subject: [PATCH 5/7] scenario tests --- test/scenario/UnpricedCollateral.test.ts | 177 +++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 test/scenario/UnpricedCollateral.test.ts diff --git a/test/scenario/UnpricedCollateral.test.ts b/test/scenario/UnpricedCollateral.test.ts new file mode 100644 index 000000000..d067a4202 --- /dev/null +++ b/test/scenario/UnpricedCollateral.test.ts @@ -0,0 +1,177 @@ +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 { + BasketLibP1, + ERC20MockDecimals, + IAssetRegistry, + TestIBackingManager, + TestIBasketHandler, + TestIMain, + TestIRToken, + 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 + + describe('Unpriced Collateral', () => { + beforeEach(async () => { + ;[owner, addr1] = await ethers.getSigners() + + // Deploy fixture + let erc20s: ERC20MockDecimals[] + ;({ assetRegistry, backingManager, config, main, rToken, erc20s } = 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])) + ) + } + }) + + 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') + }) + + 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') + }) + }) +}) From 4f785bd5d3c4c027ee488d94581138dd6e652376 Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Mon, 21 Oct 2024 21:23:09 -0400 Subject: [PATCH 6/7] add revenue test --- test/scenario/UnpricedCollateral.test.ts | 52 ++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/test/scenario/UnpricedCollateral.test.ts b/test/scenario/UnpricedCollateral.test.ts index d067a4202..bb5a374d2 100644 --- a/test/scenario/UnpricedCollateral.test.ts +++ b/test/scenario/UnpricedCollateral.test.ts @@ -5,6 +5,8 @@ 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, @@ -12,7 +14,9 @@ import { TestIBackingManager, TestIBasketHandler, TestIMain, + TestIRevenueTrader, TestIRToken, + RTokenAsset, UnpricedCollateral, } from '../../typechain' import { advanceTime } from '../utils/time' @@ -38,6 +42,9 @@ describeP1(`Unpriced Collateral - P${IMPLEMENTATION}`, () => { let rToken: TestIRToken let assetRegistry: IAssetRegistry let bh: TestIBasketHandler + let rTokenAsset: RTokenAsset + let rsrTrader: TestIRevenueTrader + let rsr: ERC20MockDecimals describe('Unpriced Collateral', () => { beforeEach(async () => { @@ -45,9 +52,17 @@ describeP1(`Unpriced Collateral - P${IMPLEMENTATION}`, () => { // Deploy fixture let erc20s: ERC20MockDecimals[] - ;({ assetRegistry, backingManager, config, main, rToken, erc20s } = await loadFixture( - defaultFixtureNoBasket - )) + ;({ + assetRegistry, + backingManager, + config, + main, + rToken, + erc20s, + rTokenAsset, + rsrTrader, + rsr, + } = await loadFixture(defaultFixtureNoBasket)) // Setup Factories const BasketLibFactory: ContractFactory = await ethers.getContractFactory('BasketLibP1') @@ -134,6 +149,10 @@ describeP1(`Unpriced Collateral - P${IMPLEMENTATION}`, () => { 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) } }) @@ -163,6 +182,7 @@ describeP1(`Unpriced Collateral - P${IMPLEMENTATION}`, () => { 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 () => { @@ -172,6 +192,32 @@ describeP1(`Unpriced Collateral - P${IMPLEMENTATION}`, () => { 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) }) }) }) From c40b322fd2886577c87d112a19805e3caf2e62be Mon Sep 17 00:00:00 2001 From: Taylor Brent Date: Tue, 22 Oct 2024 16:13:47 -0400 Subject: [PATCH 7/7] record deployments in factory --- contracts/facade/factories/CollateralFactory.sol | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/contracts/facade/factories/CollateralFactory.sol b/contracts/facade/factories/CollateralFactory.sol index da7bb8b9d..b585ca179 100644 --- a/contracts/facade/factories/CollateralFactory.sol +++ b/contracts/facade/factories/CollateralFactory.sol @@ -11,7 +11,9 @@ contract CollateralFactory { event DemurrageCollateralDeployed(address indexed collateral); event UnpricedCollateralDeployed(address indexed collateral); - mapping(address coll => uint192 feePerSecond) public demurrageDeployments; + mapping(address coll => uint192 feePerSecond) public demurrageDeployments; // not unique by erc20 + + mapping(address erc20 => address coll) public unpricedDeployments; // unique by erc20 bytes32 public constant USD = bytes32("USD"); @@ -33,6 +35,7 @@ contract CollateralFactory { returns (address newCollateral) { newCollateral = address(new UnpricedCollateral(_erc20)); + unpricedDeployments[address(_erc20)] = newCollateral; emit UnpricedCollateralDeployed(newCollateral); } }