Skip to content

Commit

Permalink
Merge pull request #16 from bgd-labs/feat/add-pull-limit-to-clinic-st…
Browse files Browse the repository at this point in the history
…eward

Added pull limit to the ClinicSteward contract
  • Loading branch information
sakulstra authored Feb 28, 2025
2 parents 4b659b2 + da1f5c8 commit 3c6dfa5
Show file tree
Hide file tree
Showing 4 changed files with 246 additions and 49 deletions.
52 changes: 26 additions & 26 deletions snapshots/ClinicSteward.json
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
{
"function batchLiquidate: with 0 users": "88346",
"function batchLiquidate: with 1 user": "556909",
"function batchLiquidate: with 2 users": "886551",
"function batchLiquidate: with 3 users": "1104291",
"function batchLiquidate: with 4 users": "1322043",
"function batchLiquidate: with 5 users": "1645670",
"function batchLiquidate: with 6 users": "1863422",
"function batchRepayBadDebt: with 0 users": "64367",
"function batchRepayBadDebt: with 1 user": "221408",
"function batchRepayBadDebt: with 2 users": "283879",
"function batchRepayBadDebt: with 3 users": "346349",
"function batchRepayBadDebt: with 4 users": "408820",
"function batchRepayBadDebt: with 5 users": "471291",
"function batchRepayBadDebt: with 6 users": "533762",
"function getBadDebtAmount: with 0 users": "12821",
"function getBadDebtAmount: with 1 user": "38631",
"function getBadDebtAmount: with 2 users": "51389",
"function getBadDebtAmount: with 4 users": "76907",
"function getBadDebtAmount: with 5 users": "89666",
"function getBadDebtAmount: with 6 users": "102425",
"function getDebtAmount: with 0 users": "12844",
"function getDebtAmount: with 1 user": "34314",
"function getDebtAmount: with 2 users": "51389",
"function getDebtAmount: with 4 users": "59569",
"function getDebtAmount: with 5 users": "67987",
"function getDebtAmount: with 6 users": "76406"
"function batchLiquidate: with 0 users": "117607",
"function batchLiquidate: with 1 user": "569483",
"function batchLiquidate: with 2 users": "899138",
"function batchLiquidate: with 3 users": "1116891",
"function batchLiquidate: with 4 users": "1334656",
"function batchLiquidate: with 5 users": "1658296",
"function batchLiquidate: with 6 users": "1876061",
"function batchRepayBadDebt: with 0 users": "93637",
"function batchRepayBadDebt: with 1 user": "253484",
"function batchRepayBadDebt: with 2 users": "315955",
"function batchRepayBadDebt: with 3 users": "378425",
"function batchRepayBadDebt: with 4 users": "440896",
"function batchRepayBadDebt: with 5 users": "503367",
"function batchRepayBadDebt: with 6 users": "565838",
"function getBadDebtAmount: with 0 users": "12843",
"function getBadDebtAmount: with 1 user": "38653",
"function getBadDebtAmount: with 2 users": "51411",
"function getBadDebtAmount: with 4 users": "76929",
"function getBadDebtAmount: with 5 users": "89688",
"function getBadDebtAmount: with 6 users": "102447",
"function getDebtAmount: with 0 users": "12866",
"function getDebtAmount: with 1 user": "34336",
"function getDebtAmount: with 2 users": "51411",
"function getDebtAmount: with 4 users": "59591",
"function getDebtAmount: with 5 users": "68009",
"function getDebtAmount: with 6 users": "76428"
}
112 changes: 90 additions & 22 deletions src/maintenance/ClinicSteward.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {IPool, DataTypes} from "aave-address-book/AaveV3.sol";
import {IPool, DataTypes, IPoolAddressesProvider, IPriceOracleGetter} 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";
Expand All @@ -14,6 +14,7 @@ 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 {IERC20Metadata} from "openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sol";

import {IClinicSteward} from "./interfaces/IClinicSteward.sol";

Expand Down Expand Up @@ -46,6 +47,19 @@ import {IClinicSteward} from "./interfaces/IClinicSteward.sol";
* --- 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` role).
*
* --- Budget ---
* The contract has a configurable limit on the total dollar value of assets that can be pulled from the Collector.
* This limit is set upon contract creation and can be updated by the `DEFAULT_ADMIN_ROLE` via `setAvailableBudget`.
* The current limit can be tracked via `availableBudget`.
* Any attempt to pull funds exceeding the remaining limit will revert with `AvailableBudgetExceeded` error.
* When repaying or liquidating assets, the budget is always decreasing by the estimated amount of funds needed.
* Even if only a subset of the funds is actually spent (e.g. when overestimating debt on liquidation) the budget is reduced by the full amount.
* This is not an exact science and the goal achieved by the mechanic is to maintain an upper bound of funds that can be spent.
*
* --- Limitations ---
* The available budget is tracked in $ with 8 decimal precision.
* When liquidating or repaying assets worth less than 1 unit, it will not be discounted from the budget.
*/
contract ClinicSteward is IClinicSteward, RescuableBase, Multicall, AccessControl {
using SafeERC20 for IERC20;
Expand All @@ -62,15 +76,29 @@ contract ClinicSteward is IClinicSteward, RescuableBase, Multicall, AccessContro
/// @inheritdoc IClinicSteward
address public immutable override COLLECTOR;

/// @inheritdoc IClinicSteward
address public immutable override ORACLE;

/// @inheritdoc IClinicSteward
uint256 public override availableBudget;

/* CONSTRUCTOR */

constructor(address pool, address collector, address admin, address cleanupRoleRecipient) {
/// @param pool The address of the Aave pool.
/// @param collector The address of the Aave collector.
/// @param admin The address of the admin. He will receive the `DEFAULT_ADMIN_ROLE` role.
/// @param cleanupRoleRecipient The address of the `CLEANUP_ROLE` role recipient.
/// @param initialBudget The initial available budget, in dollar value (with 8 decimals).
constructor(address pool, address collector, address admin, address cleanupRoleRecipient, uint256 initialBudget) {
if (pool == address(0) || collector == address(0) || admin == address(0) || cleanupRoleRecipient == address(0)) {
revert ZeroAddress();
}

POOL = IPool(pool);
COLLECTOR = collector;
ORACLE = IPoolAddressesProvider(IPool(pool).ADDRESSES_PROVIDER()).getPriceOracle();

_setAvailableBudget(initialBudget);

_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(CLEANUP_ROLE, cleanupRoleRecipient);
Expand All @@ -96,6 +124,7 @@ contract ClinicSteward is IClinicSteward, RescuableBase, Multicall, AccessContro
{
(uint256 totalDebtAmount, uint256[] memory debtAmounts) = getBadDebtAmount(asset, users);
_pullFunds(asset, totalDebtAmount, useATokens);
_decreaseAvailableBudget({asset: asset, amount: totalDebtAmount});

for (uint256 i = 0; i < users.length; i++) {
if (debtAmounts[i] != 0) {
Expand All @@ -114,7 +143,7 @@ contract ClinicSteward is IClinicSteward, RescuableBase, Multicall, AccessContro
{
// this is an over approximation as not necessarily all bad debt can be liquidated
// the excess is transfered back to the collector
(uint256 maxDebtAmount,) = getDebtAmount(debtAsset, users);
(uint256 maxDebtAmount, uint256[] memory amounts) = getDebtAmount(debtAsset, users);
_pullFunds(debtAsset, maxDebtAmount, useAToken);

for (uint256 i = 0; i < users.length; i++) {
Expand All @@ -124,9 +153,14 @@ contract ClinicSteward is IClinicSteward, RescuableBase, Multicall, AccessContro
user: users[i],
debtToCover: type(uint256).max,
receiveAToken: true
}) {} catch {}
}) {} catch {
maxDebtAmount -= amounts[i];
}
}

// violates CEI which is acceptable given the assets are whitelisted and the contract is permissioned
_decreaseAvailableBudget({asset: debtAsset, amount: maxDebtAmount});

// the excess is always in the underlying
_transferExcessToCollector(debtAsset);

Expand All @@ -135,6 +169,11 @@ contract ClinicSteward is IClinicSteward, RescuableBase, Multicall, AccessContro
_transferExcessToCollector(collateralAToken);
}

/// @inheritdoc IClinicSteward
function setAvailableBudget(uint256 newAvailableBudget) external onlyRole(DEFAULT_ADMIN_ROLE) {
_setAvailableBudget(newAvailableBudget);
}

/// @inheritdoc IClinicSteward
function rescueToken(address token) external override {
_emergencyTokenTransfer(token, COLLECTOR, type(uint256).max);
Expand Down Expand Up @@ -172,6 +211,53 @@ contract ClinicSteward is IClinicSteward, RescuableBase, Multicall, AccessContro
return IERC20(erc20Token).balanceOf(address(this));
}

/* PRIVATE FUNCTIONS */

function _pullFunds(address asset, uint256 amount, bool useAToken) private {
if (useAToken) {
address aToken = POOL.getReserveAToken(asset);
// 1 wei surplus to account for rounding on multiple operations
ICollector(COLLECTOR).transfer(IERC20Col(aToken), address(this), amount + 1);
POOL.withdraw(asset, type(uint256).max, address(this));
} else {
ICollector(COLLECTOR).transfer(IERC20Col(asset), address(this), amount);
}
}

function _decreaseAvailableBudget(address asset, uint256 amount) private {
uint256 assetPrice = IPriceOracleGetter(ORACLE).getAssetPrice(asset);

uint256 dollarAmount = (amount * assetPrice) / (10 ** IERC20Metadata(asset).decimals());

uint256 oldAvailableBudget = availableBudget;

if (dollarAmount > oldAvailableBudget) {
revert AvailableBudgetExceeded({
asset: asset,
assetAmount: amount,
dollarAmount: dollarAmount,
availableBudget: oldAvailableBudget
});
}

_setAvailableBudget(oldAvailableBudget - dollarAmount);
}

function _setAvailableBudget(uint256 newAvailableBudget) private {
uint256 oldAvailableBudget = availableBudget;

availableBudget = newAvailableBudget;

emit AvailableBudgetChanged({oldValue: oldAvailableBudget, newValue: newAvailableBudget});
}

function _transferExcessToCollector(address asset) private {
uint256 balanceAfter = IERC20(asset).balanceOf(address(this));
if (balanceAfter != 0) {
IERC20(asset).safeTransfer(COLLECTOR, balanceAfter);
}
}

/* PRIVATE VIEW FUNCTIONS */

function _getUsersDebtAmounts(address asset, address[] memory users, bool usersCanHaveCollateral)
Expand Down Expand Up @@ -201,22 +287,4 @@ contract ClinicSteward is IClinicSteward, RescuableBase, Multicall, AccessContro

return (totalDebtAmount, debtAmounts);
}

function _pullFunds(address asset, uint256 amount, bool useAToken) internal {
if (useAToken) {
address aToken = POOL.getReserveAToken(asset);
// 1 wei surplus to account for rounding on multiple operations
ICollector(COLLECTOR).transfer(IERC20Col(aToken), address(this), amount + 1);
POOL.withdraw(asset, type(uint256).max, address(this));
} else {
ICollector(COLLECTOR).transfer(IERC20Col(asset), address(this), amount);
}
}

function _transferExcessToCollector(address asset) internal {
uint256 balanceAfter = IERC20(asset).balanceOf(address(this));
if (balanceAfter != 0) {
IERC20(asset).safeTransfer(COLLECTOR, balanceAfter);
}
}
}
26 changes: 26 additions & 0 deletions src/maintenance/interfaces/IClinicSteward.sol
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,25 @@ import {IRescuableBase} from "solidity-utils/contracts/utils/interfaces/IRescuab
import {IAccessControl} from "openzeppelin-contracts/contracts/access/IAccessControl.sol";

interface IClinicSteward is IRescuableBase, IAccessControl {
/* ERRORS */

/// @notice Thrown when passed address is zero
error ZeroAddress();

/// @notice Thrown when an attempt is made to pull more funds (in dollar value) than the available budget allows.
/// @param asset The asset being pulled.
/// @param assetAmount The amount of the asset being pulled.
/// @param dollarAmount The equivalent dollar value of the assetAmount.
/// @param availableBudget The available budget.
error AvailableBudgetExceeded(address asset, uint256 assetAmount, uint256 dollarAmount, uint256 availableBudget);

/* EVENTS */

/// @notice Emitted when the available budget is changed.
/// @param oldValue The previous available budget.
/// @param newValue The new available budget.
event AvailableBudgetChanged(uint256 oldValue, uint256 newValue);

/* GLOBAL VARIABLES */

/// @notice The role that allows to call the `batchLiquidate` and `batchRepayBadDebt` functions
Expand All @@ -22,6 +38,12 @@ interface IClinicSteward is IRescuableBase, IAccessControl {
/// @notice The Aave collector
function COLLECTOR() external view returns (address);

/// @notice The Aave oracle
function ORACLE() external view returns (address);

/// @notice The available budget for the contract, in dollar value (8 decimals).
function availableBudget() external view returns (uint256);

/* EXTERNAL FUNCTIONS */

/// @notice Liquidates all the users with a max debt amount to be liquidated
Expand All @@ -42,6 +64,10 @@ interface IClinicSteward is IRescuableBase, IAccessControl {
/// If false it will pull the underlying.
function batchRepayBadDebt(address asset, address[] calldata users, bool useATokens) external;

/// @notice Sets the available budget for the contract.
/// @param newAvailableBudget The new available budget, in dollar value (with 8 decimals).
function setAvailableBudget(uint256 newAvailableBudget) external;

/// @notice Rescues the tokens
/// @param token The address of the token to rescue
function rescueToken(address token) external;
Expand Down
Loading

3 comments on commit 3c6dfa5

@sakulstra
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌈 Test Results
No files changed, compilation skipped

Ran 16 tests for tests/maintenance/ClinicSteward.t.sol:ClinicStewardTest
[PASS] test_batchLiquidate() (gas: 1974456)
[PASS] test_batchLiquidateUseAToken() (gas: 2155609)
[PASS] test_batchRepayBadDebt() (gas: 717421)
[PASS] test_batchRepayBadDebtUseAToken() (gas: 603452)
[PASS] test_getBadDebtAmount() (gas: 142364)
[PASS] test_getDebtAmount() (gas: 119959)
[PASS] test_maxRescue() (gas: 184221)
[PASS] test_rescueEth() (gas: 28277)
[PASS] test_rescueToken() (gas: 197252)
[PASS] test_reverts_batchLiquidate_caller_not_cleaner(address) (runs: 256, μ: 36540, ~: 36540)
[PASS] test_reverts_batchLiquidate_exceeded_pull_limit() (gas: 3916317)
[PASS] test_reverts_batchRepayBadDebt_caller_not_cleaner(address) (runs: 256, μ: 34392, ~: 34392)
[PASS] test_reverts_batchRepayBadDebt_exceeded_pull_limit() (gas: 2287979)
[PASS] test_reverts_setAvailableBudget_caller_not_admin(address) (runs: 256, μ: 15149, ~: 15149)
[PASS] test_setAvailableBudget() (gas: 24838)
[PASS] test_userHasSomeCollateral_returns_zero() (gas: 66557)
Suite result: ok. 16 passed; 0 failed; 0 skipped; finished in 3.98s (6.41s CPU time)

Ran 28 tests for tests/gas/maintenance/ClinicSteward.gas.t.sol:ClinicStewardGasTest
[PASS] test_batchLiquidate_five_users() (gas: 1789387)
[PASS] test_batchLiquidate_four_users() (gas: 1464697)
[PASS] test_batchLiquidate_one_user() (gas: 699013)
[PASS] test_batchLiquidate_six_users() (gas: 2008290)
[PASS] test_batchLiquidate_three_users() (gas: 1245837)
[PASS] test_batchLiquidate_two_users() (gas: 1026915)
[PASS] test_batchLiquidate_zero_users() (gas: 255029)
[PASS] test_batchRepayBadDebt_five_users() (gas: 630152)
[PASS] test_batchRepayBadDebt_four_users() (gas: 567313)
[PASS] test_batchRepayBadDebt_one_user() (gas: 382343)
[PASS] test_batchRepayBadDebt_six_users() (gas: 692925)
[PASS] test_batchRepayBadDebt_three_users() (gas: 504477)
[PASS] test_batchRepayBadDebt_two_users() (gas: 441708)
[PASS] test_batchRepayBadDebt_zero_users() (gas: 235059)
[PASS] test_getBadDebtAmount_five_users() (gas: 108559)
[PASS] test_getBadDebtAmount_four_users() (gas: 94496)
[PASS] test_getBadDebtAmount_one_user() (gas: 52289)
[PASS] test_getBadDebtAmount_six_users() (gas: 122687)
[PASS] test_getBadDebtAmount_three_users() (gas: 80457)
[PASS] test_getBadDebtAmount_two_users() (gas: 66414)
[PASS] test_getBadDebtAmount_zero_users() (gas: 23235)
[PASS] test_getDebtAmount_five_users() (gas: 89926)
[PASS] test_getDebtAmount_four_users() (gas: 79574)
[PASS] test_getDebtAmount_one_user() (gas: 48591)
[PASS] test_getDebtAmount_six_users() (gas: 100232)
[PASS] test_getDebtAmount_three_users() (gas: 69225)
[PASS] test_getDebtAmount_two_users() (gas: 66387)
[PASS] test_getDebtAmount_zero_users() (gas: 23163)
Suite result: ok. 28 passed; 0 failed; 0 skipped; finished in 3.98s (147.08ms CPU time)

Ran 2 test suites in 3.99s (7.96s CPU time): 44 tests passed, 0 failed, 0 skipped (44 total tests)

@sakulstra
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔮 Coverage report
File Line Coverage Function Coverage Branch Coverage
src/maintenance/ClinicSteward.sol ${\color{orange}93.67\%}$
$74 / 79$
94, 115, 116, 156, 157
${\color{orange}93.33\%}$
$14 / 15$
ClinicSteward.renewAllowance
${\color{orange}87.5\%}$
$7 / 8$

@sakulstra
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Forge Gas Snapshots

🔕 Unchanged
Path Value
snapshots/ClinicSteward.json
function batchLiquidate: with 0 users 117,607
function batchLiquidate: with 1 user 569,483
function batchLiquidate: with 2 users 899,138
function batchLiquidate: with 3 users 1,116,891
function batchLiquidate: with 4 users 1,334,656
function batchLiquidate: with 5 users 1,658,296
function batchLiquidate: with 6 users 1,876,061
function batchRepayBadDebt: with 0 users 93,637
function batchRepayBadDebt: with 1 user 253,484
function batchRepayBadDebt: with 2 users 315,955
function batchRepayBadDebt: with 3 users 378,425
function batchRepayBadDebt: with 4 users 440,896
function batchRepayBadDebt: with 5 users 503,367
function batchRepayBadDebt: with 6 users 565,838
function getBadDebtAmount: with 0 users 12,843
function getBadDebtAmount: with 1 user 38,653
function getBadDebtAmount: with 2 users 51,411
function getBadDebtAmount: with 4 users 76,929
function getBadDebtAmount: with 5 users 89,688
function getBadDebtAmount: with 6 users 102,447
function getDebtAmount: with 0 users 12,866
function getDebtAmount: with 1 user 34,336
function getDebtAmount: with 2 users 51,411
function getDebtAmount: with 4 users 59,591
function getDebtAmount: with 5 users 68,009
function getDebtAmount: with 6 users 76,428

Please sign in to comment.