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,14 +2,18 @@
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);

mapping(address coll => uint192 feePerSecond) public demurrageDeployments;
mapping(address coll => uint192 feePerSecond) public demurrageDeployments; // not unique by erc20
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

address erc20 => uint256/uint192 fee => address collateral is much better to query and track.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't do that -- permissionless pass-in of all variables

Copy link
Collaborator Author

@tbrent tbrent Oct 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is not just 1 collateral possible, per fee tier

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry I meant for unpriced demurrage collateral. There aren't any specific parameters for those, I don't think right?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's 2 lines below and works the way you want it to

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well it's saving by address of the deployed collateral. maybe this comment should go in the other pr, but if we save a mapping like akshat suggested (and error on duplicates), then the fe can easily query the contract based on the erc20 addy. otherwise we're requiring a subgraph dependency


mapping(address erc20 => address coll) public unpricedDeployments; // unique by erc20

bytes32 public constant USD = bytes32("USD");

Expand All @@ -25,4 +29,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