diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1c3a782..4451173b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Delay automatic rebalancing after a collateral default while allowing issuance against the backup basket. - Allow governance-triggered basket changes to rebalance without waiting for `tradingDelay`. +- Prevent unregistering RSR and require RSR assets to preserve BackingManager tradeability. +- Limit BackingManager `minTradeVolume` increases to one tenth of both the conservative RSR stake value and the RSR asset's `maxTradeVolume`. # 4.2.0 diff --git a/contracts/p0/AssetRegistry.sol b/contracts/p0/AssetRegistry.sol index 37eb43b9a..6ceee85c0 100644 --- a/contracts/p0/AssetRegistry.sol +++ b/contracts/p0/AssetRegistry.sol @@ -86,6 +86,7 @@ contract AssetRegistryP0 is ComponentP0, IAssetRegistry { /// @custom:governance function unregister(IAsset asset) external governance { require(address(asset.erc20()) != address(main.rToken()), "cannot unregister RToken"); + require(address(asset.erc20()) != address(main.rsr()), "cannot unregister RSR"); require(_erc20s.contains(address(asset.erc20())), "no asset to unregister"); require(assets[asset.erc20()] == asset, "asset not found"); @@ -175,6 +176,20 @@ contract AssetRegistryP0 is ComponentP0, IAssetRegistry { // Refresh to ensure it does not revert, and to save a recent lastPrice asset.refresh(); + if (address(asset.erc20()) == address(main.rsr())) { + (uint192 low, uint192 high) = asset.price(); + require(low > 0 && high < FIX_MAX, "RSR asset unpriced"); + + uint192 requiredVolume = main.backingManager().minTradeVolume() * 10; + require(asset.maxTradeVolume() >= requiredVolume, "RSR maxTradeVolume too low"); + if (!_isInitializing()) { + require( + asset.bal(address(main.stRSR())).safeMul(low, FLOOR) >= requiredVolume, + "RSR stake too small" + ); + } + } + if (_erc20s.contains(address(asset.erc20())) && assets[asset.erc20()] != asset) { _erc20s.remove(address(asset.erc20())); emit AssetUnregistered(asset.erc20(), assets[asset.erc20()]); diff --git a/contracts/p0/BackingManager.sol b/contracts/p0/BackingManager.sol index 7a3bdce60..1c5a087ed 100644 --- a/contracts/p0/BackingManager.sol +++ b/contracts/p0/BackingManager.sol @@ -276,4 +276,18 @@ contract BackingManagerP0 is TradingP0, IBackingManager { emit BackingBufferSet(backingBuffer, val); backingBuffer = val; } + + function _validateMinTradeVolume(uint192 val) internal view override { + if (_isInitializing()) return; + + IAsset rsrAsset = main.assetRegistry().toAsset(main.rsr()); + uint192 requiredVolume = val * 10; + require(rsrAsset.maxTradeVolume() >= requiredVolume, "RSR maxTradeVolume too low"); + + (uint192 low, ) = rsrAsset.price(); + require( + rsrAsset.bal(address(main.stRSR())).safeMul(low, FLOOR) >= requiredVolume, + "RSR stake too small" + ); + } } diff --git a/contracts/p0/mixins/Trading.sol b/contracts/p0/mixins/Trading.sol index e96aa3dec..55c7799d6 100644 --- a/contracts/p0/mixins/Trading.sol +++ b/contracts/p0/mixins/Trading.sol @@ -120,7 +120,11 @@ abstract contract TradingP0 is RewardableP0, ITrading { /// @custom:governance function setMinTradeVolume(uint192 val) public governance { require(val <= MAX_TRADE_VOLUME, "invalid minTradeVolume"); + if (val > minTradeVolume) _validateMinTradeVolume(val); emit MinTradeVolumeSet(minTradeVolume, val); minTradeVolume = val; } + + // solhint-disable-next-line no-empty-blocks + function _validateMinTradeVolume(uint192) internal view virtual {} } diff --git a/contracts/p1/AssetRegistry.sol b/contracts/p1/AssetRegistry.sol index 8398654e7..e961df912 100644 --- a/contracts/p1/AssetRegistry.sol +++ b/contracts/p1/AssetRegistry.sol @@ -6,11 +6,13 @@ import "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; import "../plugins/assets/RTokenAsset.sol"; import "../interfaces/IAssetRegistry.sol"; import "../interfaces/IMain.sol"; +import "../libraries/Fixed.sol"; import "./mixins/Component.sol"; /// The AssetRegistry provides the mapping from ERC20 to Asset, allowing the rest of Main /// to think in terms of ERC20 tokens and target/ref units. contract AssetRegistryP1 is ComponentP1, IAssetRegistry { + using FixLib for uint192; using EnumerableSet for EnumerableSet.AddressSet; uint256 public constant GAS_FOR_BH_QTY = 100_000; // enough to call bh.quantity @@ -132,6 +134,7 @@ contract AssetRegistryP1 is ComponentP1, IAssetRegistry { IERC20Metadata erc20 = asset.erc20(); require(address(erc20) != address(main.rToken()), "cannot unregister RToken"); + require(address(erc20) != address(main.rsr()), "cannot unregister RSR"); require(_erc20s.contains(address(erc20)), "no asset to unregister"); require(assets[erc20] == asset, "asset not found"); @@ -278,6 +281,20 @@ contract AssetRegistryP1 is ComponentP1, IAssetRegistry { // Refresh to ensure it does not revert, and to save a recent lastPrice asset.refresh(); + if (address(erc20) == address(main.rsr())) { + (uint192 low, uint192 high) = asset.price(); + require(low > 0 && high < FIX_MAX, "RSR asset unpriced"); + + uint192 requiredVolume = backingManager.minTradeVolume() * 10; + require(asset.maxTradeVolume() >= requiredVolume, "RSR maxTradeVolume too low"); + if (!_isInitializing()) { + require( + asset.bal(address(main.stRSR())).safeMul(low, FLOOR) >= requiredVolume, + "RSR stake too small" + ); + } + } + if (!main.frozen()) { backingManager.grantRTokenAllowance(erc20); } diff --git a/contracts/p1/BackingManager.sol b/contracts/p1/BackingManager.sol index d9e8b679b..f0962c2c1 100644 --- a/contracts/p1/BackingManager.sol +++ b/contracts/p1/BackingManager.sol @@ -347,6 +347,20 @@ contract BackingManagerP1 is TradingP1, IBackingManager { backingBuffer = val; } + function _validateMinTradeVolume(uint192 val) internal view override { + if (_isInitializing()) return; + + IAsset rsrAsset = assetRegistry.toAsset(rsr); + uint192 requiredVolume = val * 10; + require(rsrAsset.maxTradeVolume() >= requiredVolume, "RSR maxTradeVolume too low"); + + (uint192 low, ) = rsrAsset.price(); + require( + rsrAsset.bal(address(stRSR)).safeMul(low, FLOOR) >= requiredVolume, + "RSR stake too small" + ); + } + /// Call after upgrade to >= 3.0.0 function cacheComponents() public { assetRegistry = main.assetRegistry(); diff --git a/contracts/p1/mixins/Trading.sol b/contracts/p1/mixins/Trading.sol index 6eb40edc8..173495319 100644 --- a/contracts/p1/mixins/Trading.sol +++ b/contracts/p1/mixins/Trading.sol @@ -174,10 +174,14 @@ abstract contract TradingP1 is Multicall, ComponentP1, ReentrancyGuardUpgradeabl function setMinTradeVolume(uint192 val) public { requireGovernanceOnly(); require(val <= MAX_TRADE_VOLUME, "invalid minTradeVolume"); + if (val > minTradeVolume) _validateMinTradeVolume(val); emit MinTradeVolumeSet(minTradeVolume, val); minTradeVolume = val; } + // solhint-disable-next-line no-empty-blocks + function _validateMinTradeVolume(uint192) internal view virtual {} + /** * @dev This empty reserved space is put in place to allow future versions to add new * variables without shifting down storage in the inheritance chain. diff --git a/test/Main.test.ts b/test/Main.test.ts index ab6b903aa..bc8e64229 100644 --- a/test/Main.test.ts +++ b/test/Main.test.ts @@ -36,6 +36,7 @@ import { expectPrice, expectUnpriced, setOraclePrice } from './utils/oracles' import { bn, fp } from '../common/numbers' import { Asset, + AssetMock, ATokenFiatCollateral, BackingManagerP1, BasketHandlerP1, @@ -1072,7 +1073,7 @@ describe(`MainP${IMPLEMENTATION} contract`, () => { }) it('Should allow to update minTradeVolume if OWNER and perform validations', async () => { - const newValue: BigNumber = fp('0.02') + const newValue: BigNumber = fp('0.005') // Check existing value expect(await backingManager.minTradeVolume()).to.equal(config.minTradeVolume) @@ -1097,6 +1098,33 @@ describe(`MainP${IMPLEMENTATION} contract`, () => { ).to.be.revertedWith('invalid minTradeVolume') }) + it('Should bound minTradeVolume increases by RSR tradeability', async () => { + const stakeAmt = fp('2000000') + await rsr.mint(addr1.address, stakeAmt) + await rsr.connect(addr1).approve(stRSR.address, stakeAmt) + await stRSR.connect(addr1).stake(stakeAmt) + + const maxAllowed = (await rsrAsset.maxTradeVolume()).div(10) + await backingManager.connect(owner).setMinTradeVolume(maxAllowed) + await expect( + backingManager.connect(owner).setMinTradeVolume(maxAllowed.add(1)) + ).to.be.revertedWith('RSR maxTradeVolume too low') + + // RevenueTrader settings are independent + await expect(rsrTrader.connect(owner).setMinTradeVolume(maxAllowed.add(1))).to.not.be.reverted + + // Reductions remain available when the stake no longer satisfies the bound + await rsr.burn(stRSR.address, stakeAmt.sub(fp('1'))) + await backingManager.connect(owner).setMinTradeVolume(config.minTradeVolume) + + const [low] = await rsrAsset.price() + const poolAllowed = low.div(10) // one RSR remains in StRSR + await backingManager.connect(owner).setMinTradeVolume(poolAllowed) + await expect( + backingManager.connect(owner).setMinTradeVolume(poolAllowed.add(1)) + ).to.be.revertedWith('RSR stake too small') + }) + it('Should allow to update backingBuffer if OWNER and perform validations', async () => { const newValue: BigNumber = fp('0.02') @@ -1577,14 +1605,15 @@ describe(`MainP${IMPLEMENTATION} contract`, () => { await expect(assetRegistry.toAsset(other.address)).to.be.revertedWith('erc20 unregistered') // Reverts if no registered asset - After unregister - await expect(assetRegistry.connect(owner).unregister(rsrAsset.address)) + await expect(assetRegistry.connect(owner).unregister(aaveAsset.address)) .to.emit(assetRegistry, 'AssetUnregistered') - .withArgs(rsr.address, rsrAsset.address) - await expect(assetRegistry.toAsset(rsr.address)).to.be.revertedWith('erc20 unregistered') + .withArgs(aaveToken.address, aaveAsset.address) + await expect(assetRegistry.toAsset(aaveToken.address)).to.be.revertedWith( + 'erc20 unregistered' + ) // Returns correctly the asset expect(await assetRegistry.toAsset(rToken.address)).to.equal(rTokenAsset.address) - expect(await assetRegistry.toAsset(aaveToken.address)).to.equal(aaveAsset.address) expect(await assetRegistry.toAsset(compToken.address)).to.equal(compAsset.address) expect(await assetRegistry.toAsset(token0.address)).to.equal(collateral0.address) expect(await assetRegistry.toAsset(token1.address)).to.equal(collateral1.address) @@ -1693,10 +1722,72 @@ describe(`MainP${IMPLEMENTATION} contract`, () => { ).to.be.revertedWith('cannot swap RToken') }) - it('Should not allow to unregister RToken', async () => { + it('Should not allow to unregister RToken or RSR', async () => { await expect(assetRegistry.connect(owner).unregister(rTokenAsset.address)).to.be.revertedWith( 'cannot unregister RToken' ) + await expect(assetRegistry.connect(owner).unregister(rsrAsset.address)).to.be.revertedWith( + 'cannot unregister RSR' + ) + }) + + it('Should preserve RSR tradeability when swapping its asset', async () => { + const AssetFactory: ContractFactory = await ethers.getContractFactory('Asset') + const requiredVolume = config.minTradeVolume.mul(10) + + const AssetMockFactory: ContractFactory = await ethers.getContractFactory('AssetMock') + const unpricedAsset: AssetMock = ( + await AssetMockFactory.deploy( + PRICE_TIMEOUT, + await rsrAsset.chainlinkFeed(), + ORACLE_ERROR, + rsr.address, + requiredVolume, + ORACLE_TIMEOUT + ) + ) + await expect( + assetRegistry.connect(owner).swapRegistered(unpricedAsset.address) + ).to.be.revertedWith('RSR asset unpriced') + + const lowMaxAsset: Asset = ( + await AssetFactory.deploy( + PRICE_TIMEOUT, + await rsrAsset.chainlinkFeed(), + ORACLE_ERROR, + rsr.address, + requiredVolume.sub(1), + ORACLE_TIMEOUT + ) + ) + + await expect( + assetRegistry.connect(owner).swapRegistered(lowMaxAsset.address) + ).to.be.revertedWith('RSR maxTradeVolume too low') + expect(await assetRegistry.toAsset(rsr.address)).to.equal(rsrAsset.address) + + const validAsset: Asset = ( + await AssetFactory.deploy( + PRICE_TIMEOUT, + await rsrAsset.chainlinkFeed(), + ORACLE_ERROR, + rsr.address, + requiredVolume, + ORACLE_TIMEOUT + ) + ) + await expect( + assetRegistry.connect(owner).swapRegistered(validAsset.address) + ).to.be.revertedWith('RSR stake too small') + expect(await assetRegistry.toAsset(rsr.address)).to.equal(rsrAsset.address) + + await rsr.mint(addr1.address, fp('1')) + await rsr.connect(addr1).approve(stRSR.address, fp('1')) + await stRSR.connect(addr1).stake(fp('1')) + await expect(assetRegistry.connect(owner).swapRegistered(validAsset.address)) + .to.emit(assetRegistry, 'AssetRegistered') + .withArgs(rsr.address, validAsset.address) + expect(await assetRegistry.toAsset(rsr.address)).to.equal(validAsset.address) }) context('With quantity reverting', function () { @@ -2685,10 +2776,10 @@ describe(`MainP${IMPLEMENTATION} contract`, () => { expect(await indexBH.status()).to.equal(CollateralStatus.SOUND) expect(await indexBH.fullyCollateralized()).to.equal(false) - // Unregister everything except token0 + // Unregister everything except token0 and the protocol assets const erc20s = await assetRegistry.erc20s() for (const erc20 of erc20s) { - if (erc20 != token0.address && erc20 != rToken.address) { + if (erc20 != token0.address && erc20 != rToken.address && erc20 != rsr.address) { await assetRegistry.connect(owner).unregister(await assetRegistry.toAsset(erc20)) } } diff --git a/test/RToken.test.ts b/test/RToken.test.ts index cb958db48..2804e2c84 100644 --- a/test/RToken.test.ts +++ b/test/RToken.test.ts @@ -1991,10 +1991,15 @@ describe(`RTokenP${IMPLEMENTATION} contract`, () => { }) it('Should not revert when redeeming mostly unregistered collateral #fast', async function () { - // Unregister everything except token0 + // Unregister everything except token0 and the protocol assets const erc20s = await assetRegistry.erc20s() + const rsr = await main.rsr() for (const erc20 of erc20s) { - if (erc20 != token0.address && erc20 != rToken.address) { + if ( + erc20 != token0.address && + erc20 != rToken.address && + erc20.toLowerCase() != rsr.toLowerCase() + ) { await assetRegistry.connect(owner).unregister(await assetRegistry.toAsset(erc20)) } } diff --git a/test/Revenues.test.ts b/test/Revenues.test.ts index b2cc9651e..4f7e570d2 100644 --- a/test/Revenues.test.ts +++ b/test/Revenues.test.ts @@ -1395,7 +1395,9 @@ describe(`Revenues - P${IMPLEMENTATION}`, () => { // Perform asset swap await assetRegistry.connect(owner).swapRegistered(newSellAsset.address) + await rsr.mint(stRSR.address, fp('1')) await assetRegistry.connect(owner).swapRegistered(newRSRAsset.address) + await rsr.burn(stRSR.address, fp('1')) await basketHandler.refreshBasket() // Set rewards manually diff --git a/test/scenario/ComplexBasket.test.ts b/test/scenario/ComplexBasket.test.ts index 450cdb946..c1d2f25fe 100644 --- a/test/scenario/ComplexBasket.test.ts +++ b/test/scenario/ComplexBasket.test.ts @@ -175,7 +175,9 @@ describe(`Complex Basket - P${IMPLEMENTATION}`, () => { ORACLE_TIMEOUT ) ) + await rsr.mint(stRSR.address, fp('1')) await assetRegistry.connect(owner).swapRegistered(newRSRAsset.address) + await rsr.burn(stRSR.address, fp('1')) rsrAsset = newRSRAsset /***** Setup Basket ***********/ diff --git a/test/scenario/LargeDecimals.test.ts b/test/scenario/LargeDecimals.test.ts index c3900bfeb..0273ca9ae 100644 --- a/test/scenario/LargeDecimals.test.ts +++ b/test/scenario/LargeDecimals.test.ts @@ -150,7 +150,9 @@ describe(`Large Decimals Basket - P${IMPLEMENTATION}`, () => { ORACLE_TIMEOUT ) ) + await rsr.mint(stRSR.address, fp('1')) await assetRegistry.connect(owner).swapRegistered(newRSRAsset.address) + await rsr.burn(stRSR.address, fp('1')) rsrAsset = newRSRAsset // Setup reward asset