diff --git a/src/IonPool.sol b/src/IonPool.sol index 233c48ca..4bdf9d41 100644 --- a/src/IonPool.sol +++ b/src/IonPool.sol @@ -478,7 +478,10 @@ contract IonPool is PausableUpgradeable, RewardToken { Ilk storage ilk = $.ilks[ilkIndex]; uint256 _totalNormalizedDebt = ilk.totalNormalizedDebt; - if (_totalNormalizedDebt == 0 || block.timestamp == ilk.lastRateUpdate) { + // Because all interest that would have accrued during a pause is + // cancelled upon `unpause`, we return zero interest while markets are + // paused. + if (_totalNormalizedDebt == 0 || block.timestamp == ilk.lastRateUpdate || paused()) { // Unsafe cast OK // block.timestamp - ilk.lastRateUpdate will almost always be 0 // here. The exception is on first borrow. diff --git a/src/interfaces/IIonPool.sol b/src/interfaces/IIonPool.sol index b28d071f..b7b9e587 100644 --- a/src/interfaces/IIonPool.sol +++ b/src/interfaces/IIonPool.sol @@ -234,4 +234,5 @@ interface IIonPool { function getTotalUnderlyingClaims() external view returns (uint256); function getUnderlyingClaimOf(address user) external view returns (uint256); function extsload(bytes32 slot) external view returns (bytes32); + function balanceOfUnaccrued(address user) external view returns (uint256); } diff --git a/src/token/RewardToken.sol b/src/token/RewardToken.sol index aa9c3120..c1887128 100644 --- a/src/token/RewardToken.sol +++ b/src/token/RewardToken.sol @@ -459,6 +459,14 @@ abstract contract RewardToken is return $._normalizedBalances[user].rayMulDown($.supplyFactor + totalSupplyFactorIncrease); } + /** + * @dev Current claim of the underlying token without accounting for interest to be accrued. + */ + function balanceOfUnaccrued(address user) public view returns (uint256) { + RewardTokenStorage storage $ = _getRewardTokenStorage(); + return $._normalizedBalances[user].rayMulDown($.supplyFactor); + } + /** * @dev Accounting is done in normalized balances * @param user to get normalized balance of diff --git a/test/unit/concrete/IonPool.t.sol b/test/unit/concrete/IonPool.t.sol index 0863f53a..96a3e2ad 100644 --- a/test/unit/concrete/IonPool.t.sol +++ b/test/unit/concrete/IonPool.t.sol @@ -1192,6 +1192,67 @@ contract IonPool_InterestTest is IonPoolSharedSetup, IIonPoolEvents { previousRates[i] = rate; } } + + function test_AccrueInterestWhenPaused() public { + uint256 collateralDepositAmount = 10e18; + uint256 normalizedBorrowAmount = 5e18; + + for (uint8 i = 0; i < lens.ilkCount(iIonPool); i++) { + vm.prank(borrower1); + ionPool.depositCollateral(i, borrower1, borrower1, collateralDepositAmount, new bytes32[](0)); + + uint256 rate = ionPool.rate(i); + uint256 liquidityBefore = lens.liquidity(iIonPool); + + assertEq(ionPool.collateral(i, borrower1), collateralDepositAmount); + assertEq(underlying.balanceOf(borrower1), normalizedBorrowAmount.rayMulDown(rate) * i); + + vm.prank(borrower1); + ionPool.borrow(i, borrower1, borrower1, normalizedBorrowAmount, new bytes32[](0)); + + uint256 liquidityRemoved = normalizedBorrowAmount.rayMulDown(rate); + + assertEq(ionPool.normalizedDebt(i, borrower1), normalizedBorrowAmount); + assertEq(lens.totalNormalizedDebt(iIonPool, i), normalizedBorrowAmount); + assertEq(lens.liquidity(iIonPool), liquidityBefore - liquidityRemoved); + assertEq(underlying.balanceOf(borrower1), normalizedBorrowAmount.rayMulDown(rate) * (i + 1)); + } + + vm.warp(block.timestamp + 1 hours); + + ionPool.pause(); + + uint256 rate0AfterPause = ionPool.rate(0); + uint256 rate1AfterPause = ionPool.rate(1); + uint256 rate2AfterPause = ionPool.rate(2); + + uint256 supplyFactorAfterPause = ionPool.supplyFactor(); + uint256 lenderBalanceAfterPause = ionPool.balanceOf(lender2); + + vm.warp(block.timestamp + 365 days); + + ( + uint256 totalSupplyFactorIncrease, + uint256 treasuryMintAmount, + uint104[] memory rateIncreases, + uint256 totalDebtIncrease, + uint48[] memory timestampIncreases + ) = ionPool.calculateRewardAndDebtDistribution(); + + assertEq(totalSupplyFactorIncrease, 0, "no supply factor increase"); + assertEq(treasuryMintAmount, 0, "no treasury mint amount"); + for (uint8 i = 0; i < lens.ilkCount(iIonPool); i++) { + assertEq(rateIncreases[i], 0, "no rate increase"); + assertEq(timestampIncreases[i], 365 days, "no timestamp increase"); + } + assertEq(totalDebtIncrease, 0, "no total debt increase"); + + assertEq(ionPool.balanceOf(lender2), lenderBalanceAfterPause, "lender balance doesn't change"); + assertEq(ionPool.supplyFactor(), supplyFactorAfterPause, "supply factor doesn't change"); + assertEq(ionPool.rate(0), rate0AfterPause, "rate 0 doesn't change"); + assertEq(ionPool.rate(1), rate1AfterPause, "rate 1 doesn't change"); + assertEq(ionPool.rate(2), rate2AfterPause, "rate 2 doesn't change"); + } } contract IonPool_AdminTest is IonPoolSharedSetup { diff --git a/test/unit/concrete/vault/Vault.t.sol b/test/unit/concrete/vault/Vault.t.sol index 9039e0bb..bd7aea3d 100644 --- a/test/unit/concrete/vault/Vault.t.sol +++ b/test/unit/concrete/vault/Vault.t.sol @@ -1431,6 +1431,138 @@ contract VaultERC4626ExternalViews is VaultSharedSetup { super.setUp(); } + function test_TotalAssetsWithSinglePausedIonPool() public { + weEthIonPool.updateSupplyCap(type(uint256).max); + weEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); + + supply(address(this), weEthIonPool, 1000e18); + borrow(address(this), weEthIonPool, weEthGemJoin, 100e18, 70e18); + + uint256[] memory allocationCaps = new uint256[](3); + allocationCaps[0] = 20e18; + allocationCaps[1] = 0; + allocationCaps[2] = 0; + + vm.prank(OWNER); + vault.updateAllocationCaps(markets, allocationCaps); + + uint256 depositAmt = 10e18; + setERC20Balance(address(BASE_ASSET), address(this), depositAmt); + vault.deposit(depositAmt, address(this)); + + assertEq(weEthIonPool.balanceOf(address(vault)), depositAmt, "weEthIonPool balance"); + + // Pause the weEthIonPool, stop accruing interest + weEthIonPool.pause(); + assertTrue(weEthIonPool.paused(), "weEthIonPool is paused"); + + vm.warp(block.timestamp + 365 days); + + assertEq(weEthIonPool.balanceOf(address(vault)), depositAmt, "weEthIonPool accrues interest"); + assertEq( + weEthIonPool.balanceOfUnaccrued(address(vault)), + weEthIonPool.balanceOf(address(vault)), + "weEthIonPool unaccrued balance" + ); + + uint256 totalAssets = vault.totalAssets(); + assertEq(totalAssets, depositAmt, "total assets with paused IonPool does not include interest"); + + // When unpaused, should now accrue interest + weEthIonPool.unpause(); + vm.warp(block.timestamp + 365 days); + + assertGt(weEthIonPool.balanceOf(address(vault)), depositAmt, "weEthIonPool accrues interest"); + assertGt( + weEthIonPool.balanceOf(address(vault)), + weEthIonPool.balanceOfUnaccrued(address(vault)), + "weEthIonPool unaccrued balance" + ); + + assertGt(vault.totalAssets(), depositAmt, "total assets with paused IonPool does not include interest"); + } + + function test_TotalAssetsWithMultiplePausedIonPools() public { + // Make sure every pool has debt to accrue interest from + uint256 initialSupplyAmt = 1000e18; + weEthIonPool.updateSupplyCap(type(uint256).max); + rsEthIonPool.updateSupplyCap(type(uint256).max); + rswEthIonPool.updateSupplyCap(type(uint256).max); + + weEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); + rsEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); + rswEthIonPool.updateIlkDebtCeiling(0, type(uint256).max); + + supply(address(this), weEthIonPool, initialSupplyAmt); + borrow(address(this), weEthIonPool, weEthGemJoin, 100e18, 70e18); + + supply(address(this), rsEthIonPool, initialSupplyAmt); + borrow(address(this), rsEthIonPool, rsEthGemJoin, 100e18, 70e18); + + supply(address(this), rswEthIonPool, initialSupplyAmt); + borrow(address(this), rswEthIonPool, rswEthGemJoin, 100e18, 70e18); + + uint256[] memory allocationCaps = new uint256[](3); + uint256 weEthIonPoolAmt = 10e18; + uint256 rsEthIonPoolAmt = 20e18; + uint256 rswEthIonPoolAmt = 30e18; + allocationCaps[0] = weEthIonPoolAmt; + allocationCaps[1] = rsEthIonPoolAmt; + allocationCaps[2] = rswEthIonPoolAmt; + + vm.prank(OWNER); + vault.updateAllocationCaps(markets, allocationCaps); + + uint256 depositAmt = 60e18; + setERC20Balance(address(BASE_ASSET), address(this), depositAmt); + vault.deposit(depositAmt, address(this)); + + assertEq(weEthIonPool.balanceOf(address(vault)), weEthIonPoolAmt, "weEthIonPool balance"); + assertEq(rsEthIonPool.balanceOf(address(vault)), rsEthIonPoolAmt, "rsEthIonPool balance"); + assertEq(rswEthIonPool.balanceOf(address(vault)), rswEthIonPoolAmt, "rswEthIonPool balance"); + + weEthIonPool.pause(); + // NOTE rsEthIonPool is not paused + rswEthIonPool.pause(); + + assertTrue(weEthIonPool.paused(), "weEthIonPool is paused"); + assertFalse(rsEthIonPool.paused(), "rsEthIonPool is not paused"); + assertTrue(rswEthIonPool.paused(), "rswEthIonPool is paused"); + + vm.warp(block.timestamp + 365 days); + + // The 'unaccrued' values should not change + assertEq(weEthIonPool.balanceOfUnaccrued(address(vault)), weEthIonPoolAmt, "weEthIonPool balance"); + assertEq(rsEthIonPool.balanceOfUnaccrued(address(vault)), rsEthIonPoolAmt, "rsEthIonPool balance"); + assertEq(rswEthIonPool.balanceOfUnaccrued(address(vault)), rswEthIonPoolAmt, "rswEthIonPool balance"); + + // When paused, the unaccrued and accrued balanceOf should be the same + assertEq( + weEthIonPool.balanceOf(address(vault)), + weEthIonPool.balanceOfUnaccrued(address(vault)), + "weEthIonPool balance increases" + ); + assertEq( + rswEthIonPool.balanceOf(address(vault)), + rswEthIonPool.balanceOfUnaccrued(address(vault)), + "rswEthIonPool balance increases" + ); + + // When not paused, the accrued balanceOf should be greater + assertGt( + rsEthIonPool.balanceOf(address(vault)), + rsEthIonPool.balanceOfUnaccrued(address(vault)), + "rsEthIonPool balance does not change" + ); + + uint256 expectedTotalAssets = weEthIonPool.balanceOfUnaccrued(address(vault)) + + rsEthIonPool.balanceOf(address(vault)) + rswEthIonPool.balanceOfUnaccrued(address(vault)); + + assertEq( + vault.totalAssets(), expectedTotalAssets, "total assets without accounting for interest in paused IonPools" + ); + } + // --- Max --- // Get max and submit max transactions