diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/comment.yml b/.github/workflows/comment.yml new file mode 100644 index 0000000..2117aea --- /dev/null +++ b/.github/workflows/comment.yml @@ -0,0 +1,13 @@ +name: PR Comment + +on: + workflow_run: + workflows: [Test] + types: + - completed + +jobs: + test: + uses: bgd-labs/github-workflows/.github/workflows/comment.yml@main + secrets: + READ_ONLY_PAT: ${{ secrets.READ_ONLY_PAT }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 762a296..733cb09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,8 @@ -name: CI +name: Test + +concurrency: + group: ${{ github.head_ref || github.run_id }} + cancel-in-progress: true on: push: @@ -9,10 +13,7 @@ env: FOUNDRY_PROFILE: ci jobs: - check: - strategy: - fail-fast: true - + test: name: Foundry project runs-on: ubuntu-latest steps: @@ -20,10 +21,14 @@ jobs: with: submodules: recursive - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + - uses: bgd-labs/action-rpc-env@main + with: + ALCHEMY_API_KEY: ${{ secrets.ALCHEMY_API_KEY }} + + - name: Run Foundry setup + uses: bgd-labs/github-workflows/.github/actions/foundry-setup@main with: - version: nightly + ZKSYNC: "true" - name: Show Forge version run: | @@ -34,12 +39,23 @@ jobs: forge fmt --check id: fmt - - name: Run Forge build - run: | - forge build --sizes - id: build + - name: Run Forge tests + id: test + uses: bgd-labs/github-workflows/.github/actions/foundry-test@main + + - name: Run ZK tests + id: zktest + uses: bgd-labs/github-workflows/.github/actions/foundry-test@main + with: + ZKSYNC: true + ROOT_DIR: "zksync" - name: Run Forge tests + uses: bgd-labs/github-workflows/.github/actions/comment-artifact@main + + # we let failing tests pass so we can log them in the comment, still we want the ci to fail + - name: Post test + if: ${{ steps.test.outputs.testStatus != 0 || steps.zktest.outputs.testStatus != 0 }} run: | - forge test -vvv - id: test + echo "tests failed" + exit 1 diff --git a/.gitignore b/.gitignore index 85198aa..14e0bf6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ docs/ # Dotenv file .env + +.vscode \ No newline at end of file diff --git a/.gitmodules b/.gitmodules index f7316a1..eec435c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "lib/aave-helpers"] path = lib/aave-helpers url = https://github.com/bgd-labs/aave-helpers +[submodule "lib/aave-v3-origin"] + path = lib/aave-v3-origin + url = https://github.com/bgd-labs/aave-v3-origin + branch = v3.3.0 diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..0876901 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,16 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "languages": { + "Solidity": { + "formatter": { + "external": { + "command": "forge", + "arguments": ["fmt", "--raw", "-"] + } + } + } + } +} diff --git a/foundry.toml b/foundry.toml index c8425b0..31a3b98 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,7 @@ [profile.default] src = "src" out = "out" +test = "tests" libs = ["lib"] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/aave-helpers b/lib/aave-helpers index 8d9947c..010fc1c 160000 --- a/lib/aave-helpers +++ b/lib/aave-helpers @@ -1 +1 @@ -Subproject commit 8d9947cc2cf992a5606c7cba6e990d30e876d735 +Subproject commit 010fc1c508e69d7c43b4df73bd0d6957b62ed1e1 diff --git a/lib/aave-v3-origin b/lib/aave-v3-origin new file mode 160000 index 0000000..21c3014 --- /dev/null +++ b/lib/aave-v3-origin @@ -0,0 +1 @@ +Subproject commit 21c30148d1484ddec57f5d223f530179b103cae6 diff --git a/remappings.txt b/remappings.txt index 4fec9b8..275bef8 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,7 +1,7 @@ aave-address-book/=lib/aave-helpers/lib/aave-address-book/src/ aave-helpers/=lib/aave-helpers/ -aave-v3-origin-tests/=lib/aave-helpers/lib/aave-address-book/lib/aave-v3-origin/tests/ -aave-v3-origin/=lib/aave-helpers/lib/aave-address-book/lib/aave-v3-origin/src +aave-v3-origin-tests/=lib/aave-v3-origin/tests/ +aave-v3-origin/=lib/aave-v3-origin/src ds-test/=lib/aave-helpers/lib/solidity-utils/lib/openzeppelin-contracts-upgradeable/lib/forge-std/lib/ds-test/src/ erc4626-tests/=lib/aave-helpers/lib/solidity-utils/lib/openzeppelin-contracts-upgradeable/lib/erc4626-tests/ forge-std/=lib/aave-helpers/lib/aave-address-book/lib/aave-v3-origin/lib/forge-std/src/ diff --git a/snapshots/BatchRepayBadDebtSteward.json b/snapshots/BatchRepayBadDebtSteward.json new file mode 100644 index 0000000..4bf2f91 --- /dev/null +++ b/snapshots/BatchRepayBadDebtSteward.json @@ -0,0 +1,35 @@ +{ + "function batchLiquidate: with 0 users": "250226", + "function batchLiquidate: with 1 user": "580464", + "function batchLiquidate: with 2 users": "907296", + "function batchLiquidate: with 3 users": "1125027", + "function batchLiquidate: with 4 users": "1342771", + "function batchLiquidate: with 5 users": "1666388", + "function batchLiquidate: with 6 users": "1884131", + "function batchLiquidateWithMaxCap: with 0 users": "271158", + "function batchLiquidateWithMaxCap: with 1 user": "572541", + "function batchLiquidateWithMaxCap: with 2 users": "893497", + "function batchLiquidateWithMaxCap: with 3 users": "1105352", + "function batchLiquidateWithMaxCap: with 4 users": "1317219", + "function batchLiquidateWithMaxCap: with 5 users": "1634961", + "function batchLiquidateWithMaxCap: with 6 users": "1846828", + "function batchRepayBadDebt: with 0 users": "70072", + "function batchRepayBadDebt: with 1 user": "222244", + "function batchRepayBadDebt: with 2 users": "284640", + "function batchRepayBadDebt: with 3 users": "347036", + "function batchRepayBadDebt: with 4 users": "409431", + "function batchRepayBadDebt: with 5 users": "471827", + "function batchRepayBadDebt: with 6 users": "534223", + "function getBadDebtAmount: with 0 users": "12852", + "function getBadDebtAmount: with 1 user": "38658", + "function getBadDebtAmount: with 2 users": "51412", + "function getBadDebtAmount: with 4 users": "76922", + "function getBadDebtAmount: with 5 users": "89677", + "function getBadDebtAmount: with 6 users": "102432", + "function getDebtAmount: with 0 users": "12875", + "function getDebtAmount: with 1 user": "34341", + "function getDebtAmount: with 2 users": "51412", + "function getDebtAmount: with 4 users": "59584", + "function getDebtAmount: with 5 users": "67998", + "function getDebtAmount: with 6 users": "76413" +} \ No newline at end of file diff --git a/src/maintenance/BatchRepayBadDebtSteward.sol b/src/maintenance/BatchRepayBadDebtSteward.sol new file mode 100644 index 0000000..6f26989 --- /dev/null +++ b/src/maintenance/BatchRepayBadDebtSteward.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IPool, DataTypes} from "aave-address-book/AaveV3.sol"; + +import {UserConfiguration} from "aave-v3-origin/contracts/protocol/libraries/configuration/UserConfiguration.sol"; +import {ICollector, IERC20 as IERC20Col} from "aave-v3-origin/contracts/treasury/ICollector.sol"; + +import {IRescuableBase} from "solidity-utils/contracts/utils/interfaces/IRescuableBase.sol"; +import {RescuableBase} from "solidity-utils/contracts/utils/RescuableBase.sol"; + +import {AccessControl} from "openzeppelin-contracts/contracts/access/AccessControl.sol"; +import {Multicall} from "openzeppelin-contracts/contracts/utils/Multicall.sol"; + +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +import {IBatchRepayBadDebtSteward} from "./interfaces/IBatchRepayBadDebtSteward.sol"; + +/** + * @title BatchRepayBadDebtSteward + * @author BGD Labs + * @notice This contract helps with the liquidation or repayment of bad debt for + * a list of users within the Aave V3 protocol. + * All funds for those operations are pulled from the Aave Collector. + * + * Repayment of bad debt is permitted only for users without any collateral. + * If a user has collateral, a liquidation should be performed instead. + * + * The contract inherits from `Multicall`. Using the `multicall` function from this contract\ + * multiple liquidation or repayment operations could be bundled into a single transaction. + * + * -- Security Considerations -- + * + * The contract is not upgradable and should never hold any funds. + * If for any reason funds are stuck on the contract, they are rescuable to the Aave Collector (the recipient address cannot be changed). + * This can be done by calling the `rescueToken` or `rescueEth` functions that are open to everyone. + * + * The contract can only: + * - repay a pure bad debt (the debt without any collateral) + * - liquidate users that are already eligible for a liquidation + * + * Therefore even when there is a certain level of trust given to the permissioned entity, there is no + * possibility for any actor to enrich themselves. + * + * --- Access control + * Upon creation, a DAO-permitted entity is configured as the AccessControl default admin. + * For operational flexibility, this entity can give permissions to other addresses (`CLEANUP` role). + */ +contract BatchRepayBadDebtSteward is IBatchRepayBadDebtSteward, RescuableBase, Multicall, AccessControl { + using SafeERC20 for IERC20; + using UserConfiguration for DataTypes.UserConfigurationMap; + + /* PUBLIC GLOBAL VARIABLES */ + + /// @inheritdoc IBatchRepayBadDebtSteward + bytes32 public constant CLEANUP = keccak256("CLEANUP"); + + /// @inheritdoc IBatchRepayBadDebtSteward + IPool public immutable override POOL; + + /// @inheritdoc IBatchRepayBadDebtSteward + address public immutable override COLLECTOR; + + /* CONSTRUCTOR */ + + constructor(address _pool, address _collector, address admin, address _guardian) { + if (_pool == address(0) || _collector == address(0)) { + revert ZeroAddress(); + } + + POOL = IPool(_pool); + COLLECTOR = _collector; + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + _grantRole(CLEANUP, _guardian); + } + + /* EXTERNAL FUNCTIONS */ + + /// @inheritdoc IBatchRepayBadDebtSteward + function batchLiquidate(address debtAsset, address collateralAsset, address[] memory users) external override { + (uint256 maxDebtAmount,) = getDebtAmount(debtAsset, users); + + batchLiquidateWithMaxCap(debtAsset, collateralAsset, users, maxDebtAmount); + } + + /// @inheritdoc IBatchRepayBadDebtSteward + function batchRepayBadDebt(address asset, address[] memory users) external override onlyRole(CLEANUP) { + (uint256 totalDebtAmount, uint256[] memory debtAmounts) = getBadDebtAmount(asset, users); + + ICollector(COLLECTOR).transfer(IERC20Col(asset), address(this), totalDebtAmount); + IERC20(asset).forceApprove(address(POOL), totalDebtAmount); + + for (uint256 i = 0; i < users.length; i++) { + POOL.repay({asset: asset, amount: debtAmounts[i], interestRateMode: 2, onBehalfOf: users[i]}); + } + + uint256 balanceLeft = IERC20(asset).balanceOf(address(this)); + if (balanceLeft != 0) IERC20(asset).transfer(COLLECTOR, balanceLeft); + } + + /// @inheritdoc IBatchRepayBadDebtSteward + function rescueToken(address token) external override { + _emergencyTokenTransfer(token, COLLECTOR, type(uint256).max); + } + + /// @inheritdoc IBatchRepayBadDebtSteward + function rescueEth() external override { + _emergencyEtherTransfer(COLLECTOR, address(this).balance); + } + + /* PUBLIC FUNCTIONS */ + + /// @inheritdoc IBatchRepayBadDebtSteward + function batchLiquidateWithMaxCap( + address debtAsset, + address collateralAsset, + address[] memory users, + uint256 maxDebtTokenAmount + ) public override onlyRole(CLEANUP) { + ICollector(COLLECTOR).transfer(IERC20Col(debtAsset), address(this), maxDebtTokenAmount); + IERC20(debtAsset).forceApprove(address(POOL), maxDebtTokenAmount); + + for (uint256 i = 0; i < users.length; i++) { + POOL.liquidationCall({ + collateralAsset: collateralAsset, + debtAsset: debtAsset, + user: users[i], + debtToCover: type(uint256).max, + receiveAToken: true + }); + } + + // transfer back surplus + uint256 balanceAfter = IERC20(debtAsset).balanceOf(address(this)); + if (balanceAfter != 0) { + IERC20(debtAsset).safeTransfer(COLLECTOR, balanceAfter); + } + + // transfer back liquidated assets + address collateralAToken = POOL.getReserveAToken(collateralAsset); + uint256 collateralATokenBalance = IERC20(collateralAToken).balanceOf(address(this)); + IERC20(collateralAToken).safeTransfer(COLLECTOR, collateralATokenBalance); + } + + /* PUBLIC VIEW FUNCTIONS */ + + /// @inheritdoc IBatchRepayBadDebtSteward + function getDebtAmount(address asset, address[] memory users) + public + view + override + returns (uint256, uint256[] memory) + { + return _getUsersDebtAmounts({asset: asset, users: users, usersCanHaveCollateral: true}); + } + + /// @inheritdoc IBatchRepayBadDebtSteward + function getBadDebtAmount(address asset, address[] memory users) + public + view + override + returns (uint256, uint256[] memory) + { + return _getUsersDebtAmounts({asset: asset, users: users, usersCanHaveCollateral: false}); + } + + /// @inheritdoc IRescuableBase + function maxRescue(address erc20Token) public view virtual override(IRescuableBase, RescuableBase) returns (uint256) { + return IERC20(erc20Token).balanceOf(address(this)); + } + + /* PRIVATE VIEW FUNCTIONS */ + + function _getUsersDebtAmounts(address asset, address[] memory users, bool usersCanHaveCollateral) + private + view + returns (uint256, uint256[] memory) + { + uint256 length = users.length; + + uint256 totalDebtAmount; + uint256[] memory debtAmounts = new uint256[](length); + + address variableDebtTokenAddress = POOL.getReserveVariableDebtToken(asset); + + address user; + for (uint256 i = 0; i < length; i++) { + user = users[i]; + + if (!usersCanHaveCollateral) { + DataTypes.UserConfigurationMap memory userConfiguration = POOL.getUserConfiguration(user); + + if (userConfiguration.isUsingAsCollateralAny()) { + revert UserHasSomeCollateral(user); + } + } + + totalDebtAmount += debtAmounts[i] = IERC20(variableDebtTokenAddress).balanceOf(user); + } + + return (totalDebtAmount, debtAmounts); + } +} diff --git a/src/maintenance/interfaces/IBatchRepayBadDebtSteward.sol b/src/maintenance/interfaces/IBatchRepayBadDebtSteward.sol new file mode 100644 index 0000000..720a539 --- /dev/null +++ b/src/maintenance/interfaces/IBatchRepayBadDebtSteward.sol @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IPool} from "aave-address-book/AaveV3.sol"; + +import {IRescuableBase} from "solidity-utils/contracts/utils/interfaces/IRescuableBase.sol"; + +import {IAccessControl} from "openzeppelin-contracts/contracts/access/IAccessControl.sol"; + +interface IBatchRepayBadDebtSteward is IRescuableBase, IAccessControl { + /* ERRORS */ + + /// @notice Thrown when a user has some collateral. To repay a bad debt, + /// a user should have no collateral. + /// @param user The address of the user + error UserHasSomeCollateral(address user); + + /// @notice Thrown when passed address is zero + error ZeroAddress(); + + /* GLOBAL VARIABLES */ + + /// @notice The role that allows to call the `batchLiquidate`, `batchLiquidateWithMaxCap` and `batchRepayBadDebt` functions + function CLEANUP() external view returns (bytes32); + + /// @notice The Aave pool + function POOL() external view returns (IPool); + + /// @notice The Aave collector + function COLLECTOR() external view returns (address); + + /* EXTERNAL FUNCTIONS */ + + /// @notice Liquidates all the users + /// @param debtAsset The address of the debt asset + /// @param collateralAsset The address of the collateral asset that will be liquidated + /// @param users The addresses of the users to liquidate + /// @dev This amount is pulled from the Aave collector. The contract sends + /// any surplus back to the collector. + function batchLiquidate(address debtAsset, address collateralAsset, address[] memory users) external; + + /// @notice Liquidates all the users with a max debt amount to be liquidated + /// @param debtAsset The address of the debt asset + /// @param collateralAsset The address of the collateral asset that will be liquidated + /// @param users The addresses of the users to liquidate + /// @param maxDebtTokenAmount The maximum amount of debt tokens to be liquidated + /// @dev This amount is pulled from the Aave collector. The contract sends + /// any surplus back to the collector. + function batchLiquidateWithMaxCap( + address debtAsset, + address collateralAsset, + address[] memory users, + uint256 maxDebtTokenAmount + ) external; + + /// @notice Repays all the bad debt of users + /// @dev Will revert if the user has a collateral or no debt. + /// Repayed amount is pulled from the Aave collector. The contract sends + /// any surplus back to the collector. + /// @param asset The address of an asset to repay + /// @param users The addresses of users to repay + function batchRepayBadDebt(address asset, address[] calldata users) external; + + /// @notice Rescues the tokens + /// @param token The address of the token to rescue + function rescueToken(address token) external; + + /// @notice Rescues the ETH + function rescueEth() external; + + /* EXTERNAL VIEW FUNCTIONS */ + + /// @notice Returns the total debt amount and the debt amounts of users + /// @param asset The address of the asset to repay + /// @param users The addresses of the users to repay + /// @return totalDebtAmount The total debt amount + /// @return debtAmounts The debt amounts of the users + function getDebtAmount(address asset, address[] memory users) + external + view + returns (uint256 totalDebtAmount, uint256[] memory debtAmounts); + + /// @notice Returns the total bad debt amount and the debt amounts of users + /// @dev Will revert if the user has a collateral or no debt. + /// @param asset The address of the asset to repay + /// @param users The addresses of the users to repay + /// @return totalDebtAmount The total debt amount + /// @return debtAmounts The debt amounts of the users + function getBadDebtAmount(address asset, address[] memory users) + external + view + returns (uint256 totalDebtAmount, uint256[] memory debtAmounts); +} diff --git a/tests/gas/maintenance/BatchRepayBadDebtSteward.gas.t.sol b/tests/gas/maintenance/BatchRepayBadDebtSteward.gas.t.sol new file mode 100644 index 0000000..9045545 --- /dev/null +++ b/tests/gas/maintenance/BatchRepayBadDebtSteward.gas.t.sol @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; + +import {IPool, IAToken, DataTypes} from "aave-address-book/AaveV3.sol"; +import {AaveV3Avalanche, AaveV3AvalancheAssets} from "aave-address-book/AaveV3Avalanche.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +import {IBatchRepayBadDebtSteward} from "../../../src/maintenance/interfaces/IBatchRepayBadDebtSteward.sol"; +import {BatchRepayBadDebtSteward} from "../../../src/maintenance/BatchRepayBadDebtSteward.sol"; + +import {BatchRepayBadDebtStewardBaseTest} from "../../maintenance/BatchRepayBadDebtSteward.t.sol"; + +contract BatchRepayBadDebtStewardGasTest is BatchRepayBadDebtStewardBaseTest { + function test_getBadDebtAmount_zero_users() public { + _callGetBadDebtAmountWithNumberOfUsers(0); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getBadDebtAmount: with 0 users"); + } + + function test_getBadDebtAmount_one_user() public { + _callGetBadDebtAmountWithNumberOfUsers(1); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getBadDebtAmount: with 1 user"); + } + + function test_getBadDebtAmount_two_users() public { + _callGetBadDebtAmountWithNumberOfUsers(2); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getBadDebtAmount: with 2 users"); + } + + function test_getBadDebtAmount_three_users() public { + _callGetBadDebtAmountWithNumberOfUsers(3); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getBadDebtAmount: with 2 users"); + } + + function test_getBadDebtAmount_four_users() public { + _callGetBadDebtAmountWithNumberOfUsers(4); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getBadDebtAmount: with 4 users"); + } + + function test_getBadDebtAmount_five_users() public { + _callGetBadDebtAmountWithNumberOfUsers(5); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getBadDebtAmount: with 5 users"); + } + + function test_getBadDebtAmount_six_users() public { + _callGetBadDebtAmountWithNumberOfUsers(6); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getBadDebtAmount: with 6 users"); + } + + function test_getDebtAmount_zero_users() public { + _callGetDebtAmountWithNumberOfUsers(0); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getDebtAmount: with 0 users"); + } + + function test_getDebtAmount_one_user() public { + _callGetDebtAmountWithNumberOfUsers(1); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getDebtAmount: with 1 user"); + } + + function test_getDebtAmount_two_users() public { + _callGetBadDebtAmountWithNumberOfUsers(2); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getDebtAmount: with 2 users"); + } + + function test_getDebtAmount_three_users() public { + _callGetDebtAmountWithNumberOfUsers(3); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getDebtAmount: with 2 users"); + } + + function test_getDebtAmount_four_users() public { + _callGetDebtAmountWithNumberOfUsers(4); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getDebtAmount: with 4 users"); + } + + function test_getDebtAmount_five_users() public { + _callGetDebtAmountWithNumberOfUsers(5); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getDebtAmount: with 5 users"); + } + + function test_getDebtAmount_six_users() public { + _callGetDebtAmountWithNumberOfUsers(6); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function getDebtAmount: with 6 users"); + } + + function test_batchRepayBadDebt_zero_users() public { + _callBatchRepayBadDebtWithNumberOfUsers(0); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchRepayBadDebt: with 0 users"); + } + + function test_batchRepayBadDebt_one_user() public { + _callBatchRepayBadDebtWithNumberOfUsers(1); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchRepayBadDebt: with 1 user"); + } + + function test_batchRepayBadDebt_two_users() public { + _callBatchRepayBadDebtWithNumberOfUsers(2); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchRepayBadDebt: with 2 users"); + } + + function test_batchRepayBadDebt_three_users() public { + _callBatchRepayBadDebtWithNumberOfUsers(3); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchRepayBadDebt: with 3 users"); + } + + function test_batchRepayBadDebt_four_users() public { + _callBatchRepayBadDebtWithNumberOfUsers(4); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchRepayBadDebt: with 4 users"); + } + + function test_batchRepayBadDebt_five_users() public { + _callBatchRepayBadDebtWithNumberOfUsers(5); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchRepayBadDebt: with 5 users"); + } + + function test_batchRepayBadDebt_six_users() public { + _callBatchRepayBadDebtWithNumberOfUsers(6); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchRepayBadDebt: with 6 users"); + } + + function test_batchLiquidate_zero_users() public { + _callBatchLiquidateWithNumberOfUsers(0); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidate: with 0 users"); + } + + function test_batchLiquidate_one_user() public { + _callBatchLiquidateWithNumberOfUsers(1); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidate: with 1 user"); + } + + function test_batchLiquidate_two_users() public { + _callBatchLiquidateWithNumberOfUsers(2); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidate: with 2 users"); + } + + function test_batchLiquidate_three_users() public { + _callBatchLiquidateWithNumberOfUsers(3); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidate: with 3 users"); + } + + function test_batchLiquidate_four_users() public { + _callBatchLiquidateWithNumberOfUsers(4); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidate: with 4 users"); + } + + function test_batchLiquidate_five_users() public { + _callBatchLiquidateWithNumberOfUsers(5); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidate: with 5 users"); + } + + function test_batchLiquidate_six_users() public { + _callBatchLiquidateWithNumberOfUsers(6); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidate: with 6 users"); + } + + function test_batchLiquidateWithMaxCap_zero_users() public { + _callBatchLiquidateWithMaxCapWithNumberOfUsers(0); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidateWithMaxCap: with 0 users"); + } + + function test_batchLiquidateWithMaxCap_one_user() public { + _callBatchLiquidateWithMaxCapWithNumberOfUsers(1); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidateWithMaxCap: with 1 user"); + } + + function test_batchLiquidateWithMaxCap_two_users() public { + _callBatchLiquidateWithMaxCapWithNumberOfUsers(2); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidateWithMaxCap: with 2 users"); + } + + function test_batchLiquidateWithMaxCap_three_users() public { + _callBatchLiquidateWithMaxCapWithNumberOfUsers(3); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidateWithMaxCap: with 3 users"); + } + + function test_batchLiquidateWithMaxCap_four_users() public { + _callBatchLiquidateWithMaxCapWithNumberOfUsers(4); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidateWithMaxCap: with 4 users"); + } + + function test_batchLiquidateWithMaxCap_five_users() public { + _callBatchLiquidateWithMaxCapWithNumberOfUsers(5); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidateWithMaxCap: with 5 users"); + } + + function test_batchLiquidateWithMaxCap_six_users() public { + _callBatchLiquidateWithMaxCapWithNumberOfUsers(6); + + vm.snapshotGasLastCall("BatchRepayBadDebtSteward", "function batchLiquidateWithMaxCap: with 6 users"); + } + + function _callGetBadDebtAmountWithNumberOfUsers(uint256 userAmount) private view { + address[] memory users = new address[](userAmount); + for (uint256 i = 0; i < userAmount; ++i) { + users[i] = usersWithBadDebt[i]; + } + + steward.getBadDebtAmount(assetUnderlying, users); + } + + function _callGetDebtAmountWithNumberOfUsers(uint256 userAmount) private view { + address[] memory users = new address[](userAmount); + for (uint256 i = 0; i < userAmount; ++i) { + users[i] = usersEligibleForLiquidations[i]; + } + + steward.getDebtAmount(assetUnderlying, users); + } + + function _callBatchRepayBadDebtWithNumberOfUsers(uint256 userAmount) private { + address[] memory users = new address[](userAmount); + for (uint256 i = 0; i < userAmount; ++i) { + users[i] = usersWithBadDebt[i]; + } + + uint256 mintAmount = 1_000_000e18; + deal(assetUnderlying, collector, mintAmount); + + vm.prank(guardian); + steward.batchRepayBadDebt(assetUnderlying, users); + } + + function _callBatchLiquidateWithNumberOfUsers(uint256 userAmount) private { + address[] memory users = new address[](userAmount); + for (uint256 i = 0; i < userAmount; ++i) { + users[i] = usersEligibleForLiquidations[i]; + } + + uint256 mintAmount = 1_000_000e18; + deal(assetUnderlying, collector, mintAmount); + + vm.prank(guardian); + steward.batchLiquidate(assetUnderlying, collateralEligibleForLiquidations, users); + } + + function _callBatchLiquidateWithMaxCapWithNumberOfUsers(uint256 userAmount) private { + address[] memory users = new address[](userAmount); + for (uint256 i = 0; i < userAmount; ++i) { + users[i] = usersEligibleForLiquidations[i]; + } + + uint256 mintAmount = 1_000_000e18; + deal(assetUnderlying, collector, mintAmount); + + vm.prank(guardian); + steward.batchLiquidateWithMaxCap(assetUnderlying, collateralEligibleForLiquidations, users, totalDebtToLiquidate); + } +} diff --git a/tests/maintenance/BatchRepayBadDebtSteward.t.sol b/tests/maintenance/BatchRepayBadDebtSteward.t.sol new file mode 100644 index 0000000..8d01aab --- /dev/null +++ b/tests/maintenance/BatchRepayBadDebtSteward.t.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {Test} from "forge-std/Test.sol"; + +import {GovV3Helpers} from "aave-helpers/src/GovV3Helpers.sol"; + +import {IPool, IAToken, DataTypes} from "aave-address-book/AaveV3.sol"; +import {AaveV3Avalanche, AaveV3AvalancheAssets} from "aave-address-book/AaveV3Avalanche.sol"; + +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; +import {IAccessControl} from "openzeppelin-contracts/contracts/access/IAccessControl.sol"; + +import {IBatchRepayBadDebtSteward} from "../../src/maintenance/interfaces/IBatchRepayBadDebtSteward.sol"; +import {BatchRepayBadDebtSteward} from "../../src/maintenance/BatchRepayBadDebtSteward.sol"; + +contract BatchRepayBadDebtStewardBaseTest is Test { + BatchRepayBadDebtSteward public steward; + + address public assetUnderlying = AaveV3AvalancheAssets.BTCb_UNDERLYING; + address public assetAToken = AaveV3AvalancheAssets.BTCb_A_TOKEN; + address public assetDebtToken = AaveV3AvalancheAssets.BTCb_V_TOKEN; + + address[] public usersWithBadDebt = [ + 0x233bA87Cb5180fcCf86ac8Dd19CE07d09732DD39, + 0x20Bb103F0688D434d80336010b7F5feA22B8480E, + 0x9D4Ad74b46675998321101b2a49a4D66ac7509Ea, + 0xCb87dD67Fe09121abd335044a1e0d6bf44C915FB, + 0xd3D97E2cbF1fc09528830F58BCA6DbC4cc74bA14, + 0xd80aC14a778f4A708dcda53D9B03e1A66B551872 + ]; + uint256 totalBadDebt; + uint256[] public usersBadDebtAmounts; + + address[] public usersEligibleForLiquidations = [ + 0x6821Aceb02Bd2a64d0900cBf193A543aB41d397e, + 0x5822fb7f7eC70258abe638a6E8637FEd206311F7, + 0x35cBb2525e442488Ed51cE524b4000D4d0d01acC, + 0xE32655B320e77a7103e0837282106677F2C28dCb, + 0x042F53D7250c49f9DCCc54b1A1CFafB16d8A0633, + 0x4dAad5b1B9A4c60F1Bd18A0890AF6794A2242a7E + ]; + address collateralEligibleForLiquidations = AaveV3AvalancheAssets.USDC_UNDERLYING; + uint256[] public usersEligibleForLiquidationsDebtAmounts; + uint256 public totalDebtToLiquidate; + + address[] public usersWithGoodDebt = [ + 0x37592B2927c40C4B0054614D7FF6065D6B752a8C, + 0x5fb6Fd8125023c7c4f8FFC8b636a658ad8C1b4eF, + 0x20ed4EF75cE723A580D250005a00DfC86F03112a, + 0x1C943ffC835bFcCd2a435e7bE3507eF821aC06e2, + 0x87F8A29eB20D5DB98dE07301345b48E1e9DDa569, + 0x97F0d7f9d9e7Fe4BFBAbc04BE336dc058873A0E8 + ]; + + address public guardian = address(0x101); + + address public admin = address(0x102); + + address public collector = address(AaveV3Avalanche.COLLECTOR); + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("avalanche"), 56921378); // https://snowscan.xyz/block/56768474 + steward = new BatchRepayBadDebtSteward(address(AaveV3Avalanche.POOL), collector, admin, guardian); + + // collector upgrade + GovV3Helpers.executePayload(vm, 65); + // v3.3 pool upgrade + GovV3Helpers.executePayload(vm, 67); + vm.prank(AaveV3Avalanche.ACL_ADMIN); + IAccessControl(address(collector)).grantRole("FUNDS_ADMIN", address(steward)); + + vm.prank(collector); + IERC20(assetUnderlying).approve(address(steward), 100_000_000e18); + + vm.label(address(AaveV3Avalanche.POOL), "Pool"); + vm.label(address(steward), "BatchRepayBadDebtSteward"); + vm.label(guardian, "Guardian"); + vm.label(admin, "Admin"); + vm.label(collector, "Collector"); + vm.label(assetUnderlying, "AssetUnderlying"); + vm.label(assetDebtToken, "AssetDebtToken"); + + for (uint256 i = 0; i < usersWithBadDebt.length; i++) { + uint256 currentDebtAmount = IERC20(assetDebtToken).balanceOf(usersWithBadDebt[i]); + + assertNotEq(currentDebtAmount, 0); + + totalBadDebt += currentDebtAmount; + usersBadDebtAmounts.push(currentDebtAmount); + } + + DataTypes.ReserveDataLegacy memory collateralReserveData = + AaveV3Avalanche.POOL.getReserveData(collateralEligibleForLiquidations); + for (uint256 i = 0; i < usersEligibleForLiquidations.length; i++) { + uint256 currentDebtAmount = IERC20(assetDebtToken).balanceOf(usersEligibleForLiquidations[i]); + + assertNotEq(currentDebtAmount, 0); + + totalDebtToLiquidate += currentDebtAmount; + usersEligibleForLiquidationsDebtAmounts.push(currentDebtAmount); + + uint256 collateralBalance = IERC20(collateralReserveData.aTokenAddress).balanceOf(usersEligibleForLiquidations[i]); + + assertNotEq(collateralBalance, 0); + } + } +} + +contract BatchRepayBadDebtStewardTest is BatchRepayBadDebtStewardBaseTest { + function test_batchRepayBadDebt() public { + deal(assetUnderlying, collector, totalBadDebt); + + uint256 collectorBalanceBefore = IERC20(assetUnderlying).balanceOf(collector); + + vm.prank(guardian); + steward.batchRepayBadDebt(assetUnderlying, usersWithBadDebt); + + uint256 collectorBalanceAfter = IERC20(assetUnderlying).balanceOf(collector); + + assertEq(collectorBalanceBefore - collectorBalanceAfter, totalBadDebt); + + for (uint256 i = 0; i < usersWithBadDebt.length; i++) { + assertEq(IERC20(assetDebtToken).balanceOf(usersWithBadDebt[i]), 0); + } + } + + function test_reverts_batchRepayBadDebt_caller_not_cleaner(address caller) public { + vm.assume(caller != guardian); + + vm.expectRevert( + abi.encodePacked( + IAccessControl.AccessControlUnauthorizedAccount.selector, uint256(uint160(caller)), steward.CLEANUP() + ) + ); + + vm.prank(caller); + steward.batchRepayBadDebt(assetUnderlying, usersWithBadDebt); + } + + function test_batchLiquidate() public { + uint256 collectorBalanceBefore = IERC20(assetUnderlying).balanceOf(collector); + uint256 stewardBalanceBefore = IERC20(assetUnderlying).balanceOf(address(steward)); + + DataTypes.ReserveDataLegacy memory collateralReserveData = + AaveV3Avalanche.POOL.getReserveData(collateralEligibleForLiquidations); + uint256 collectorCollateralBalanceBefore = IERC20(collateralReserveData.aTokenAddress).balanceOf(collector); + uint256 stewardCollateralBalanceBefore = IERC20(collateralReserveData.aTokenAddress).balanceOf(address(steward)); + + vm.prank(guardian); + steward.batchLiquidate(assetUnderlying, collateralEligibleForLiquidations, usersEligibleForLiquidations); + + uint256 collectorBalanceAfter = IERC20(assetUnderlying).balanceOf(collector); + uint256 stewardBalanceAfter = IERC20(assetUnderlying).balanceOf(address(steward)); + + uint256 collectorCollateralBalanceAfter = IERC20(collateralReserveData.aTokenAddress).balanceOf(collector); + uint256 stewardCollateralBalanceAfter = IERC20(collateralReserveData.aTokenAddress).balanceOf(address(steward)); + + assertTrue(collectorBalanceBefore >= collectorBalanceAfter); + assertTrue(collectorBalanceBefore - collectorBalanceAfter <= totalDebtToLiquidate); + + assertTrue(collectorCollateralBalanceAfter >= collectorCollateralBalanceBefore); + + assertEq(stewardBalanceBefore, stewardBalanceAfter); + assertEq(stewardCollateralBalanceBefore, stewardCollateralBalanceAfter); + + for (uint256 i = 0; i < usersEligibleForLiquidations.length; i++) { + uint256 currentDebtAmount = IERC20(assetDebtToken).balanceOf(usersEligibleForLiquidations[i]); + + uint256 collateralBalance = IERC20(collateralReserveData.aTokenAddress).balanceOf(usersEligibleForLiquidations[i]); + + assertTrue(currentDebtAmount == 0 || collateralBalance == 0); + } + } + + function test_batchLiquidateWithMaxCap() public { + uint256 passedAmount = totalDebtToLiquidate - 100; + + uint256 collectorBalanceBefore = IERC20(assetUnderlying).balanceOf(collector); + + vm.prank(guardian); + steward.batchLiquidateWithMaxCap( + assetUnderlying, collateralEligibleForLiquidations, usersEligibleForLiquidations, passedAmount + ); + + uint256 collectorBalanceAfter = IERC20(assetUnderlying).balanceOf(collector); + + assertTrue(collectorBalanceBefore >= collectorBalanceAfter, "EXPECTED_BALANCE_DECREASE"); + assertTrue(collectorBalanceBefore - collectorBalanceAfter <= passedAmount, "OVERSPENT"); + + DataTypes.ReserveDataLegacy memory collateralReserveData = + AaveV3Avalanche.POOL.getReserveData(collateralEligibleForLiquidations); + + for (uint256 i = 0; i < usersEligibleForLiquidations.length; i++) { + uint256 currentDebtAmount = IERC20(assetDebtToken).balanceOf(usersEligibleForLiquidations[i]); + + uint256 collateralBalance = IERC20(collateralReserveData.aTokenAddress).balanceOf(usersEligibleForLiquidations[i]); + + assertTrue(currentDebtAmount == 0 || collateralBalance == 0); + } + } + + function test_reverts_batchLiquidate_caller_not_cleaner(address caller) public { + vm.assume(caller != guardian); + + vm.expectRevert( + abi.encodePacked( + IAccessControl.AccessControlUnauthorizedAccount.selector, uint256(uint160(caller)), steward.CLEANUP() + ) + ); + + vm.prank(caller); + steward.batchLiquidate(assetUnderlying, collateralEligibleForLiquidations, usersEligibleForLiquidations); + } + + function test_reverts_batchLiquidateWithMaxCap_caller_not_cleaner(address caller) public { + vm.assume(caller != guardian); + + vm.expectRevert( + abi.encodePacked( + IAccessControl.AccessControlUnauthorizedAccount.selector, uint256(uint160(caller)), steward.CLEANUP() + ) + ); + + vm.prank(caller); + steward.batchLiquidateWithMaxCap( + assetUnderlying, collateralEligibleForLiquidations, usersEligibleForLiquidations, 1 + ); + } + + function test_rescueToken() public { + uint256 mintAmount = 1_000_000e18; + deal(assetUnderlying, address(steward), mintAmount); + + uint256 collectorBalanceBefore = IERC20(assetUnderlying).balanceOf(collector); + + steward.rescueToken(assetUnderlying); + + uint256 collectorBalanceAfter = IERC20(assetUnderlying).balanceOf(collector); + + assertEq(collectorBalanceAfter - collectorBalanceBefore, mintAmount); + assertEq(IERC20(assetUnderlying).balanceOf(address(steward)), 0); + } + + receive() external payable {} + + function test_rescueEth() public { + uint256 mintAmount = 1_000_000e18; + deal(address(steward), mintAmount); + + uint256 collectorBalanceBefore = collector.balance; + + steward.rescueEth(); + + uint256 collectorBalanceAfter = collector.balance; + + assertEq(collectorBalanceAfter - collectorBalanceBefore, mintAmount); + assertEq(address(steward).balance, 0); + } + + function test_getDebtAmount() public view { + (uint256 receivedTotalDebt, uint256[] memory receivedUsersDebtAmounts) = + steward.getDebtAmount(assetUnderlying, usersEligibleForLiquidations); + + assertEq(receivedTotalDebt, totalDebtToLiquidate); + assertEq(receivedUsersDebtAmounts.length, usersEligibleForLiquidations.length); + + for (uint256 i = 0; i < usersEligibleForLiquidations.length; i++) { + assertEq(receivedUsersDebtAmounts[i], usersEligibleForLiquidationsDebtAmounts[i]); + } + } + + function test_getBadDebtAmount() public view { + (uint256 receivedTotalBadDebt, uint256[] memory receivedUsersBadDebtAmounts) = + steward.getBadDebtAmount(assetUnderlying, usersWithBadDebt); + + assertEq(receivedTotalBadDebt, totalBadDebt); + assertEq(receivedUsersBadDebtAmounts.length, usersBadDebtAmounts.length); + + for (uint256 i = 0; i < usersBadDebtAmounts.length; i++) { + assertEq(receivedUsersBadDebtAmounts[i], usersBadDebtAmounts[i]); + } + } + + function test_reverts_getBadDebtAmount_userHasSomeCollateral() public { + vm.expectRevert( + abi.encodePacked(IBatchRepayBadDebtSteward.UserHasSomeCollateral.selector, uint256(uint160(usersWithGoodDebt[0]))) + ); + + steward.getBadDebtAmount(assetUnderlying, usersWithGoodDebt); + } + + function test_maxRescue() public { + assertEq(steward.maxRescue(assetUnderlying), 0); + + uint256 mintAmount = 1_000_000e18; + deal(assetUnderlying, address(steward), mintAmount); + + assertEq(steward.maxRescue(assetUnderlying), mintAmount); + } +}