Skip to content

Commit

Permalink
feat: fix account liquidation
Browse files Browse the repository at this point in the history
Some additional `tokensToTransfer` validation is needed.

Besides, USDT credit manager doesn't fit into max contract size.
  • Loading branch information
lekhovitsky committed Oct 25, 2023
1 parent 296c65e commit 72f2290
Show file tree
Hide file tree
Showing 8 changed files with 90 additions and 103 deletions.
31 changes: 16 additions & 15 deletions contracts/credit/CreditFacadeV3.sol
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ uint256 constant OPEN_CREDIT_ACCOUNT_FLAGS = ALL_PERMISSIONS & ~(DECREASE_DEBT_P

uint256 constant CLOSE_CREDIT_ACCOUNT_FLAGS = ALL_PERMISSIONS & ~(INCREASE_DEBT_PERMISSION | WITHDRAW_PERMISSION);

uint256 constant LIQUIDATE_CREDIT_ACCOUNT_FLAGS = EXTERNAL_CALLS_PERMISSION;
uint256 constant LIQUIDATE_CREDIT_ACCOUNT_FLAGS = EXTERNAL_CALLS_PERMISSION | ADD_COLLATERAL_PERMISSION;

/// @title Credit facade V3
/// @notice Provides a user interface to open, close and liquidate leveraged positions in the credit manager,
Expand Down Expand Up @@ -289,27 +289,32 @@ contract CreditFacadeV3 is ICreditFacadeV3, ACLNonReentrantTrait {
/// - Evaluates account's collateral and debt to determine whether liquidated account is unhealthy or expired
/// - Cancels immature scheduled withdrawals and returns tokens to the account (on emergency, even mature
/// withdrawals are returned)
/// - Performs a multicall (only adapter calls allowed)
/// - Performs a multicall (only `addCollateral` and adapter calls are allowed)
/// - Erases all bots permissions
/// - Closes a credit account in the credit manager, distributing the funds between pool, owner and liquidator
/// - Liquidates a credit account in the credit manager by repaying debt to the pool, removing quotas
/// and transferring specified tokens to the liquidator
/// - If pool incurs a loss on liquidation, further borrowing through the facade is forbidden
/// - If cumulative loss from bad debt liquidations exceeds the threshold, the facade is paused
/// @notice Typically, a liquidator would swap all holdings on the account to underlying via multicall and receive
/// the premium. An alternative strategy would be to allow credit manager to take underlying shortfall from
/// the caller and receive all account's holdings directly to handle them in another way.
/// the premium in underlying (in this case, one can set `tokensToTransferMask` to `1`).
/// An alternative strategy would be to add underlying collateral to repay debt and receive specified tokens
/// to handle them in another way.
/// @notice Accounts stays open after liqudiation with zero debt and leftover funds
/// @param creditAccount Account to liquidate
/// @param to Address to send tokens left on the account after closure and funds distribution
/// @param skipTokensMask Bit mask of tokens that should be skipped
/// @param to Address to send tokens left on the account after liquidation
/// @param tokensToTransferMask Bit mask of tokens left on the account that should be sent
/// @param convertToETH Whether to unwrap WETH before sending to `to`
/// @param calls List of calls to perform before liquidating the account
/// @dev When the credit facade is paused, reverts if caller is not an approved emergency liquidator
/// @dev Reverts if `creditAccount` is not opened in connected credit manager
/// @dev Reverts if account is not liquidatable
/// @dev Reverts if account's debt was updated in the same block
/// @dev Reverts if total value of funds left on the account after repaying debt to the pool and sending specified
/// tokens to the liquidator is insufficient
function liquidateCreditAccount(
address creditAccount,
address to,
uint256 skipTokensMask,
uint256 tokensToTransferMask,
bool convertToETH,
MultiCall[] calldata calls
)
Expand Down Expand Up @@ -353,25 +358,21 @@ contract CreditFacadeV3 is ICreditFacadeV3, ACLNonReentrantTrait {
collateralDebtData.enabledTokensMask = collateralDebtData.enabledTokensMask.enable(tokensToEnable); // U:[FA-15]
}

// add replace: add underlying -> remove any enabled token [ with profit]

if (skipCalls < calls.length) {
FullCheckParams memory fullCheckParams = _multicall(
_multicall(
creditAccount, calls, collateralDebtData.enabledTokensMask, LIQUIDATE_CREDIT_ACCOUNT_FLAGS, skipCalls
); // U:[FA-16]
collateralDebtData.enabledTokensMask = fullCheckParams.enabledTokensMaskAfter; // U:[FA-16]
}

_eraseAllBotPermissions({creditAccount: creditAccount}); // U:[FA-16]

(uint256 remainingFunds, uint256 reportedLoss) = ICreditManagerV3(creditManager).liquidateCreditAccount({
creditAccount: creditAccount,
collateralDebtData: collateralDebtData,
payer: msg.sender,
to: to,
skipTokensMask: skipTokensMask,
tokensToTransferMask: tokensToTransferMask,
convertToETH: convertToETH,
isExpiredLiquidation: isExpired
isExpired: isExpired
}); // U:[FA-16]

if (reportedLoss > 0) {
Expand Down
138 changes: 62 additions & 76 deletions contracts/credit/CreditManagerV3.sol
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,6 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT

CreditAccountInfo storage newCreditAccountInfo = creditAccountInfo[creditAccount];

// accounts are reusable, so debt and interest index must be reset either when opening an account or closing it
// to make potential liquidations cheaper, they are reset here
newCreditAccountInfo.debt = 0; // U:[CM-6]
newCreditAccountInfo.cumulativeIndexLastUpdate = _poolBaseInterestIndex(); // U:[CM-6]

// newCreditAccountInfo.flags = 0;
// newCreditAccountInfo.lastDebtUpdate = 0;
// newCreditAccountInfo.borrower = onBehalfOf;
Expand Down Expand Up @@ -255,84 +250,62 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT
creditAccountsSet.remove(creditAccount); // U:[CM-8]
}

/// @notice Closes a credit account by repaying debt to the pool, removing quotas and returning account to the factory
/// @param creditAccount Account to close
/// @notice Liquidates a credit account
/// @param creditAccount Account to liquidate
/// @param collateralDebtData A struct with account's debt and collateral data
/// @param payer Address to transfer underlying from in case account's balance is insufficient
/// @param to Address to transfer tokens that left on the account after closure and repayments
/// @param skipTokensMask Bit mask of tokens that should be skipped
/// @param to Address to transfer tokens left on the account after liquidation
/// @param tokensToTransferMask Bit mask of tokens left on the account that should be sent
/// @param convertToETH If true and any of transferred tokens is WETH, it will be sent to withdrawal manager,
/// from which `to` can later claim it as ETH
/// @return remainingFunds Amount of underlying sent to account owner on liquidation
/// from which credit facade should claim it as ETH to `to` at the end of the call
/// @return remainingFunds Total value of assets left on the account after liquidation
/// @return loss Loss incurred on liquidation
/// @dev If `loss > 0`, zeroes out limits for account's quoted tokens in the quota keeper
/// @custom:expects `cdd` is a result of `calcDebtAndCollateral` in `DEBT_COLLATERAL_{x}_WITHDRAWALS` mode, where x is
/// `WITHOUT` for normal closure, `CANCEL` for liquidation and `FORCE_CANCEL` for emergency liquidation
/// @custom:invariant `remainingFunds * loss == 0`
/// @custom:expects Account is opened in this credit manager
/// @custom:expects `cdd` is a result of `calcDebtAndCollateral` in `DEBT_COLLATERAL_{x}_WITHDRAWALS` mode,
/// where x is `CANCEL` for normal liquidation and `FORCE_CANCEL` for emergency liquidation
function liquidateCreditAccount(
address creditAccount,
CollateralDebtData calldata collateralDebtData,
address payer,
address to,
uint256 skipTokensMask,
uint256 tokensToTransferMask,
bool convertToETH,
bool isExpiredLiquidation
bool isExpired
)
external
override
nonReentrant // U:[CM-5]
creditFacadeOnly // U:[CM-2]
returns (uint256 remainingFunds, uint256 loss)
{
getBorrowerOrRevert(creditAccount); // U:[CM-7]
CreditAccountInfo storage currentCreditAccountInfo = creditAccountInfo[creditAccount];
if (currentCreditAccountInfo.lastDebtUpdate == block.number) {
revert DebtUpdatedTwiceInOneBlockException();
}

{
CreditAccountInfo storage currentCreditAccountInfo = creditAccountInfo[creditAccount];
if (currentCreditAccountInfo.lastDebtUpdate == block.number) {
revert DebtUpdatedTwiceInOneBlockException(); // U:[CM-7]
}
currentCreditAccountInfo.debt = 0;
currentCreditAccountInfo.lastDebtUpdate = uint64(block.number);

// currentCreditAccountInfo.cumulativeQuotaInterest = 1;
// currentCreditAccountInfo.quotaFees = 0;
assembly {
let slot := add(currentCreditAccountInfo.slot, 2)
sstore(slot, 1)
}

remainingFunds = IERC20(underlying).safeBalanceOf({account: creditAccount});
uint256 minRemainingFunds;

{
uint256 amountToPool;
uint256 profit;

(amountToPool, remainingFunds, profit, loss) = collateralDebtData.calcLiquidationPayments({
liquidationDiscount: isExpiredLiquidation ? liquidationDiscountExpired : liquidationDiscount,
feeLiquidation: isExpiredLiquidation ? feeLiquidationExpired : feeLiquidation,
(amountToPool, minRemainingFunds, profit, loss) = collateralDebtData.calcLiquidationPayments({
liquidationDiscount: isExpired ? liquidationDiscountExpired : liquidationDiscount,
feeLiquidation: isExpired ? feeLiquidationExpired : feeLiquidation,
amountWithFeeFn: _amountWithFee,
amountMinusFeeFn: _amountMinusFee
}); // U:[CM-8]

// Computes total value of tokens which will be kept on account
if (remainingFunds > 1) {
// Calc totalValue for skipTokensMask without underlying
skipTokensMask = skipTokensMask.disable(UNDERLYING_TOKEN_MASK); // U:[CM-8]

if (skipTokensMask != 0) {
uint256 skipTokensValue = _getTokensValue(creditAccount, skipTokensMask);
unchecked {
remainingFunds = remainingFunds > skipTokensValue ? remainingFunds - skipTokensValue : 0;
}
}
}

uint256 underlyingBalance = IERC20(underlying).safeBalanceOf({account: creditAccount}); // U:[CM-8]
{
uint256 distributedFunds = amountToPool + remainingFunds + 1;

if (underlyingBalance < distributedFunds) {
unchecked {
IERC20(underlying).safeTransferFrom({
from: payer,
to: creditAccount,
amount: _amountWithFee(distributedFunds - underlyingBalance)
}); // U:[CM-8]
}

underlyingBalance = distributedFunds;
}
}

if (collateralDebtData.quotedTokens.length != 0) {
bool setLimitsToZero = loss > 0; // U:[CM-8]

Expand All @@ -346,35 +319,53 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT
if (amountToPool != 0) {
ICreditAccountBase(creditAccount).transfer({token: underlying, to: pool, amount: amountToPool}); // U:[CM-8]
unchecked {
underlyingBalance -= amountToPool;
remainingFunds -= amountToPool;
}
}

if (collateralDebtData.debt + profit + loss != 0) {
_poolRepayCreditAccount(collateralDebtData.debt, profit, loss); // U:[CM-8]
}
}

if (remainingFunds > 1) {
uint256 amountToLiquidator = underlyingBalance - remainingFunds;
if (amountToLiquidator != 0) {
_safeTokenTransfer({
creditAccount: creditAccount,
token: underlying,
to: to,
amount: amountToLiquidator,
convertToETH: convertToETH
}); // U:[CM-8]
uint256 skipTokensValue;
uint256 skipTokensMask =
collateralDebtData.enabledTokensMask.disable(UNDERLYING_TOKEN_MASK).disable(tokensToTransferMask);
if (skipTokensMask != 0) skipTokensValue = _getTokensValue(creditAccount, skipTokensMask);

if (remainingFunds + skipTokensValue < minRemainingFunds) {
revert InsufficientRemainingFundsException();
}

if (tokensToTransferMask & UNDERLYING_TOKEN_MASK != 0) {
tokensToTransferMask = tokensToTransferMask.disable(UNDERLYING_TOKEN_MASK);

uint256 amountToLiquidator = remainingFunds;
if (skipTokensValue < minRemainingFunds) {
unchecked {
amountToLiquidator -= minRemainingFunds - skipTokensValue;
}
}

skipTokensMask = skipTokensMask.enable(UNDERLYING_TOKEN_MASK); // U:[CM-8]
if (amountToLiquidator != 0) {
_safeTokenTransfer({
creditAccount: creditAccount,
token: underlying,
to: to,
amount: amountToLiquidator,
convertToETH: convertToETH
}); // U:[CM-8]
remainingFunds -= amountToLiquidator;
}
}

remainingFunds += skipTokensValue;

_batchTokensTransfer({
creditAccount: creditAccount,
to: to,
convertToETH: convertToETH,
tokensToTransferMask: collateralDebtData.enabledTokensMask.disable(skipTokensMask)
tokensToTransferMask: tokensToTransferMask
}); // U:[CM-8, 9]
}

Expand Down Expand Up @@ -700,7 +691,7 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT

cdd.debt = currentCreditAccountInfo.debt; // U:[CM-20]
cdd.cumulativeIndexLastUpdate = currentCreditAccountInfo.cumulativeIndexLastUpdate; // U:[CM-20]
cdd.cumulativeIndexNow = _poolBaseInterestIndex(); // U:[CM-20]
cdd.cumulativeIndexNow = IPoolV3(pool).baseInterestIndex(); // U:[CM-20]

if (task == CollateralCalcTask.GENERIC_PARAMS) {
return cdd; // U:[CM-20]
Expand Down Expand Up @@ -1480,11 +1471,6 @@ contract CreditManagerV3 is ICreditManagerV3, SanityCheckTrait, ReentrancyGuardT
return amount;
}

/// @dev Internal wrapper for `pool.baseInterestIndex` call to reduce contract size
function _poolBaseInterestIndex() internal view returns (uint256) {
return IPoolV3(pool).baseInterestIndex();
}

/// @dev Internal wrapper for `pool.repayCreditAccount` call to reduce contract size
function _poolRepayCreditAccount(uint256 debt, uint256 profit, uint256 loss) internal {
IPoolV3(pool).repayCreditAccount(debt, profit, loss);
Expand Down
2 changes: 1 addition & 1 deletion contracts/interfaces/ICreditFacadeV3.sol
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ interface ICreditFacadeV3 is IVersion, ICreditFacadeV3Events {
function liquidateCreditAccount(
address creditAccount,
address to,
uint256 skipTokensMask,
uint256 tokensToTransferMask,
bool convertToETH,
MultiCall[] calldata calls
) external;
Expand Down
2 changes: 2 additions & 0 deletions contracts/interfaces/ICreditFacadeV3Multicall.sol
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,15 @@ interface ICreditFacadeV3Multicall {
/// @param token Token to add
/// @param amount Amount to add
/// @dev Requires token approval from caller to the credit manager
/// @dev This method can also be called during liquidation
function addCollateral(address token, uint256 amount) external;

/// @notice Adds collateral to account using signed EIP-2612 permit message
/// @param token Token to add
/// @param amount Amount to add
/// @param deadline Permit deadline
/// @dev `v`, `r`, `s` must be a valid signature of the permit message from caller to the credit manager
/// @dev This method can also be called during liquidation
function addCollateralWithPermit(address token, uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
external;

Expand Down
5 changes: 2 additions & 3 deletions contracts/interfaces/ICreditManagerV3.sol
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,10 @@ interface ICreditManagerV3 is IVersion, ICreditManagerV3Events {
function liquidateCreditAccount(
address creditAccount,
CollateralDebtData calldata collateralDebtData,
address payer,
address to,
uint256 skipTokensMask,
uint256 tokensToTransferMask,
bool convertToETH,
bool isExpiredLiquidation
bool isExpired
) external returns (uint256 remainingFunds, uint256 loss);

function manageDebt(address creditAccount, uint256 amount, uint256 enabledTokensMask, ManageDebtAction action)
Expand Down
3 changes: 3 additions & 0 deletions contracts/interfaces/IExceptions.sol
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ error IncreaseQuotaOnZeroDebtAccountException();
/// @notice Thrown when attempting to close an account with non-zero debt
error CloseAccountWithNonZeroDebtException();

/// @notice Thrown when value of funds remaining on the account after liquidation is insufficient
error InsufficientRemainingFundsException();

/// @notice Thrown when Credit Facade tries to write over a non-zero active Credit Account
error ActiveCreditAccountOverridenException();

Expand Down
6 changes: 1 addition & 5 deletions contracts/libraries/CollateralLogic.sol
Original file line number Diff line number Diff line change
Expand Up @@ -284,12 +284,8 @@ library CollateralLogic {
address priceOracle,
uint256 tokensToCheckMask
) internal view returns (uint256 totalValueUSD) {
uint256 totalValueUSD;

while (tokensToCheckMask != 0) {
uint256 tokenMask;
tokenMask = tokensToCheckMask & uint256(-int256(tokensToCheckMask));

uint256 tokenMask = tokensToCheckMask & uint256(-int256(tokensToCheckMask));
(address token,) = collateralTokenByMaskFn(tokenMask, false);

uint256 balance = IERC20(token).safeBalanceOf({account: creditAccount});
Expand Down
6 changes: 3 additions & 3 deletions contracts/test/unit/credit/CreditFacadeV3.unit.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve
creditFacade.liquidateCreditAccount({
creditAccount: DUMB_ADDRESS,
to: DUMB_ADDRESS,
skipTokensMask: 0,
tokensToTransferMask: 1,
convertToETH: false,
calls: new MultiCall[](0)
});
Expand Down Expand Up @@ -271,7 +271,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve
creditFacade.liquidateCreditAccount({
creditAccount: DUMB_ADDRESS,
to: DUMB_ADDRESS,
skipTokensMask: 0,
tokensToTransferMask: 1,
convertToETH: false,
calls: new MultiCall[](0)
});
Expand Down Expand Up @@ -310,7 +310,7 @@ contract CreditFacadeV3UnitTest is TestHelper, BalanceHelper, ICreditFacadeV3Eve
creditFacade.liquidateCreditAccount({
creditAccount: DUMB_ADDRESS,
to: DUMB_ADDRESS,
skipTokensMask: 0,
tokensToTransferMask: 1,
convertToETH: false,
calls: new MultiCall[](0)
});
Expand Down

0 comments on commit 72f2290

Please sign in to comment.