diff --git a/CHANGELOG.md b/CHANGELOG.md index c870e8289..ba1c3a782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +# 4.3.0 + +- 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`. + # 4.2.0 Bump solidity version to 0.8.28 diff --git a/contracts/interfaces/IBasketHandler.sol b/contracts/interfaces/IBasketHandler.sol index d5f21969f..261a3034d 100644 --- a/contracts/interfaces/IBasketHandler.sol +++ b/contracts/interfaces/IBasketHandler.sol @@ -116,6 +116,9 @@ interface IBasketHandler is IComponent { /// @return If the basket is ready to issue and trade function isReady() external view returns (bool); + /// @return Whether the BackingManager's trading delay is bypassed for this basket + function tradingDelayBypassed() external view returns (bool); + /// Returns basket quantity rounded up, wihout any issuance premium /// @param erc20 The ERC20 token contract for the asset /// @return {tok/BU} The redemption quantity of token in the reference basket, rounded up diff --git a/contracts/interfaces/IDeployer.sol b/contracts/interfaces/IDeployer.sol index ede062536..7f14b63a6 100644 --- a/contracts/interfaces/IDeployer.sol +++ b/contracts/interfaces/IDeployer.sol @@ -47,7 +47,7 @@ struct DeploymentParams { bool enableIssuancePremium; // whether to enable the issuance premium // // === BackingManager === - uint48 tradingDelay; // {s} how long to wait until starting auctions after switching basket + uint48 tradingDelay; // {s} delay for automatic trading after default uint48 batchAuctionLength; // {s} the length of a Gnosis EasyAuction uint48 dutchAuctionLength; // {s} the length of a falling-price dutch auction uint192 backingBuffer; // {1} how much extra backing collateral to keep diff --git a/contracts/mixins/Versioned.sol b/contracts/mixins/Versioned.sol index 94d275cb8..511ef4ac4 100644 --- a/contracts/mixins/Versioned.sol +++ b/contracts/mixins/Versioned.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.28; import "../interfaces/IVersioned.sol"; // This value should be updated on each release -string constant VERSION = "4.2.0"; +string constant VERSION = "4.3.0"; /** * @title Versioned diff --git a/contracts/p0/BackingManager.sol b/contracts/p0/BackingManager.sol index 2efc92896..7a3bdce60 100644 --- a/contracts/p0/BackingManager.sol +++ b/contracts/p0/BackingManager.sol @@ -24,7 +24,7 @@ contract BackingManagerP0 is TradingP0, IBackingManager { uint48 public constant MAX_TRADING_DELAY = 60 * 60 * 24 * 365; // {s} 1 year uint192 public constant MAX_BACKING_BUFFER = 1e18; // {%} - uint48 public tradingDelay; // {s} how long to wait until resuming trading after switching + uint48 public tradingDelay; // {s} delay for automatic trading after default uint192 public backingBuffer; // {%} how much extra backing collateral to keep mapping(TradeKind => uint48) private tradeEnd; // {s} last endTime() of an auction per kind @@ -95,7 +95,8 @@ contract BackingManagerP0 is TradingP0, IBackingManager { require(tradesOpen == 0, "trade open"); require(main.basketHandler().isReady(), "basket not ready"); require( - block.timestamp >= main.basketHandler().timestamp() + tradingDelay, + block.timestamp >= main.basketHandler().timestamp() + tradingDelay || + main.basketHandler().tradingDelayBypassed(), "trading delayed" ); require(!main.basketHandler().fullyCollateralized(), "already collateralized"); @@ -152,7 +153,8 @@ contract BackingManagerP0 is TradingP0, IBackingManager { require(tradesOpen == 0, "trade open"); require(main.basketHandler().isReady(), "basket not ready"); require( - block.timestamp >= main.basketHandler().timestamp() + tradingDelay, + block.timestamp >= main.basketHandler().timestamp() + tradingDelay || + main.basketHandler().tradingDelayBypassed(), "trading delayed" ); require(main.basketHandler().fullyCollateralized(), "undercollateralized"); diff --git a/contracts/p0/BasketHandler.sol b/contracts/p0/BasketHandler.sol index aca8db918..aea8c38e9 100644 --- a/contracts/p0/BasketHandler.sol +++ b/contracts/p0/BasketHandler.sol @@ -156,6 +156,11 @@ contract BasketHandlerP0 is ComponentP0, IBasketHandler { bool public enableIssuancePremium; + // === 4.3.0 === + + /// Whether the trading delay is bypassed for the current basket + bool public tradingDelayBypassed; + // ==== Invariants ==== // basket is a valid Basket: // basket.erc20s is a valid collateral array and basket.erc20s == keys(basket.refAmts) @@ -214,11 +219,17 @@ contract BasketHandlerP0 is ComponentP0, IBasketHandler { function refreshBasket() external { main.assetRegistry().refresh(); + bool isOwner = main.hasRole(OWNER, _msgSender()); require( - main.hasRole(OWNER, _msgSender()) || - (lastStatus == CollateralStatus.DISABLED && !main.tradingPausedOrFrozen()), + isOwner || (lastStatus == CollateralStatus.DISABLED && !main.tradingPausedOrFrozen()), "basket unrefreshable" ); + // A disabled basket preserves the prior bypass value. + if (isOwner) { + tradingDelayBypassed = true; + } else if (!disabled) { + tradingDelayBypassed = false; + } _switchBasket(); trackStatus(); diff --git a/contracts/p1/BackingManager.sol b/contracts/p1/BackingManager.sol index 1bdca04b0..d9e8b679b 100644 --- a/contracts/p1/BackingManager.sol +++ b/contracts/p1/BackingManager.sol @@ -33,7 +33,7 @@ contract BackingManagerP1 is TradingP1, IBackingManager { uint48 public constant MAX_TRADING_DELAY = 60 * 60 * 24 * 365; // {s} 1 year uint192 public constant MAX_BACKING_BUFFER = FIX_ONE; // {1} 100% - uint48 public tradingDelay; // {s} how long to wait until resuming trading after switching + uint48 public tradingDelay; // {s} delay for automatic trading after default uint192 public backingBuffer; // {1} how much extra backing collateral to keep // === 3.0.0 === @@ -120,7 +120,11 @@ contract BackingManagerP1 is TradingP1, IBackingManager { require(tradesOpen == 0, "trade open"); require(basketHandler.isReady(), "basket not ready"); - require(block.timestamp >= basketHandler.timestamp() + tradingDelay, "trading delayed"); + require( + block.timestamp >= basketHandler.timestamp() + tradingDelay || + basketHandler.tradingDelayBypassed(), + "trading delayed" + ); BasketRange memory basketsHeld = basketHandler.basketsHeldBy(address(this)); require(basketsHeld.bottom < rToken.basketsNeeded(), "already collateralized"); @@ -185,7 +189,11 @@ contract BackingManagerP1 is TradingP1, IBackingManager { require(tradesOpen == 0, "trade open"); require(basketHandler.isReady(), "basket not ready"); - require(block.timestamp >= basketHandler.timestamp() + tradingDelay, "trading delayed"); + require( + block.timestamp >= basketHandler.timestamp() + tradingDelay || + basketHandler.tradingDelayBypassed(), + "trading delayed" + ); require(basketsHeld.bottom >= rToken.basketsNeeded(), "undercollateralized"); // require(basketHandler.fullyCollateralized()) diff --git a/contracts/p1/BasketHandler.sol b/contracts/p1/BasketHandler.sol index 97fab9835..35c06f815 100644 --- a/contracts/p1/BasketHandler.sol +++ b/contracts/p1/BasketHandler.sol @@ -96,6 +96,11 @@ contract BasketHandlerP1 is ComponentP1, IBasketHandler { bool public enableIssuancePremium; + // === 4.3.0 === + + /// Whether the trading delay is bypassed for the current basket + bool public tradingDelayBypassed; + // ==== Invariants ==== // basket is a valid Basket: // basket.erc20s is a valid collateral array and basket.erc20s == keys(basket.refAmts) @@ -159,11 +164,17 @@ contract BasketHandlerP1 is ComponentP1, IBasketHandler { function refreshBasket() external { assetRegistry.refresh(); + bool isOwner = main.hasRole(OWNER, _msgSender()); require( - main.hasRole(OWNER, _msgSender()) || - (lastStatus == CollateralStatus.DISABLED && !main.tradingPausedOrFrozen()), + isOwner || (lastStatus == CollateralStatus.DISABLED && !main.tradingPausedOrFrozen()), "basket unrefreshable" ); + // A disabled basket preserves the prior bypass value. + if (isOwner) { + tradingDelayBypassed = true; + } else if (!disabled) { + tradingDelayBypassed = false; + } _switchBasket(); trackStatus(); diff --git a/contracts/plugins/assets/VersionedAsset.sol b/contracts/plugins/assets/VersionedAsset.sol index 7cde39f95..b9189f46a 100644 --- a/contracts/plugins/assets/VersionedAsset.sol +++ b/contracts/plugins/assets/VersionedAsset.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.28; import "../../interfaces/IVersioned.sol"; // This value should be updated on each release -string constant ASSET_VERSION = "4.2.0"; +string constant ASSET_VERSION = "4.3.0"; /** * @title VersionedAsset diff --git a/test/RToken.test.ts b/test/RToken.test.ts index a48cb9019..cb958db48 100644 --- a/test/RToken.test.ts +++ b/test/RToken.test.ts @@ -67,6 +67,7 @@ describe(`RTokenP${IMPLEMENTATION} contract`, () => { let collateral0: Collateral let collateral1: Collateral + let collateral: Collateral[] let collateral2: ATokenFiatCollateral let collateral3: CTokenFiatCollateral let basket: Collateral[] @@ -98,6 +99,7 @@ describe(`RTokenP${IMPLEMENTATION} contract`, () => { assetRegistry, backingManager, basket, + collateral, basketHandler, config, facadeTest, @@ -1671,6 +1673,61 @@ describe(`RTokenP${IMPLEMENTATION} contract`, () => { ) }) + it('Should redeem across the pre-default and backup baskets', async function () { + const backupToken = ( + await ethers.getContractAt('ERC20Mock', await collateral[2].erc20()) + ) + await assetRegistry.connect(owner).register(collateral[2].address) + await basketHandler + .connect(owner) + .setBackupConfig(ethers.utils.formatBytes32String('USD'), 1, [backupToken.address]) + await backupToken.connect(owner).mint(addr1.address, initialBal) + + await setOraclePrice(collateral1.address, bn('0.5e8')) + await collateral1.refresh() + await advanceTime((await collateral1.delayUntilDefault()).toString()) + await collateral1.refresh() + await basketHandler.connect(addr2).refreshBasket() + + await advanceTime(Number(config.warmupPeriod) + 1) + await backupToken.connect(addr1).approve(rToken.address, initialBal) + await rToken.connect(addr1).issue(issueAmount) + + await rToken.connect(owner).setRedemptionThrottleParams({ + amtRate: fp('1e9'), + pctRate: fp('1'), + }) + + const redeemAmount = issueAmount.mul(2) + const basketNonces = [1, 2] + const portions = [fp('0.5'), fp('0.5')] + const quote = await basketHandler.quoteCustomRedemption( + basketNonces, + portions, + redeemAmount + ) + + await rToken + .connect(addr1) + .redeemCustom( + addr1.address, + redeemAmount, + basketNonces, + portions, + quote.erc20s, + quote.quantities + ) + + expect(await rToken.totalSupply()).to.equal(0) + expect(await facadeTest.callStatic.totalAssetValue(rToken.address)).to.equal(0) + await Promise.all( + tokens.map(async (token) => { + expect(await token.balanceOf(addr1.address)).to.equal(initialBal) + }) + ) + expect(await backupToken.balanceOf(addr1.address)).to.equal(initialBal) + }) + it('Should redeem to a different account', async function () { // Provide approvals await Promise.all(tokens.map((t) => t.connect(addr1).approve(rToken.address, initialBal))) diff --git a/test/Recollateralization.test.ts b/test/Recollateralization.test.ts index 962185af3..0292acf19 100644 --- a/test/Recollateralization.test.ts +++ b/test/Recollateralization.test.ts @@ -964,7 +964,7 @@ describe(`Recollateralization - P${IMPLEMENTATION}`, () => { }) }) - it('Should skip start recollateralization after tradingDelay', async () => { + it('Should skip tradingDelay after governance basket switch', async () => { // Set trading delay const newDelay = 3600 await backingManager.connect(owner).setTradingDelay(newDelay) // 1 hour @@ -977,19 +977,13 @@ describe(`Recollateralization - P${IMPLEMENTATION}`, () => { .to.emit(basketHandler, 'BasketSet') .withArgs(3, [token1.address], [fp('1')], false) + expect(await basketHandler.tradingDelayBypassed()).to.equal(true) + // Trigger recollateralization const sellAmt: BigNumber = await token0.balanceOf(backingManager.address) const minBuyAmt: BigNumber = await toMinBuyAmt(sellAmt, fp('1'), fp('1')) - // Attempt to trigger before trading delay - Should revert - await expect(backingManager.rebalance(TradeKind.BATCH_AUCTION)).to.be.revertedWith( - 'trading delayed' - ) - - // Advance time post trading delay - await advanceTime(newDelay + 1) - - // Auction can be run now + // Auction can be run immediately await expect(facadeTest.runAuctionsForAllTraders(rToken.address)) .to.emit(backingManager, 'TradeStarted') .withArgs( @@ -1012,6 +1006,35 @@ describe(`Recollateralization - P${IMPLEMENTATION}`, () => { }) }) + it('Should delay automatic rebalance after default', async () => { + const newDelay = 3600 + await backingManager.connect(owner).setTradingDelay(newDelay) + + await assetRegistry.connect(owner).register(backupCollateral1.address) + await basketHandler + .connect(owner) + .setBackupConfig(ethers.utils.formatBytes32String('USD'), bn(1), [backupToken1.address]) + + await setOraclePrice(collateral0.address, bn('0.5e8')) + await collateral0.refresh() + await advanceTime((await collateral0.delayUntilDefault()).toString()) + await collateral0.refresh() + + await basketHandler.connect(addr1).refreshBasket() + expect(await basketHandler.tradingDelayBypassed()).to.equal(false) + + await advanceTime(config.warmupPeriod.add(1).toString()) + await expect(backingManager.rebalance(TradeKind.BATCH_AUCTION)).to.be.revertedWith( + 'trading delayed' + ) + + await advanceTime(newDelay + 1) + await expect(backingManager.rebalance(TradeKind.BATCH_AUCTION)).to.emit( + backingManager, + 'TradeStarted' + ) + }) + it('Should not recollateralize when switching basket if all assets are UNPRICED', async () => { // Setup prime basket await basketHandler.connect(owner).setPrimeBasket([token1.address], [fp('1')]) diff --git a/test/Revenues.test.ts b/test/Revenues.test.ts index 95ece509c..b2cc9651e 100644 --- a/test/Revenues.test.ts +++ b/test/Revenues.test.ts @@ -5123,7 +5123,7 @@ describe(`Revenues - P${IMPLEMENTATION}`, () => { expect(await rToken.basketsNeeded()).to.equal(mintAmt.mul(2)) }) - it('Should not forward revenue before trading delay', async () => { + it('Should forward revenue immediately after governance basket switch', async () => { // Set trading delay const newDelay = 3600 await backingManager.connect(owner).setTradingDelay(newDelay) // 1 hour @@ -5141,19 +5141,9 @@ describe(`Revenues - P${IMPLEMENTATION}`, () => { .to.emit(basketHandler, 'BasketSet') .withArgs(3, [token1.address], [fp('1')], false) - // Cannot forward revenue yet - await expect(backingManager.forwardRevenue([aaveToken.address])).to.be.revertedWith( - 'trading delayed' - ) - - expect(await aaveToken.balanceOf(backingManager.address)).to.equal(rewardAmt) - expect(await aaveToken.balanceOf(rsrTrader.address)).to.equal(0) - expect(await aaveToken.balanceOf(rTokenTrader.address)).to.equal(0) - - // Advance time post trading delay - await advanceTime(newDelay + 1) + expect(await basketHandler.tradingDelayBypassed()).to.equal(true) - // Now we can forward revenue successfully + // Governance basket changes do not wait for trading delay await expect(backingManager.forwardRevenue([aaveToken.address])).to.emit( aaveToken, 'Transfer' @@ -5166,6 +5156,35 @@ describe(`Revenues - P${IMPLEMENTATION}`, () => { rewardAmt.mul(4000).div(10000) ) }) + + it('Should delay revenue forwarding after an automatic default', async () => { + const newDelay = 3600 + await backingManager.connect(owner).setTradingDelay(newDelay) + + await assetRegistry.connect(owner).register(collateral[2].address) + await basketHandler + .connect(owner) + .setBackupConfig(ethers.utils.formatBytes32String('USD'), 1, [erc20s[2].address]) + + const rewardAmt = bn('100e18') + await token2.setRewards(backingManager.address, rewardAmt) + await backingManager.claimRewardsSingle(token2.address) + + await token2.setExchangeRate(fp('0.99')) + await assetRegistry.refresh() + await basketHandler.connect(addr1).refreshBasket() + expect(await basketHandler.tradingDelayBypassed()).to.equal(false) + + await advanceTime(Number(config.warmupPeriod) + 1) + await expect(backingManager.forwardRevenue([aaveToken.address])).to.be.revertedWith( + 'trading delayed' + ) + await advanceTime(newDelay + 1) + await expect(backingManager.forwardRevenue([aaveToken.address])).to.emit( + aaveToken, + 'Transfer' + ) + }) }) }) diff --git a/test/fixtures.ts b/test/fixtures.ts index 3f6e7d27a..7c5c6dbfe 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -95,7 +95,7 @@ export const ORACLE_ERROR = fp('0.01') // 1% oracle error export const REVENUE_HIDING = fp('0') // no revenue hiding by default; test individually // This will have to be updated on each release -export const VERSION = '4.2.0' +export const VERSION = '4.3.0' export type Collateral = | FiatCollateral diff --git a/test/integration/UpgradeToR4.test.ts b/test/integration/UpgradeToR4.test.ts index a9b4b21e2..70fe2e2e6 100644 --- a/test/integration/UpgradeToR4.test.ts +++ b/test/integration/UpgradeToR4.test.ts @@ -26,8 +26,8 @@ const rTokensToTest: RTokenParams[] = [ }, ] -// 4.2.0 -const v4VersionHash = '0x99b189f6a35f2d8d52cd79b21cabb1eca4a12f69132e253d75b4ee7634d0fef8' +// 4.3.0 +const v4VersionHash = '0xdef94dbdd8411ae515cefcb56b153df454453010a1f4c881b3daa7ed18db793a' async function _confirmVersion(address: string, target: string) { const versionedTarget = await ethers.getContractAt('Versioned', address) @@ -35,7 +35,7 @@ async function _confirmVersion(address: string, target: string) { } // NOTE: This is an explicit test! -describe('Upgrade from 3.4.0 to 4.2.0 (Mainnet Fork)', () => { +describe('Upgrade from 3.4.0 to 4.3.0 (Mainnet Fork)', () => { let implementations: IImplementations let deployer: DeployerP1 let versionRegistry: VersionRegistry @@ -138,7 +138,7 @@ describe('Upgrade from 3.4.0 to 4.2.0 (Mainnet Fork)', () => { ) await whileImpersonating(hre, TimelockController.address, async (signer) => { - // Upgrade Main to 4.2.0's Main + // Upgrade Main to 4.3.0's Main await RTokenMain.connect(signer).upgradeTo(implementations.main) // Set registries @@ -181,7 +181,7 @@ describe('Upgrade from 3.4.0 to 4.2.0 (Mainnet Fork)', () => { ] for (let j = 0; j < targetsToVerify.length; j++) { - await _confirmVersion(targetsToVerify[j], '4.2.0') + await _confirmVersion(targetsToVerify[j], '4.3.0') } const broker = await ethers.getContractAt('BrokerP1', await RTokenMain.broker()) @@ -193,7 +193,7 @@ describe('Upgrade from 3.4.0 to 4.2.0 (Mainnet Fork)', () => { // So, let's upgrade the RToken _again_ to verify the process flow works. await whileImpersonating(hre, TimelockController.address, async (signer) => { - // Upgrade Main to 4.2.0's Main + // Upgrade Main to 4.3.0's Main await RTokenMain.connect(signer).upgradeMainTo(v4VersionHash) // Upgrade RToken diff --git a/test/integration/UpgradeToR4WithRegistries.test.ts b/test/integration/UpgradeToR4WithRegistries.test.ts index b2ca9756d..849528864 100644 --- a/test/integration/UpgradeToR4WithRegistries.test.ts +++ b/test/integration/UpgradeToR4WithRegistries.test.ts @@ -25,8 +25,8 @@ const rTokensToTest: RTokenParams[] = [ }, ] -// 4.2.0 -const v4VersionHash = '0x99b189f6a35f2d8d52cd79b21cabb1eca4a12f69132e253d75b4ee7634d0fef8' +// 4.3.0 +const v4VersionHash = '0xdef94dbdd8411ae515cefcb56b153df454453010a1f4c881b3daa7ed18db793a' const v2VersionHash = '0xb4bcb154e38601c389396fa918314da42d4626f13ef6d0ceb07e5f5d26b2fbc3' async function _confirmVersion(address: string, target: string) { @@ -35,7 +35,7 @@ async function _confirmVersion(address: string, target: string) { } // NOTE: This is an explicit test! -describe('Upgrade from 4.2.0 to New Version with all Registries Enabled', () => { +describe('Upgrade from 4.3.0 to New Version with all Registries Enabled', () => { let versionRegistry: VersionRegistry let assetPluginRegistry: AssetPluginRegistry let daoFeeRegistry: DAOFeeRegistry @@ -200,7 +200,7 @@ describe('Upgrade from 4.2.0 to New Version with all Registries Enabled', () => ) await whileImpersonating(hre, TimelockController.address, async (signer) => { - // Upgrade Main to 4.2.0's Main + // Upgrade Main to 4.3.0's Main await RTokenMain.connect(signer).upgradeTo(implementationsR4.main) // Set registries @@ -243,7 +243,7 @@ describe('Upgrade from 4.2.0 to New Version with all Registries Enabled', () => ] for (let j = 0; j < targetsToVerify.length; j++) { - await _confirmVersion(targetsToVerify[j], '4.2.0') + await _confirmVersion(targetsToVerify[j], '4.3.0') } const currentAssetRegistry = await RTokenAssetRegistry.getRegistry() @@ -261,7 +261,7 @@ describe('Upgrade from 4.2.0 to New Version with all Registries Enabled', () => // So, let's upgrade the RToken to a new version now. await whileImpersonating(hre, TimelockController.address, async (signer) => { - // Upgrade Main to 4.2.0's Main + // Upgrade Main to 4.3.0's Main await RTokenMain.connect(signer).upgradeMainTo(v2VersionHash) // Registry does not have assets yet. @@ -284,7 +284,7 @@ describe('Upgrade from 4.2.0 to New Version with all Registries Enabled', () => // Finish upgrade, with asset validation await whileImpersonating(hre, TimelockController.address, async (signer) => { - // Upgrade Main to 4.2.0's Main + // Upgrade Main to 4.3.0's Main await RTokenMain.connect(signer).upgradeMainTo(v2VersionHash) // Upgrade RToken, without validating assets