Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unpriced collateral #1220

Closed
wants to merge 12 commits into from
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
}
}
74 changes: 74 additions & 0 deletions contracts/plugins/assets/UnpricedCollateral.sol
Original file line number Diff line number Diff line change
@@ -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
}
223 changes: 223 additions & 0 deletions test/scenario/UnpricedCollateral.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <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 = <ERC20MockDecimals[]>(
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 = <UnpricedCollateral[]>(
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)
})
})
})
Loading