From 72aac6ae09918bc7939af978ec779d55b326d7e1 Mon Sep 17 00:00:00 2001 From: Dima Lekhovitsky Date: Sat, 20 Jul 2024 13:16:34 +0300 Subject: [PATCH 1/4] feat: remove `liquidateExactCollateral` Exact output version can't deal with fee-on-transfer underlyings nor phantom collaterals. --- contracts/bots/PartialLiquidationBotV3.sol | 22 +---- .../interfaces/IPartialLiquidationBotV3.sol | 25 ------ .../PartialLiquidationBotV3.int.t.sol | 81 +------------------ 3 files changed, 5 insertions(+), 123 deletions(-) diff --git a/contracts/bots/PartialLiquidationBotV3.sol b/contracts/bots/PartialLiquidationBotV3.sol index d632b1d..68dfcd0 100644 --- a/contracts/bots/PartialLiquidationBotV3.sol +++ b/contracts/bots/PartialLiquidationBotV3.sol @@ -91,6 +91,7 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra /// @param maxHealthFactor_ Maximum health factor to allow after the liquidation /// @param premiumScaleFactor_ Factor to scale credit manager's liquidation premium by /// @param feeScaleFactor_ Factor to scale credit manager's liquidation fee by + /// @dev Reverts if `maxHealthFactor` is below 100% or below `minHealthFactor_` /// @dev Reverts if `treasury_` is zero address constructor( address treasury_, @@ -134,27 +135,6 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra _checkHealthFactor(vars, creditAccount); } - /// @inheritdoc IPartialLiquidationBotV3 - function liquidateExactCollateral( - address creditAccount, - address token, - uint256 seizedAmount, - uint256 maxRepaidAmount, - address to, - PriceUpdate[] calldata priceUpdates - ) external override nonReentrant returns (uint256 repaidAmount) { - LiquidationVars memory vars = _initVars(creditAccount); - IPriceOracleV3(vars.priceOracle).updatePrices(priceUpdates); - _validateLiquidation(vars, creditAccount, token); - - repaidAmount = IPriceOracleV3(vars.priceOracle).convert(seizedAmount, token, vars.underlying) - * vars.liquidationDiscount / PERCENTAGE_FACTOR; - if (repaidAmount > maxRepaidAmount) revert RepaidMoreThanAllowedException(); - - _executeLiquidation(vars, creditAccount, token, repaidAmount, seizedAmount, to); - _checkHealthFactor(vars, creditAccount); - } - // --------- // // INTERNALS // // --------- // diff --git a/contracts/interfaces/IPartialLiquidationBotV3.sol b/contracts/interfaces/IPartialLiquidationBotV3.sol index 455aba2..faf2aea 100644 --- a/contracts/interfaces/IPartialLiquidationBotV3.sol +++ b/contracts/interfaces/IPartialLiquidationBotV3.sol @@ -40,9 +40,6 @@ interface IPartialLiquidationBotV3 is IBot { /// @notice Thrown when health factor after liquidation is greater than maximum allowed error LiquidatedMoreThanNeededException(); - /// @notice Thrown when amount of underlying repaid is greater than allowed - error RepaidMoreThanAllowedException(); - /// @notice Thrown when amount of collateral seized is less than required error SeizedLessThanRequiredException(); @@ -89,26 +86,4 @@ interface IPartialLiquidationBotV3 is IBot { address to, PriceUpdate[] calldata priceUpdates ) external returns (uint256 seizedAmount); - - /// @notice Liquidates credit account by repaying its debt in exchange for the given amount of discounted collateral - /// @param creditAccount Credit account to liquidate - /// @param token Collateral token to seize - /// @param seizedAmount Amount of `token` to seize from `creditAccount` - /// @param maxRepaidAmount Maxiumum amount of underlying to repay - /// @param to Address to send seized `token` to - /// @param priceUpdates On-demand price feed updates to apply before calculations - /// @return repaidAmount Amount of underlying repaid - /// @dev Requires underlying token approval from caller to this contract - /// @dev Reverts if `token` is underlying - /// @dev Reverts if `creditAccount`'s health factor is not less than `minHealthFactor` before liquidation - /// @dev Reverts if amount of underlying to be repaid is greater than `maxRepaidAmount` - /// @dev Reverts if `creditAccount`'s health factor is not within allowed range after liquidation - function liquidateExactCollateral( - address creditAccount, - address token, - uint256 seizedAmount, - uint256 maxRepaidAmount, - address to, - PriceUpdate[] calldata priceUpdates - ) external returns (uint256 repaidAmount); } diff --git a/contracts/test/integration/PartialLiquidationBotV3.int.t.sol b/contracts/test/integration/PartialLiquidationBotV3.int.t.sol index 60b06cd..c243861 100644 --- a/contracts/test/integration/PartialLiquidationBotV3.int.t.sol +++ b/contracts/test/integration/PartialLiquidationBotV3.int.t.sol @@ -170,7 +170,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // BASIC TESTS // // ----------- // - function test_I_PL_02A_liquidateExactDebt_sanitizes_inputs() public creditTest { + function test_I_PL_02_liquidateExactDebt_sanitizes_inputs() public creditTest { _setUp(); PriceUpdate[] memory priceUpdates = _getPriceUpdates(); @@ -186,23 +186,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { bot.liquidateExactDebt(creditAccount, link, 0, 0, FRIEND, priceUpdates); } - function test_I_PL_02B_liquidateExactCollateral_sanitizes_inputs() public creditTest { - _setUp(); - - PriceUpdate[] memory priceUpdates = _getPriceUpdates(); - - // reverts on trying to liquidate underlying - vm.expectRevert(IPartialLiquidationBotV3.UnderlyingNotLiquidatableException.selector); - vm.prank(LIQUIDATOR); - bot.liquidateExactCollateral(creditAccount, dai, 0, 0, FRIEND, priceUpdates); - - // reverts on trying to liquidate healthy account - vm.expectRevert(CreditAccountNotLiquidatableException.selector); - vm.prank(LIQUIDATOR); - bot.liquidateExactCollateral(creditAccount, link, 0, 0, FRIEND, priceUpdates); - } - - function test_I_PL_03A_liquidateExactDebt_works_as_expected() public creditTest { + function test_I_PL_03_liquidateExactDebt_works_as_expected() public creditTest { _setUp(); // make account liquidatable by lowering the collateral price @@ -253,63 +237,6 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { assertEq(seizedAmount, expectedSeizedAmount, "Incorrect seized amount"); } - function test_I_PL_03B_liquidateExactCollateral_works_as_expected() public creditTest { - _setUp(); - - // make account liquidatable by lowering the collateral price - PriceFeedMock(priceOracle.priceFeeds(link)).setPrice(newLinkPrice); - PriceUpdate[] memory priceUpdates = _getPriceUpdates(); - - uint256 seizedAmount = linkAmount * 3 / 4; - - // reverts on charging too much - vm.expectRevert(IPartialLiquidationBotV3.RepaidMoreThanAllowedException.selector); - vm.prank(LIQUIDATOR); - bot.liquidateExactCollateral(creditAccount, link, seizedAmount, daiAmount / 2, FRIEND, priceUpdates); - - uint256 expectedRepaidAmount = seizedAmount * uint256(24 * newLinkPrice) / uint256(25 * daiPrice); - uint256 expectedFeeAmount = expectedRepaidAmount * 3 / 200; - - vm.expectCall( - address(creditManager), - abi.encodeCall( - creditManager.manageDebt, - ( - creditAccount, - expectedRepaidAmount - expectedFeeAmount, - daiMask | linkMask, - ManageDebtAction.DECREASE_DEBT - ) - ) - ); - - vm.expectCall( - address(creditManager), - abi.encodeCall(creditManager.withdrawCollateral, (creditAccount, dai, expectedFeeAmount, treasury)) - ); - - vm.expectCall( - address(creditManager), - abi.encodeCall(creditManager.withdrawCollateral, (creditAccount, link, seizedAmount, FRIEND)) - ); - - vm.expectEmit(true, true, true, true, address(bot)); - emit LiquidatePartial( - address(creditManager), - creditAccount, - link, - expectedRepaidAmount - expectedFeeAmount, - seizedAmount, - expectedFeeAmount - ); - - vm.prank(LIQUIDATOR); - uint256 repaidAmount = - bot.liquidateExactCollateral(creditAccount, link, seizedAmount, type(uint256).max, FRIEND, priceUpdates); - - assertEq(repaidAmount, expectedRepaidAmount, "Incorrect repaid amount"); - } - // ------------------ // // ADVANCED SCENARIOS // // ------------------ // @@ -324,7 +251,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // reverts when account is still insolvent after liquidation vm.expectRevert(NotEnoughCollateralException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactCollateral(creditAccount, link, linkAmount / 10, type(uint256).max, FRIEND, priceUpdates); + bot.liquidateExactDebt(creditAccount, link, daiAmount / 10, 0, FRIEND, priceUpdates); // reverts when account's debt is below minimum after liquidation vm.expectRevert(BorrowAmountOutOfLimitsException.selector); @@ -334,7 +261,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // reverts when repaid more debt than account had vm.expectRevert(DebtToZeroWithActiveQuotasException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactCollateral(creditAccount, link, linkAmount, type(uint256).max, FRIEND, priceUpdates); + bot.liquidateExactDebt(creditAccount, link, daiAmount * 11 / 10, 0, FRIEND, priceUpdates); } function test_I_PL_05_partialLiquidation_does_not_steal_underlying_from_account() public creditTest { From bcecde7b1eec60db2654f33c74222331f4992114 Mon Sep 17 00:00:00 2001 From: Dima Lekhovitsky Date: Sun, 21 Jul 2024 16:33:10 +0300 Subject: [PATCH 2/4] fix: handle fee-on-transfer underlyings --- contracts/bots/PartialLiquidationBotV3.sol | 43 ++++++++++++++-------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/contracts/bots/PartialLiquidationBotV3.sol b/contracts/bots/PartialLiquidationBotV3.sol index 68dfcd0..197bc1f 100644 --- a/contracts/bots/PartialLiquidationBotV3.sol +++ b/contracts/bots/PartialLiquidationBotV3.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.23; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {SafeERC20} from "@1inch/solidity-utils/contracts/libraries/SafeERC20.sol"; import {ICreditAccountV3} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditAccountV3.sol"; import {ICreditFacadeV3, MultiCall} from "@gearbox-protocol/core-v3/contracts/interfaces/ICreditFacadeV3.sol"; @@ -44,8 +44,7 @@ import {IPartialLiquidationBotV3} from "../interfaces/IPartialLiquidationBotV3.s /// - health factor range check is made using normal prices, which, under certain circumstances, may be /// mutually exclusive with the former; /// - liquidator premium and DAO fee are the same as for the full liquidation in a given credit manager -/// (although fees are sent to the treasury instead of being deposited into pools); -/// - this implementation can't handle fee-on-transfer underlyings. +/// (although fees are sent to the treasury instead of being deposited into pools). /// The bot can also be used for deleverage to prevent liquidations by triggering earlier, limiting /// operation size and/or charging less in premium and fees. contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTrait, SanityCheckTrait { @@ -124,14 +123,18 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra PriceUpdate[] calldata priceUpdates ) external override nonReentrant returns (uint256 seizedAmount) { LiquidationVars memory vars = _initVars(creditAccount); - IPriceOracleV3(vars.priceOracle).updatePrices(priceUpdates); + if (priceUpdates.length != 0) IPriceOracleV3(vars.priceOracle).updatePrices(priceUpdates); _validateLiquidation(vars, creditAccount, token); - seizedAmount = IPriceOracleV3(vars.priceOracle).convert(repaidAmount, vars.underlying, token) - * PERCENTAGE_FACTOR / vars.liquidationDiscount; + uint256 balanceBefore = IERC20(vars.underlying).safeBalanceOf(creditAccount); + IERC20(vars.underlying).safeTransferFrom(msg.sender, creditAccount, repaidAmount); + repaidAmount = IERC20(vars.underlying).safeBalanceOf(creditAccount) - balanceBefore; + + uint256 fee; + (repaidAmount, fee, seizedAmount) = _calcPartialLiquidationPayments(vars, repaidAmount, token); if (seizedAmount < minSeizedAmount) revert SeizedLessThanRequiredException(); - _executeLiquidation(vars, creditAccount, token, repaidAmount, seizedAmount, to); + _executeLiquidation(vars, creditAccount, token, repaidAmount, seizedAmount, fee, to); _checkHealthFactor(vars, creditAccount); } @@ -159,22 +162,32 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra } } - /// @dev Executes partial liquidation: - /// - transfers `repaidAmount` of underlying from the caller to `creditAccount` - /// - performs a multicall on `creditAccount` that repays debt, withdraws fee to the treasury, - /// and withdraws `seizedAmount` of `token` to `to` + /// @dev Calculates and returns partial liquidation payment amounts: + /// - amount of underlying that should go towards repaying debt + /// - amount of underlying that should go towards liquidation fees + /// - amount of collateral that should be withdrawn to the liquidator + function _calcPartialLiquidationPayments(LiquidationVars memory vars, uint256 amount, address token) + internal + view + returns (uint256 repaidAmount, uint256 fee, uint256 seizedAmount) + { + seizedAmount = IPriceOracleV3(vars.priceOracle).convert(amount, vars.underlying, token) * PERCENTAGE_FACTOR + / vars.liquidationDiscount; + fee = amount * vars.feeLiquidation / PERCENTAGE_FACTOR; + repaidAmount = amount - fee; + } + + /// @dev Executes partial liquidation by performing a multicall on `creditAccount` that repays debt, + /// withdraws fee to the treasury and withdraws `token` to `to` function _executeLiquidation( LiquidationVars memory vars, address creditAccount, address token, uint256 repaidAmount, uint256 seizedAmount, + uint256 fee, address to ) internal { - IERC20(vars.underlying).safeTransferFrom(msg.sender, creditAccount, repaidAmount); - uint256 fee = repaidAmount * vars.feeLiquidation / PERCENTAGE_FACTOR; - repaidAmount -= fee; - MultiCall[] memory calls = new MultiCall[](3); calls[0] = MultiCall({ target: vars.creditFacade, From e0f5c21cfb64b5499a40764c3d2da34a4a087a52 Mon Sep 17 00:00:00 2001 From: Dima Lekhovitsky Date: Sun, 21 Jul 2024 17:23:16 +0300 Subject: [PATCH 3/4] feat: phantom collaterals support --- contracts/bots/PartialLiquidationBotV3.sol | 26 +++++-- .../interfaces/IPartialLiquidationBotV3.sol | 4 +- .../PartialLiquidationBotV3.int.t.sol | 74 +++++++++++++++++++ 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/contracts/bots/PartialLiquidationBotV3.sol b/contracts/bots/PartialLiquidationBotV3.sol index 197bc1f..b267476 100644 --- a/contracts/bots/PartialLiquidationBotV3.sol +++ b/contracts/bots/PartialLiquidationBotV3.sol @@ -24,6 +24,7 @@ import { } from "@gearbox-protocol/core-v3/contracts/interfaces/IExceptions.sol"; import {IPriceOracleV3, PriceUpdate} from "@gearbox-protocol/core-v3/contracts/interfaces/IPriceOracleV3.sol"; import {IBot} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IBot.sol"; +import {IPhantomToken} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IPhantomToken.sol"; import {IVersion} from "@gearbox-protocol/core-v3/contracts/interfaces/base/IVersion.sol"; import {PERCENTAGE_FACTOR} from "@gearbox-protocol/core-v3/contracts/libraries/Constants.sol"; import {ReentrancyGuardTrait} from "@gearbox-protocol/core-v3/contracts/traits/ReentrancyGuardTrait.sol"; @@ -56,6 +57,7 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra address creditFacade; address priceOracle; address underlying; + address receivedToken; uint256 feeLiquidation; uint256 liquidationDiscount; } @@ -122,9 +124,9 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra address to, PriceUpdate[] calldata priceUpdates ) external override nonReentrant returns (uint256 seizedAmount) { - LiquidationVars memory vars = _initVars(creditAccount); + LiquidationVars memory vars = _initVars(creditAccount, token); if (priceUpdates.length != 0) IPriceOracleV3(vars.priceOracle).updatePrices(priceUpdates); - _validateLiquidation(vars, creditAccount, token); + _validateLiquidation(vars, creditAccount); uint256 balanceBefore = IERC20(vars.underlying).safeBalanceOf(creditAccount); IERC20(vars.underlying).safeTransferFrom(msg.sender, creditAccount, repaidAmount); @@ -132,9 +134,10 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra uint256 fee; (repaidAmount, fee, seizedAmount) = _calcPartialLiquidationPayments(vars, repaidAmount, token); + + seizedAmount = _executeLiquidation(vars, creditAccount, token, repaidAmount, seizedAmount, fee, to); if (seizedAmount < minSeizedAmount) revert SeizedLessThanRequiredException(); - _executeLiquidation(vars, creditAccount, token, repaidAmount, seizedAmount, fee, to); _checkHealthFactor(vars, creditAccount); } @@ -143,7 +146,7 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra // --------- // /// @dev Loads state variables used in `creditAccount` liquidation - function _initVars(address creditAccount) internal view returns (LiquidationVars memory vars) { + function _initVars(address creditAccount, address token) internal view returns (LiquidationVars memory vars) { vars.creditManager = ICreditAccountV3(creditAccount).creditManager(); vars.creditFacade = ICreditManagerV3(vars.creditManager).creditFacade(); vars.priceOracle = ICreditManagerV3(vars.creditManager).priceOracle(); @@ -152,11 +155,16 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra vars.liquidationDiscount = PERCENTAGE_FACTOR - (PERCENTAGE_FACTOR - liquidationDiscount) * premiumScaleFactor / PERCENTAGE_FACTOR; vars.feeLiquidation = feeLiquidation * feeScaleFactor / PERCENTAGE_FACTOR; + try IPhantomToken(token).getPhantomTokenInfo() returns (address, address depositedToken) { + vars.receivedToken = depositedToken; + } catch { + vars.receivedToken = token; + } } /// @dev Ensures that `creditAccount` is liquidatable and `token` is not underlying - function _validateLiquidation(LiquidationVars memory vars, address creditAccount, address token) internal view { - if (token == vars.underlying) revert UnderlyingNotLiquidatableException(); + function _validateLiquidation(LiquidationVars memory vars, address creditAccount) internal view { + if (vars.receivedToken == vars.underlying) revert UnderlyingNotLiquidatableException(); if (!_isLiquidatable(_calcDebtAndCollateral(vars.creditManager, creditAccount), minHealthFactor)) { revert CreditAccountNotLiquidatableException(); } @@ -187,7 +195,7 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra uint256 seizedAmount, uint256 fee, address to - ) internal { + ) internal returns (uint256 receivedAmount) { MultiCall[] memory calls = new MultiCall[](3); calls[0] = MultiCall({ target: vars.creditFacade, @@ -201,9 +209,11 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra target: vars.creditFacade, callData: abi.encodeCall(ICreditFacadeV3Multicall.withdrawCollateral, (token, seizedAmount, to)) }); + uint256 balanceBefore = IERC20(vars.receivedToken).safeBalanceOf(to); ICreditFacadeV3(vars.creditFacade).botMulticall(creditAccount, calls); + receivedAmount = IERC20(vars.receivedToken).safeBalanceOf(to) - balanceBefore; - emit LiquidatePartial(vars.creditManager, creditAccount, token, repaidAmount, seizedAmount, fee); + emit LiquidatePartial(vars.creditManager, creditAccount, vars.receivedToken, repaidAmount, receivedAmount, fee); } /// @dev Ensures that `creditAccount`'s health factor is within allowed range after partial liquidation diff --git a/contracts/interfaces/IPartialLiquidationBotV3.sol b/contracts/interfaces/IPartialLiquidationBotV3.sol index faf2aea..c7962ed 100644 --- a/contracts/interfaces/IPartialLiquidationBotV3.sol +++ b/contracts/interfaces/IPartialLiquidationBotV3.sol @@ -74,10 +74,12 @@ interface IPartialLiquidationBotV3 is IBot { /// @param priceUpdates On-demand price feed updates to apply before calculations /// @return seizedAmount Amount of `token` seized /// @dev Requires underlying token approval from caller to this contract - /// @dev Reverts if `token` is underlying + /// @dev Reverts if `token` is underlying or if `token` is a phantom token and its `depositedToken` is underlying /// @dev Reverts if `creditAccount`'s health factor is not less than `minHealthFactor` before liquidation /// @dev Reverts if amount of `token` to be seized is less than `minSeizedAmount` /// @dev Reverts if `creditAccount`'s health factor is not within allowed range after liquidation + /// @dev If `token` is a phantom token, it's withdrawn first, and its `depositedToken` is then sent to the liquidator. + /// Both `seizedAmount` and `minSeizedAmount` refer to `depositedToken` in this case. function liquidateExactDebt( address creditAccount, address token, diff --git a/contracts/test/integration/PartialLiquidationBotV3.int.t.sol b/contracts/test/integration/PartialLiquidationBotV3.int.t.sol index c243861..5de34d6 100644 --- a/contracts/test/integration/PartialLiquidationBotV3.int.t.sol +++ b/contracts/test/integration/PartialLiquidationBotV3.int.t.sol @@ -20,7 +20,13 @@ import {PriceUpdate} from "@gearbox-protocol/core-v3/contracts/interfaces/IPrice import {IntegrationTestHelper} from "@gearbox-protocol/core-v3/contracts/test/helpers/IntegrationTestHelper.sol"; import {CONFIGURATOR, FRIEND, LIQUIDATOR, USER} from "@gearbox-protocol/core-v3/contracts/test/lib/constants.sol"; +import {GeneralMock} from "@gearbox-protocol/core-v3/contracts/test/mocks/GeneralMock.sol"; import {PriceFeedMock} from "@gearbox-protocol/core-v3/contracts/test/mocks/oracles/PriceFeedMock.sol"; +import {ERC20Mock} from "@gearbox-protocol/core-v3/contracts/test/mocks/token/ERC20Mock.sol"; +import { + PhantomTokenMock, + PhantomTokenWithdrawerMock +} from "@gearbox-protocol/core-v3/contracts/test/mocks/token/PhantomTokenMock.sol"; import {Tokens} from "@gearbox-protocol/sdk-gov/contracts/Tokens.sol"; @@ -307,4 +313,72 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { uint256 seizedAmount = bot.liquidateExactDebt(creditAccount, link, repaidAmount, 0, FRIEND, priceUpdates); assertApproxEqAbs(seizedAmount, expectedSeizedAmount, 1, "Incorrect seized amount"); } + + function test_I_PL_07_partialLiquidation_works_as_expected_with_phantom_collateral() public creditTest { + _setUp(); + + // make account liquidatable by lowering the collateral price + PriceFeedMock(priceOracle.priceFeeds(link)).setPrice(newLinkPrice); + PriceUpdate[] memory priceUpdates = _getPriceUpdates(); + + // turn LINK into a phantom token around CRV by replacing its implementation + ERC20Mock crv = ERC20Mock(tokenTestSuite.addressOf(Tokens.CRV)); + vm.etch( + link, address(new PhantomTokenMock(address(new GeneralMock()), address(crv), "Phantom Curve", "pCRV")).code + ); + + // set LINK/CRV exchange rate to 0.5 (thus `expectedSeizedAmount` is divided by 2) + PhantomTokenMock(link).setExchangeRate(0.5 ether); + + // whitelist a LINK withdrawer adapter + PhantomTokenWithdrawerMock withdrawer = new PhantomTokenWithdrawerMock(address(creditManager), link); + crv.set_minter(address(withdrawer)); + vm.prank(CONFIGURATOR); + creditConfigurator.allowAdapter(address(withdrawer)); + + uint256 repaidAmount = daiAmount * 3 / 4; + + // reverts on paying too little + vm.expectRevert(IPartialLiquidationBotV3.SeizedLessThanRequiredException.selector); + vm.prank(LIQUIDATOR); + bot.liquidateExactDebt(creditAccount, link, repaidAmount, linkAmount / 2, FRIEND, priceUpdates); + + uint256 expectedSeizedAmount = repaidAmount * uint256(25 * daiPrice) / uint256(24 * newLinkPrice) / 2; + uint256 expectedFeeAmount = repaidAmount * 3 / 200; + + vm.expectCall( + address(creditManager), + abi.encodeCall( + creditManager.manageDebt, + (creditAccount, repaidAmount - expectedFeeAmount, daiMask | linkMask, ManageDebtAction.DECREASE_DEBT) + ) + ); + + vm.expectCall( + address(creditManager), + abi.encodeCall(creditManager.withdrawCollateral, (creditAccount, dai, expectedFeeAmount, treasury)) + ); + + vm.expectCall( + address(creditManager), + abi.encodeCall( + creditManager.withdrawCollateral, (creditAccount, address(crv), expectedSeizedAmount, FRIEND) + ) + ); + + vm.expectEmit(true, true, true, true, address(bot)); + emit LiquidatePartial( + address(creditManager), + creditAccount, + address(crv), + repaidAmount - expectedFeeAmount, + expectedSeizedAmount, + expectedFeeAmount + ); + + vm.prank(LIQUIDATOR); + uint256 seizedAmount = bot.liquidateExactDebt(creditAccount, link, repaidAmount, 0, FRIEND, priceUpdates); + + assertEq(seizedAmount, expectedSeizedAmount, "Incorrect seized amount"); + } } From 067f66775c89036a6d3826fbb631c56ee9163b3f Mon Sep 17 00:00:00 2001 From: Dima Lekhovitsky Date: Sun, 21 Jul 2024 17:29:14 +0300 Subject: [PATCH 4/4] feat: add serialization, rename function and event --- contracts/bots/PartialLiquidationBotV3.sol | 11 ++++- .../interfaces/IPartialLiquidationBotV3.sol | 4 +- .../PartialLiquidationBotV3.int.t.sol | 44 +++++++++---------- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/contracts/bots/PartialLiquidationBotV3.sol b/contracts/bots/PartialLiquidationBotV3.sol index b267476..75148ed 100644 --- a/contracts/bots/PartialLiquidationBotV3.sol +++ b/contracts/bots/PartialLiquidationBotV3.sol @@ -111,12 +111,17 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra feeScaleFactor = feeScaleFactor_; } + /// @notice Returns serialized bot's parameters + function serialize() external view returns (bytes memory) { + return abi.encode(treasury, minHealthFactor, maxHealthFactor, premiumScaleFactor, feeScaleFactor); + } + // ----------- // // LIQUIDATION // // ----------- // /// @inheritdoc IPartialLiquidationBotV3 - function liquidateExactDebt( + function partiallyLiquidate( address creditAccount, address token, uint256 repaidAmount, @@ -213,7 +218,9 @@ contract PartialLiquidationBotV3 is IPartialLiquidationBotV3, ReentrancyGuardTra ICreditFacadeV3(vars.creditFacade).botMulticall(creditAccount, calls); receivedAmount = IERC20(vars.receivedToken).safeBalanceOf(to) - balanceBefore; - emit LiquidatePartial(vars.creditManager, creditAccount, vars.receivedToken, repaidAmount, receivedAmount, fee); + emit PartiallyLiquidate( + vars.creditManager, creditAccount, vars.receivedToken, repaidAmount, receivedAmount, fee + ); } /// @dev Ensures that `creditAccount`'s health factor is within allowed range after partial liquidation diff --git a/contracts/interfaces/IPartialLiquidationBotV3.sol b/contracts/interfaces/IPartialLiquidationBotV3.sol index c7962ed..62a60b3 100644 --- a/contracts/interfaces/IPartialLiquidationBotV3.sol +++ b/contracts/interfaces/IPartialLiquidationBotV3.sol @@ -21,7 +21,7 @@ interface IPartialLiquidationBotV3 is IBot { /// @param repaidDebt Amount of `creditAccount`'s debt repaid /// @param seizedCollateral Amount of `token` seized from `creditAccount` /// @param fee Amount of underlying sent to the treasury as liqudiation fee - event LiquidatePartial( + event PartiallyLiquidate( address indexed creditManager, address indexed creditAccount, address indexed token, @@ -80,7 +80,7 @@ interface IPartialLiquidationBotV3 is IBot { /// @dev Reverts if `creditAccount`'s health factor is not within allowed range after liquidation /// @dev If `token` is a phantom token, it's withdrawn first, and its `depositedToken` is then sent to the liquidator. /// Both `seizedAmount` and `minSeizedAmount` refer to `depositedToken` in this case. - function liquidateExactDebt( + function partiallyLiquidate( address creditAccount, address token, uint256 repaidAmount, diff --git a/contracts/test/integration/PartialLiquidationBotV3.int.t.sol b/contracts/test/integration/PartialLiquidationBotV3.int.t.sol index 5de34d6..ccd13f1 100644 --- a/contracts/test/integration/PartialLiquidationBotV3.int.t.sol +++ b/contracts/test/integration/PartialLiquidationBotV3.int.t.sol @@ -47,7 +47,7 @@ contract UpdatablePriceFeedMock is PriceFeedMock { } contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { - event LiquidatePartial( + event PartiallyLiquidate( address indexed creditManager, address indexed creditAccount, address indexed token, @@ -176,7 +176,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // BASIC TESTS // // ----------- // - function test_I_PL_02_liquidateExactDebt_sanitizes_inputs() public creditTest { + function test_I_PL_02_partiallyLiquidate_sanitizes_inputs() public creditTest { _setUp(); PriceUpdate[] memory priceUpdates = _getPriceUpdates(); @@ -184,15 +184,15 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // reverts on trying to liquidate underlying vm.expectRevert(IPartialLiquidationBotV3.UnderlyingNotLiquidatableException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, dai, 0, 0, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, dai, 0, 0, FRIEND, priceUpdates); // reverts on trying to liquidate healthy account vm.expectRevert(CreditAccountNotLiquidatableException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, link, 0, 0, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, link, 0, 0, FRIEND, priceUpdates); } - function test_I_PL_03_liquidateExactDebt_works_as_expected() public creditTest { + function test_I_PL_03_partiallyLiquidate_works_as_expected() public creditTest { _setUp(); // make account liquidatable by lowering the collateral price @@ -204,7 +204,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // reverts on paying too little vm.expectRevert(IPartialLiquidationBotV3.SeizedLessThanRequiredException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, link, repaidAmount, linkAmount, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, link, repaidAmount, linkAmount, FRIEND, priceUpdates); uint256 expectedSeizedAmount = repaidAmount * uint256(25 * daiPrice) / uint256(24 * newLinkPrice); uint256 expectedFeeAmount = repaidAmount * 3 / 200; @@ -228,7 +228,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { ); vm.expectEmit(true, true, true, true, address(bot)); - emit LiquidatePartial( + emit PartiallyLiquidate( address(creditManager), creditAccount, link, @@ -238,7 +238,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { ); vm.prank(LIQUIDATOR); - uint256 seizedAmount = bot.liquidateExactDebt(creditAccount, link, repaidAmount, 0, FRIEND, priceUpdates); + uint256 seizedAmount = bot.partiallyLiquidate(creditAccount, link, repaidAmount, 0, FRIEND, priceUpdates); assertEq(seizedAmount, expectedSeizedAmount, "Incorrect seized amount"); } @@ -247,7 +247,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // ADVANCED SCENARIOS // // ------------------ // - function test_I_PL_04_partialLiquidation_reverts_on_inadequate_amounts() public creditTest { + function test_I_PL_04_partiallyLiquidate_reverts_on_inadequate_amounts() public creditTest { _setUp(); // make account liquidatable by lowering the collateral price @@ -257,20 +257,20 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // reverts when account is still insolvent after liquidation vm.expectRevert(NotEnoughCollateralException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, link, daiAmount / 10, 0, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, link, daiAmount / 10, 0, FRIEND, priceUpdates); // reverts when account's debt is below minimum after liquidation vm.expectRevert(BorrowAmountOutOfLimitsException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, link, daiAmount, 0, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, link, daiAmount, 0, FRIEND, priceUpdates); // reverts when repaid more debt than account had vm.expectRevert(DebtToZeroWithActiveQuotasException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, link, daiAmount * 11 / 10, 0, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, link, daiAmount * 11 / 10, 0, FRIEND, priceUpdates); } - function test_I_PL_05_partialLiquidation_does_not_steal_underlying_from_account() public creditTest { + function test_I_PL_05_partiallyLiquidate_does_not_steal_underlying_from_account() public creditTest { _setUp(); // make account liquidatable by lowering the collateral price @@ -281,12 +281,12 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { deal(dai, creditAccount, daiAmount / 10); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, link, daiAmount * 3 / 4, 0, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, link, daiAmount * 3 / 4, 0, FRIEND, priceUpdates); assertEq(tokenTestSuite.balanceOf(dai, creditAccount), daiAmount / 10, "Incorrect DAI balance"); } - function test_I_PL_06_partialLiquidation_works_as_expected_with_non_default_params() public creditTest { + function test_I_PL_06_partiallyLiquidate_works_as_expected_with_non_default_params() public creditTest { _setUp(BotParams(1.02e4, 1.05e4, 0.5e4, 0)); // set collateral price such that account's health factor is above 1 but below 1.02 @@ -297,12 +297,12 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // reverts on liquidating less than needed vm.expectRevert(IPartialLiquidationBotV3.LiquidatedLessThanNeededException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, link, 0, 0, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, link, 0, 0, FRIEND, priceUpdates); // reverts on liquidating more than needed (note that this amount works in other tests) vm.expectRevert(IPartialLiquidationBotV3.LiquidatedMoreThanNeededException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, link, daiAmount * 3 / 4, 0, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, link, daiAmount * 3 / 4, 0, FRIEND, priceUpdates); // here we liquidate 12.5K DAI of debt, which results in 12.5K / 13.75 / 0.98 ~= 928 LINK seized // after the liquidation, account has 87.5K DAI of debt and 9072 LINK of collateral, which gives @@ -310,11 +310,11 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { uint256 repaidAmount = daiAmount / 8; uint256 expectedSeizedAmount = repaidAmount * uint256(50 * daiPrice) / uint256(49 * newLinkPrice); vm.prank(LIQUIDATOR); - uint256 seizedAmount = bot.liquidateExactDebt(creditAccount, link, repaidAmount, 0, FRIEND, priceUpdates); + uint256 seizedAmount = bot.partiallyLiquidate(creditAccount, link, repaidAmount, 0, FRIEND, priceUpdates); assertApproxEqAbs(seizedAmount, expectedSeizedAmount, 1, "Incorrect seized amount"); } - function test_I_PL_07_partialLiquidation_works_as_expected_with_phantom_collateral() public creditTest { + function test_I_PL_07_partiallyLiquidate_works_as_expected_with_phantom_collateral() public creditTest { _setUp(); // make account liquidatable by lowering the collateral price @@ -341,7 +341,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { // reverts on paying too little vm.expectRevert(IPartialLiquidationBotV3.SeizedLessThanRequiredException.selector); vm.prank(LIQUIDATOR); - bot.liquidateExactDebt(creditAccount, link, repaidAmount, linkAmount / 2, FRIEND, priceUpdates); + bot.partiallyLiquidate(creditAccount, link, repaidAmount, linkAmount / 2, FRIEND, priceUpdates); uint256 expectedSeizedAmount = repaidAmount * uint256(25 * daiPrice) / uint256(24 * newLinkPrice) / 2; uint256 expectedFeeAmount = repaidAmount * 3 / 200; @@ -367,7 +367,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { ); vm.expectEmit(true, true, true, true, address(bot)); - emit LiquidatePartial( + emit PartiallyLiquidate( address(creditManager), creditAccount, address(crv), @@ -377,7 +377,7 @@ contract PartialLiquidationBotV3IntegrationTest is IntegrationTestHelper { ); vm.prank(LIQUIDATOR); - uint256 seizedAmount = bot.liquidateExactDebt(creditAccount, link, repaidAmount, 0, FRIEND, priceUpdates); + uint256 seizedAmount = bot.partiallyLiquidate(creditAccount, link, repaidAmount, 0, FRIEND, priceUpdates); assertEq(seizedAmount, expectedSeizedAmount, "Incorrect seized amount"); }