diff --git a/src/spells/EmergencySpell_SparkLend_FreezeAllAssets.sol b/src/spells/EmergencySpell_SparkLend_FreezeAllAssets.sol new file mode 100644 index 0000000..c75534d --- /dev/null +++ b/src/spells/EmergencySpell_SparkLend_FreezeAllAssets.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +pragma solidity ^0.8.13; + +import { ISparkLendFreezerMom } from "src/interfaces/ISparkLendFreezerMom.sol"; + +contract EmergencySpell_SparkLend_FreezeAllAssets { + + address public immutable sparkLendFreezerMom; + + bool public executed; + + constructor(address sparklendFreezerMom_) { + sparkLendFreezerMom = sparklendFreezerMom_; + } + + function freeze() external { + require(!executed, "FreezeAllAssetsSpell/already-executed"); + executed = true; + ISparkLendFreezerMom(sparkLendFreezerMom).freezeAllMarkets(); + } + +} diff --git a/src/spells/EmergencySpell_SparkLend_FreezeSingleAsset.sol b/src/spells/EmergencySpell_SparkLend_FreezeSingleAsset.sol index 59d2375..6381290 100644 --- a/src/spells/EmergencySpell_SparkLend_FreezeSingleAsset.sol +++ b/src/spells/EmergencySpell_SparkLend_FreezeSingleAsset.sol @@ -8,12 +8,16 @@ contract EmergencySpell_SparkLend_FreezeSingleAsset { address public immutable sparkLendFreezerMom; address public immutable reserve; + bool public executed; + constructor(address sparklendFreezerMom_, address reserve_) { sparkLendFreezerMom = sparklendFreezerMom_; reserve = reserve_; } function freeze() external { + require(!executed, "FreezeSingleAssetSpell/already-executed"); + executed = true; ISparkLendFreezerMom(sparkLendFreezerMom).freezeMarket(reserve); } diff --git a/test/IntegrationTests.t.sol b/test/IntegrationTests.t.sol index ec037a6..249aea8 100644 --- a/test/IntegrationTests.t.sol +++ b/test/IntegrationTests.t.sol @@ -7,9 +7,13 @@ import { SafeERC20 } from "lib/erc20-helpers/src/SafeERC20.sol"; import { IERC20 } from "lib/erc20-helpers/src/interfaces/IERC20.sol"; import { SparkLendFreezerMom } from "src/SparkLendFreezerMom.sol"; + import { EmergencySpell_SparkLend_FreezeSingleAsset as FreezeSingleAssetSpell } from "src/spells/EmergencySpell_SparkLend_FreezeSingleAsset.sol"; +import { EmergencySpell_SparkLend_FreezeAllAssets as FreezeAllAssetsSpell } + from "src/spells/EmergencySpell_SparkLend_FreezeAllAssets.sol"; + import { IACLManager } from "lib/aave-v3-core/contracts/interfaces/IACLManager.sol"; import { IPoolConfigurator } from "lib/aave-v3-core/contracts/interfaces/IPoolConfigurator.sol"; import { IPoolDataProvider } from "lib/aave-v3-core/contracts/interfaces/IPoolDataProvider.sol"; @@ -17,7 +21,7 @@ import { IPool } from "lib/aave-v3-core/contracts/interfaces/IPool.s import { IAuthorityLike } from "test/Interfaces.sol"; -contract IntegrationTests is Test { +contract IntegrationTestsBase is Test { using SafeERC20 for IERC20; @@ -32,7 +36,6 @@ contract IntegrationTests is Test { address constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address mkrWhale = makeAddr("mkrWhale"); - address sparkUser = makeAddr("sparkUser"); address randomUser = makeAddr("randomUser"); IAuthorityLike authority = IAuthorityLike(AUTHORITY); @@ -41,141 +44,467 @@ contract IntegrationTests is Test { IPoolConfigurator poolConfig = IPoolConfigurator(POOL_CONFIG); IPoolDataProvider dataProvider = IPoolDataProvider(DATA_PROVIDER); - SparkLendFreezerMom freezer; - FreezeSingleAssetSpell freezeWethSpell; + SparkLendFreezerMom freezer; - function setUp() public { - vm.createSelectFork(getChain('mainnet').rpcUrl); + function setUp() public virtual { + vm.createSelectFork(getChain('mainnet').rpcUrl, 18_621_350); - freezer = new SparkLendFreezerMom(POOL_CONFIG, POOL); - freezeWethSpell = new FreezeSingleAssetSpell(address(freezer), WETH); + freezer = new SparkLendFreezerMom(POOL_CONFIG, POOL); freezer.setAuthority(AUTHORITY); freezer.setOwner(PAUSE_PROXY); } + function _vote(address spell) internal { + uint256 amount = 1_000_000 ether; + + deal(MKR, mkrWhale, amount); + + vm.startPrank(mkrWhale); + IERC20(MKR).approve(AUTHORITY, amount); + authority.lock(amount); + + address[] memory slate = new address[](1); + slate[0] = spell; + authority.vote(slate); + + vm.roll(block.number + 1); + + authority.lift(spell); + + vm.stopPrank(); + + assertTrue(authority.hat() == spell); + } + + // NOTE: For all checks, not checking pool.swapBorrowRateMode() since stable rate + // isn't enabled on any reserve. + function _checkUserActionsUnfrozen( + address asset, + uint256 supplyAmount, + uint256 withdrawAmount, + uint256 borrowAmount, + uint256 repayAmount + ) + internal + { + assertEq(_isFrozen(asset), false); + + // Make a new address for each asset to avoid side effects, eg. siloed borrowing + address sparkUser = makeAddr(string.concat(IERC20(asset).name(), " user")); + + // If asset is not enabled as collateral, post enough WETH to ensure that the + // reserve asset can be borrowed. + // NOTE: LTV check is necessary because LT of DAI is still 1. + if (!_usageAsCollateralEnabled(asset) || _ltv(asset) == 0) { + _supplyWethCollateral(sparkUser, 1_000 ether); + } + + vm.startPrank(sparkUser); + + deal(asset, sparkUser, supplyAmount); + IERC20(asset).safeApprove(POOL, type(uint256).max); + + // User can supply and withdraw collateral always + pool.supply(asset, supplyAmount, sparkUser, 0); + pool.withdraw(asset, withdrawAmount, sparkUser); + + // User can borrow and repay if borrowing is enabled + if (_borrowingEnabled(asset)) { + pool.borrow(asset, borrowAmount, 2, 0, sparkUser); + pool.repay(asset, repayAmount, 2, sparkUser); + } + + vm.stopPrank(); + } + + function _checkUserActionsFrozen( + address asset, + uint256 supplyAmount, + uint256 withdrawAmount, + uint256 borrowAmount, + uint256 repayAmount + ) + internal + { + assertEq(_isFrozen(asset), true); + + // Use same address that borrowed when unfrozen to repay debt + address sparkUser = makeAddr(string.concat(IERC20(asset).name(), " user")); + + vm.startPrank(sparkUser); + + // User can't supply + vm.expectRevert(bytes("28")); // RESERVE_FROZEN + pool.supply(asset, supplyAmount, sparkUser, 0); + + // User can't borrow + vm.expectRevert(bytes("28")); // RESERVE_FROZEN + pool.borrow(asset, borrowAmount, 2, 0, sparkUser); + + // User can still withdraw collateral always + pool.withdraw(asset, withdrawAmount, sparkUser); + + // User can repay if borrowing was enabled + if (_borrowingEnabled(asset)) { + pool.repay(asset, repayAmount, 2, sparkUser); + } + + vm.stopPrank(); + } + + function _supplyWethCollateral(address user, uint256 amount) internal { + bool frozenWeth = _isFrozen(WETH); + + // Unfreeze WETH market if necessary + if (frozenWeth) { + vm.prank(SPARK_PROXY); + poolConfig.setReserveFreeze(WETH, false); + } + + // Supply WETH + vm.startPrank(user); + deal(WETH, user, amount); + IERC20(WETH).safeApprove(POOL, type(uint256).max); + pool.supply(WETH, amount, user, 0); + vm.stopPrank(); + + // If the WETH market was originally frozen, return it back to frozen state + if (frozenWeth) { + vm.prank(SPARK_PROXY); + poolConfig.setReserveFreeze(WETH, false); + } + } + + function _isFrozen(address asset) internal view returns (bool isFrozen) { + ( ,,,,,,,,, isFrozen ) = dataProvider.getReserveConfigurationData(asset); + } + + function _borrowingEnabled(address asset) internal view returns (bool borrowingEnabled) { + ( ,,,,,, borrowingEnabled,,, ) = dataProvider.getReserveConfigurationData(asset); + } + + function _usageAsCollateralEnabled(address asset) + internal view returns (bool usageAsCollateralEnabled) + { + ( ,,,,, usageAsCollateralEnabled,,,, ) = dataProvider.getReserveConfigurationData(asset); + } + + function _ltv(address asset) internal view returns (uint256 ltv) { + ( , ltv,,,,,,,, ) = dataProvider.getReserveConfigurationData(asset); + } + +} + +contract FreezeSingleAssetSpellFailures is IntegrationTestsBase { + + FreezeSingleAssetSpell freezeAssetSpell; + + function setUp() public override { + super.setUp(); + freezeAssetSpell = new FreezeSingleAssetSpell(address(freezer), WETH); + } + function test_cannotCallWithoutHat() external { - assertTrue(authority.hat() != address(freezeWethSpell)); + assertTrue(authority.hat() != address(freezeAssetSpell)); assertTrue( !authority.canCall( - address(freezeWethSpell), + address(freezeAssetSpell), address(freezer), freezer.freezeMarket.selector ) ); vm.expectRevert("SparkLendFreezerMom/not-authorized"); - freezeWethSpell.freeze(); + freezeAssetSpell.freeze(); } function test_cannotCallWithoutRoleSetup() external { - _vote(address(freezeWethSpell)); + _vote(address(freezeAssetSpell)); - assertTrue(authority.hat() == address(freezeWethSpell)); + assertTrue(authority.hat() == address(freezeAssetSpell)); assertTrue( authority.canCall( - address(freezeWethSpell), + address(freezeAssetSpell), address(freezer), freezer.freezeMarket.selector ) ); vm.expectRevert(bytes("4")); // CALLER_NOT_RISK_OR_POOL_ADMIN - freezeWethSpell.freeze(); + freezeAssetSpell.freeze(); } - function test_freezeWethSpell() external { - _vote(address(freezeWethSpell)); - + function test_cannotCallTwice() external { vm.prank(SPARK_PROXY); aclManager.addRiskAdmin(address(freezer)); - assertEq(_isFrozen(WETH), false); + _vote(address(freezeAssetSpell)); - deal(WETH, sparkUser, 20 ether); // Deal enough for 2 supplies + assertTrue(authority.hat() == address(freezeAssetSpell)); + assertTrue( + authority.canCall( + address(freezeAssetSpell), + address(freezer), + freezer.freezeMarket.selector + ) + ); - // 1. Check user actions before freeze - // NOTE: For all checks, not checking pool.swapBorrowRateMode() since stable rate - // isn't enabled on any reserve. + vm.startPrank(randomUser); // Demonstrate no ACL in spell + freezeAssetSpell.freeze(); - vm.startPrank(sparkUser); + vm.expectRevert("FreezeSingleAssetSpell/already-executed"); + freezeAssetSpell.freeze(); + } - IERC20(WETH).safeApprove(POOL, type(uint256).max); +} - // User can supply, borrow, repay, and withdraw - pool.supply(WETH, 10 ether, sparkUser, 0); - pool.borrow(WETH, 1 ether, 2, 0, sparkUser); - pool.repay(WETH, 0.5 ether, 2, sparkUser); - pool.withdraw(WETH, 1 ether, sparkUser); +contract FreezeAllAssetsSpellFailures is IntegrationTestsBase { - vm.stopPrank(); + FreezeAllAssetsSpell freezeAllAssetsSpell; - // 2. Freeze market + function setUp() public override { + super.setUp(); + freezeAllAssetsSpell = new FreezeAllAssetsSpell(address(freezer)); + } - vm.prank(randomUser); // Demonstrate no ACL in spell - freezeWethSpell.freeze(); + function test_cannotCallWithoutHat() external { + assertTrue(authority.hat() != address(freezeAllAssetsSpell)); + assertTrue( + !authority.canCall( + address(freezeAllAssetsSpell), + address(freezer), + freezer.freezeAllMarkets.selector + ) + ); - assertEq(_isFrozen(WETH), true); + vm.expectRevert("SparkLendFreezerMom/not-authorized"); + freezeAllAssetsSpell.freeze(); + } - // 3. Check user actions after freeze + function test_cannotCallWithoutRoleSetup() external { + _vote(address(freezeAllAssetsSpell)); - vm.startPrank(sparkUser); + assertTrue(authority.hat() == address(freezeAllAssetsSpell)); + assertTrue( + authority.canCall( + address(freezeAllAssetsSpell), + address(freezer), + freezer.freezeAllMarkets.selector + ) + ); - // User can't supply - vm.expectRevert(bytes("28")); // RESERVE_FROZEN - pool.supply(WETH, 10 ether, sparkUser, 0); + vm.expectRevert(bytes("4")); // CALLER_NOT_RISK_OR_POOL_ADMIN + freezeAllAssetsSpell.freeze(); + } - // User can't borrow - vm.expectRevert(bytes("28")); // RESERVE_FROZEN - pool.borrow(WETH, 1 ether, 2, 0, sparkUser); + function test_cannotCallTwice() external { + vm.prank(SPARK_PROXY); + aclManager.addRiskAdmin(address(freezer)); - // User can still repay and withdraw - pool.repay(WETH, 0.5 ether, 2, sparkUser); - pool.withdraw(WETH, 1 ether, sparkUser); + _vote(address(freezeAllAssetsSpell)); - vm.stopPrank(); + assertTrue(authority.hat() == address(freezeAllAssetsSpell)); + assertTrue( + authority.canCall( + address(freezeAllAssetsSpell), + address(freezer), + freezer.freezeMarket.selector + ) + ); - // 4. Simulate spell after freeze, unfreezing market - vm.prank(SPARK_PROXY); - poolConfig.setReserveFreeze(WETH, false); + vm.startPrank(randomUser); // Demonstrate no ACL in spell + freezeAllAssetsSpell.freeze(); - assertEq(_isFrozen(WETH), false); + vm.expectRevert("FreezeAllAssetsSpell/already-executed"); + freezeAllAssetsSpell.freeze(); + } - // 5. Check user actions after unfreeze +} - vm.startPrank(sparkUser); +contract FreezeSingleAssetSpellTest is IntegrationTestsBase { - // User can supply, borrow, repay, and withdraw - pool.supply(WETH, 10 ether, sparkUser, 0); - pool.borrow(WETH, 1 ether, 2, 0, sparkUser); - pool.repay(WETH, 1 ether, 2, sparkUser); - pool.withdraw(WETH, 1 ether, sparkUser); + using SafeERC20 for IERC20; + + address[] public untestedReserves; + + function setUp() public override { + super.setUp(); + vm.prank(SPARK_PROXY); + aclManager.addRiskAdmin(address(freezer)); } - function _vote(address spell) internal { - uint256 amount = 1_000_000 ether; + function test_freezeAssetSpell_allAssets() external { + address[] memory reserves = pool.getReservesList(); - deal(MKR, mkrWhale, amount); + assertEq(reserves.length, 9); - vm.startPrank(mkrWhale); - IERC20(MKR).approve(AUTHORITY, amount); - authority.lock(amount); + for (uint256 i = 0; i < reserves.length; i++) { + address asset = reserves[i]; - address[] memory slate = new address[](1); - slate[0] = spell; - authority.vote(slate); + // If the asset is frozen on mainnet, skip the test + if (_isFrozen(asset)) { + untestedReserves.push(asset); + continue; + } - vm.roll(block.number + 1); + assertEq(untestedReserves.length, 0); - authority.lift(spell); + uint256 decimals = IERC20(asset).decimals(); - vm.stopPrank(); + uint256 supplyAmount = 1_000 * 10 ** decimals; + uint256 withdrawAmount = 1 * 10 ** decimals; + uint256 borrowAmount = 2 * 10 ** decimals; + uint256 repayAmount = 1 * 10 ** decimals; - assertTrue(authority.hat() == spell); + uint256 snapshot = vm.snapshot(); + + address freezeAssetSpell + = address(new FreezeSingleAssetSpell(address(freezer), asset)); + + // Setup spell, max out supply caps so that they aren't hit during test + _vote(freezeAssetSpell); + + vm.startPrank(SPARK_PROXY); + poolConfig.setSupplyCap(asset, 0); + poolConfig.setBorrowCap(asset, 0); + vm.stopPrank(); + + _checkUserActionsUnfrozen( + asset, + supplyAmount, + withdrawAmount, + borrowAmount, + repayAmount + ); + + assertEq(FreezeSingleAssetSpell(freezeAssetSpell).executed(), false); + + vm.prank(randomUser); // Demonstrate no ACL in spell + FreezeSingleAssetSpell(freezeAssetSpell).freeze(); + + assertEq(FreezeSingleAssetSpell(freezeAssetSpell).executed(), true); + + _checkUserActionsFrozen( + asset, + supplyAmount, + withdrawAmount, + borrowAmount, + repayAmount + ); + + vm.prank(SPARK_PROXY); + poolConfig.setReserveFreeze(asset, false); + + _checkUserActionsUnfrozen( + asset, + supplyAmount, + withdrawAmount, + borrowAmount, + repayAmount + ); + + vm.revertTo(snapshot); + } } - function _isFrozen(address asset) internal view returns (bool isFrozen) { - ( ,,,,,,,,, isFrozen ) = dataProvider.getReserveConfigurationData(asset); +} + +contract FreezeAllAssetsSpellTest is IntegrationTestsBase { + + using SafeERC20 for IERC20; + + address[] public untestedReserves; + + function setUp() public override { + super.setUp(); + vm.prank(SPARK_PROXY); + aclManager.addRiskAdmin(address(freezer)); + } + + function test_freezeAllAssetsSpell() external { + address[] memory reserves = pool.getReservesList(); + + assertEq(reserves.length, 9); + + uint256 supplyAmount = 1_000; + uint256 withdrawAmount = 1; + uint256 borrowAmount = 2; + uint256 repayAmount = 1; + + address freezeAllAssetsSpell = address(new FreezeAllAssetsSpell(address(freezer))); + + // Setup spell + _vote(freezeAllAssetsSpell); + + // Check that protocol is working as expected before spell for each asset + for (uint256 i = 0; i < reserves.length; i++) { + address asset = reserves[i]; + uint256 decimals = IERC20(asset).decimals(); + + // If the asset is already frozen on mainnet, skip the test + if (_isFrozen(asset)) { + untestedReserves.push(asset); + continue; + } + + // Max out supply caps for this asset so that they aren't hit during test + vm.startPrank(SPARK_PROXY); + poolConfig.setSupplyCap(asset, 0); + poolConfig.setBorrowCap(asset, 0); + vm.stopPrank(); + + _checkUserActionsUnfrozen( + asset, + supplyAmount * 10 ** decimals, + withdrawAmount * 10 ** decimals, + borrowAmount * 10 ** decimals, + repayAmount * 10 ** decimals + ); + } + + assertEq(untestedReserves.length, 0); + + assertEq(FreezeAllAssetsSpell(freezeAllAssetsSpell).executed(), false); + + // Freeze all assets in the protocol + vm.prank(randomUser); // Demonstrate no ACL in spell + FreezeAllAssetsSpell(freezeAllAssetsSpell).freeze(); + + assertEq(FreezeAllAssetsSpell(freezeAllAssetsSpell).executed(), true); + + // Check that protocol is working as expected after the freeze spell for all assets + for (uint256 i = 0; i < reserves.length; i++) { + address asset = reserves[i]; + uint256 decimals = IERC20(asset).decimals(); + + // Check all assets, including unfrozen ones from start + _checkUserActionsFrozen( + asset, + supplyAmount * 10 ** decimals, + withdrawAmount * 10 ** decimals, + borrowAmount * 10 ** decimals, + repayAmount * 10 ** decimals + ); + } + + // Undo all freezes, including WBTC and make sure that protocol is back to working + // as expected + for (uint256 i = 0; i < reserves.length; i++) { + address asset = reserves[i]; + uint256 decimals = IERC20(asset).decimals(); + + vm.prank(SPARK_PROXY); + poolConfig.setReserveFreeze(asset, false); + + _checkUserActionsUnfrozen( + asset, + supplyAmount * 10 ** decimals, + withdrawAmount * 10 ** decimals, + borrowAmount * 10 ** decimals, + repayAmount * 10 ** decimals + ); + } } }