Prepared by: 0xVolodya, Independent Security Researcher Date: May 9 to May 16, 2023 |
Venus is a decentralized finance (DeFi) algorithmic money market protocol on BNB Chain.
Decentralized lending pools are very similar to traditional lending services offered by banks, except that they are offered by P2P decentralized platforms. Users can leverage assets by borrowing and lending assets listed in a pool. Lending pools help crypto holders earn a substantial income through interest paid on their supplied assets and access assets they don't currently own without selling any of their portfolio.
ID | Title | Severity | Fixed |
---|---|---|---|
[H-01] | blocksPerYear is not sync like it supposed to in bsc chain | High | ✓ |
[M-01] | Users can borrow the borrowCap amount, but they should borrow less than that and not equal | Medium | ✓ |
[M-02] | Sometimes calculateBorrowerReward and calculateSupplierReward return incorrect results | Medium | ✓ |
[M-03] | First Deposit Bug | Medium | ✓ |
[M-04] | Inconsistent scaling of USD in bad debt in the project. | Medium | ✓ |
[M-05] | There are no incentives for users to start bidding after the auction restarts. | Medium | x |
[M-06] | There are no restriction on auction duration | Medium | x |
[M-07] | The Oracle returns incorrect prices because it does not call updatePrice before calling getUnderlyingPrice | Medium | ✓ |
[M-08] | Repayments Paused While Liquidations Enabled | Medium | ✓ |
Detailed description of the impact of this finding. The variable calculations in WhitePaperInterestRateModel are incorrect compared to BaseJumpRateModelV2 due to a lack of synchronization in blocksPerYear.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. blocksPerYear should be 10512000 just like in WhitePaperInterestRateModel for bsc chain
/**
* @notice The approximate number of blocks per year that is assumed by the interest rate model
*/
uint256 public constant blocksPerYear = 2102400;
contracts/WhitePaperInterestRateModel.sol#L17
/**
* @notice The approximate number of blocks per year that is assumed by the interest rate model
*/
uint256 public constant blocksPerYear = 10512000;
contracts/BaseJumpRateModelV2.sol#L23
There will be invalid baseRatePerBlock
and multiplierPerBlock
constructor(uint256 baseRatePerYear, uint256 multiplierPerYear) {
baseRatePerBlock = baseRatePerYear / blocksPerYear;
multiplierPerBlock = multiplierPerYear / blocksPerYear;
emit NewInterestParams(baseRatePerBlock, multiplierPerBlock);
}
contracts/WhitePaperInterestRateModel.sol#L36
Manual
Change to 10512000
Detailed description of the impact of this finding. Users can borrow up to the borrowCap amount, but they should borrow less than that.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
According to the docs from the code function Borrowing that brings total borrows to borrow cap will revert
/**
* @notice Set the given borrow caps for the given vToken markets. Borrowing that brings total borrows to or above borrow cap will revert.
...
contracts/Comptroller.sol#L839
this means that whevener user should not be able to hit borrowCap and revert if nextTotalBorrows == borrowCap
but it doesn't
if (borrowCap != type(uint256).max) {
uint256 totalBorrows = VToken(vToken).totalBorrows();
uint256 nextTotalBorrows = totalBorrows + borrowAmount;
// @audit should be, users can borrow more than allowed
// if (nextTotalBorrows >= borrowCap) {
if (nextTotalBorrows > borrowCap) {
revert BorrowCapExceeded(vToken, borrowCap);
}
}
contracts/Comptroller.sol#L354
if (borrowCap != type(uint256).max) {
uint256 totalBorrows = VToken(vToken).totalBorrows();
uint256 nextTotalBorrows = totalBorrows + borrowAmount;
- if (nextTotalBorrows > borrowCap) {
+ if (nextTotalBorrows >= borrowCap) {
revert BorrowCapExceeded(vToken, borrowCap);
}
}
Detailed description of the impact of this finding. Sometimes calculateBorrowerReward and calculateSupplierReward return incorrect results
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
Whenever user wants to know his pending rewards he calls getPendingRewards
sometimes it returns incorrect results.
There is a bug inside calculateBorrowerReward
and calculateSupplierReward
function calculateBorrowerReward(
address vToken,
RewardsDistributor rewardsDistributor,
address borrower,
RewardTokenState memory borrowState,
Exp memory marketBorrowIndex
) internal view returns (uint256) {
Double memory borrowIndex = Double({ mantissa: borrowState.index });
Double memory borrowerIndex = Double({
mantissa: rewardsDistributor.rewardTokenBorrowerIndex(vToken, borrower)
});
// @audit
// if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) {
if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa > 0) {
// Covers the case where users borrowed tokens before the market's borrow state index was set
borrowerIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex();
}
Double memory deltaIndex = sub_(borrowIndex, borrowerIndex);
uint256 borrowerAmount = div_(VToken(vToken).borrowBalanceStored(borrower), marketBorrowIndex);
uint256 borrowerDelta = mul_(borrowerAmount, deltaIndex);
return borrowerDelta;
}
contracts/Lens/PoolLens.sol#L495
function calculateSupplierReward(
address vToken,
RewardsDistributor rewardsDistributor,
address supplier,
RewardTokenState memory supplyState
) internal view returns (uint256) {
Double memory supplyIndex = Double({ mantissa: supplyState.index });
Double memory supplierIndex = Double({
mantissa: rewardsDistributor.rewardTokenSupplierIndex(vToken, supplier)
});
// @audit
// if (supplierIndex.mantissa == 0 && supplyIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) {
if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) {
// Covers the case where users supplied tokens before the market's supply state index was set
supplierIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex();
}
Double memory deltaIndex = sub_(supplyIndex, supplierIndex);
uint256 supplierTokens = VToken(vToken).balanceOf(supplier);
uint256 supplierDelta = mul_(supplierTokens, deltaIndex);
return supplierDelta;
}
contracts/Lens/PoolLens.sol#L516
Inside rewardsDistributor original functions written likes this
function _distributeSupplierRewardToken(address vToken, address supplier) internal {
...
if (supplierIndex == 0 && supplyIndex >= rewardTokenInitialIndex) {
// Covers the case where users supplied tokens before the market's supply state index was set.
// Rewards the user with REWARD TOKEN accrued from the start of when supplier rewards were first
// set for the market.
supplierIndex = rewardTokenInitialIndex;
}
...
}
contracts/Rewards/RewardsDistributor.sol#L340
function _distributeBorrowerRewardToken(
address vToken,
address borrower,
Exp memory marketBorrowIndex
) internal {
...
if (borrowerIndex == 0 && borrowIndex >= rewardTokenInitialIndex) {
// Covers the case where users borrowed tokens before the market's borrow state index was set.
// Rewards the user with REWARD TOKEN accrued from the start of when borrower rewards were first
// set for the market.
borrowerIndex = rewardTokenInitialIndex;
}
...
}
Rewards/RewardsDistributor.sol#L374
function calculateSupplierReward(
address vToken,
RewardsDistributor rewardsDistributor,
address supplier,
RewardTokenState memory supplyState
) internal view returns (uint256) {
Double memory supplyIndex = Double({ mantissa: supplyState.index });
Double memory supplierIndex = Double({
mantissa: rewardsDistributor.rewardTokenSupplierIndex(vToken, supplier)
});
- if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > 0) {
+ if (supplierIndex.mantissa == 0 && supplyIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) {
// Covers the case where users supplied tokens before the market's supply state index was set
supplierIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex();
}
Double memory deltaIndex = sub_(supplyIndex, supplierIndex);
uint256 supplierTokens = VToken(vToken).balanceOf(supplier);
uint256 supplierDelta = mul_(supplierTokens, deltaIndex);
return supplierDelta;
}
function calculateBorrowerReward(
address vToken,
RewardsDistributor rewardsDistributor,
address borrower,
RewardTokenState memory borrowState,
Exp memory marketBorrowIndex
) internal view returns (uint256) {
Double memory borrowIndex = Double({ mantissa: borrowState.index });
Double memory borrowerIndex = Double({
mantissa: rewardsDistributor.rewardTokenBorrowerIndex(vToken, borrower)
});
- if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa > 0) {
+ if (borrowerIndex.mantissa == 0 && borrowIndex.mantissa >= rewardsDistributor.rewardTokenInitialIndex()) {
// Covers the case where users borrowed tokens before the market's borrow state index was set
borrowerIndex.mantissa = rewardsDistributor.rewardTokenInitialIndex();
}
Double memory deltaIndex = sub_(borrowIndex, borrowerIndex);
uint256 borrowerAmount = div_(VToken(vToken).borrowBalanceStored(borrower), marketBorrowIndex);
uint256 borrowerDelta = mul_(borrowerAmount, deltaIndex);
return borrowerDelta;
}
The CToken is a yield bearing asset which is minted when any user deposits some units of
underlying
tokens. The amount of CTokens minted to a user is calculated based upon
the amount of underlying
tokens user is depositing.
As per the implementation of CToken contract, there exist two cases for CToken amount calculation:
- First deposit - when
VToken.totalSupply()
is0
. - All subsequent deposits.
Here is the actual CToken code (extra code and comments clipped for better reading):
function _exchangeRateStored() internal view virtual returns (uint256) {
uint256 _totalSupply = totalSupply;
if (_totalSupply == 0) {
/*
* If there are no tokens minted:
* exchangeRate = initialExchangeRate
*/
return initialExchangeRateMantissa;
} else {
/*
* Otherwise:
* exchangeRate = (totalCash + totalBorrows + badDebt - totalReserves) / totalSupply
*/
uint256 totalCash = _getCashPrior();
uint256 cashPlusBorrowsMinusReserves = totalCash + totalBorrows + badDebt - totalReserves;
uint256 exchangeRate = (cashPlusBorrowsMinusReserves * expScale) / _totalSupply;
return exchangeRate;
}
}
function _mintFresh(
address payer,
address minter,
uint256 mintAmount
) internal {
/* Fail if mint not allowed */
comptroller.preMintHook(address(this), minter, mintAmount);
/* Verify market's block number equals current block number */
if (accrualBlockNumber != _getBlockNumber()) {
revert MintFreshnessCheck();
}
Exp memory exchangeRate = Exp({ mantissa: _exchangeRateStored() });
...
A sophisticated attack can impact all user deposits until the lending protocols owners and users are notified and contracts are paused. Since this attack is a replicable attack it can be performed continuously to steal the deposits of all depositors that try to deposit into the CToken contract.
The loss amount will be the sum of all deposits done by users into the CToken multiplied by the underlying token's price.
Suppose there are 10
users and each of them tries to deposit 1,000,000
underlying tokens into the CToken contract. Price of underlying token is $1
.
Total loss (in $) = $10,000,000
The fix to prevent this issue would be to enforce a minimum deposit that cannot be withdrawn. This can be done by minting small amount of CToken units to 0x00
address on the first deposit.
Instead of a fixed 1000
value an admin controlled parameterized value can also be used to control the burn amount on a per CToken basis.
Detailed description of the impact of this finding. Inconsistent scaling of USD in bad debt in the project.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
usdvalue is being calculated differently in project. This is how its calculated for auction, As you can see its being scaled down by / 1e18
. Its the only place in project where getUnderlyingPrice * tokenAmount
is being scaled down.
for (uint256 i; i < marketsCount; ++i) {
uint256 marketBadDebt = vTokens[i].badDebt();
priceOracle.updatePrice(address(vTokens[i]));
uint256 usdValue = (priceOracle.getUnderlyingPrice(address(vTokens[i])) * marketBadDebt) / 1e18;
poolBadDebt = poolBadDebt + usdValue;
auction.markets[i] = vTokens[i];
auction.marketDebt[vTokens[i]] = marketBadDebt;
marketsDebt[i] = marketBadDebt;
}
contracts/Shortfall/Shortfall.sol#L393
This is how badDebtUsd is being calculated inside poollens
for (uint256 i; i < markets.length; ++i) {
BadDebt memory badDebt;
badDebt.vTokenAddress = address(markets[i]);
badDebt.badDebtUsd =
VToken(address(markets[i])).badDebt() *
priceOracle.getUnderlyingPrice(address(markets[i]));
badDebtSummary.badDebts[i] = badDebt;
totalBadDebtUsd = totalBadDebtUsd + badDebt.badDebtUsd;
}
contracts/Lens/PoolLens.sol#L268
Manual
As I understood, scaling down should be removed inside shortfall due the only place in the project where getUnderlyingPrice * tokenAmount
is being scaled down
for (uint256 i; i < marketsCount; ++i) {
uint256 marketBadDebt = vTokens[i].badDebt();
priceOracle.updatePrice(address(vTokens[i]));
- uint256 usdValue = (priceOracle.getUnderlyingPrice(address(vTokens[i])) * marketBadDebt) / 1e18;
+ uint256 usdValue = (priceOracle.getUnderlyingPrice(address(vTokens[i])) * marketBadDebt);
poolBadDebt = poolBadDebt + usdValue;
auction.markets[i] = vTokens[i];
auction.marketDebt[vTokens[i]] = marketBadDebt;
marketsDebt[i] = marketBadDebt;
}
Detailed description of the impact of this finding. There are no incentives for users to start bidding after the auction restarts this means that auction might restarts forever.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. If the auction becomes stale without receiving any bids, anyone can restart the auction by calling restartAuction. If nobody is placing bids this means that its not profitable for users, so protocol should make some changes to auction to incentive users to start placing bids. E.x. makerdao increasing lot(funds) by 50% on the restart.
If the auction expires without receiving any bids, anyone can restart the auction by calling tick(uint auction_id). This will do two things:
- It resets bids[id].end to now + tau
- It resets bids[id].lot to bids[id].lot * pad / ONE
Make it appealing for users to start bidding after the auction becomes stale. For example, you can consider calling swapPoolsAssets
to increase the riskFundBalance
inside the _startAuction
function
Detailed description of the impact of this finding. There are no restriction on auction duration, if nextBidderBlockLimit will be changed then auction might never ends
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
There are no restriction on auction duration, current length of auction is nextBidderBlockLimit * MAX_BPS = 10*10000
10*10000 / 28753 ~ 3.5 days. That's the maximum amount of time a user can prolong the auction, its depens on auction.highestBidBlock + nextBidderBlockLimit
inside closeAuction
function closeAuction(address comptroller) external nonReentrant {
...
require(
block.number > auction.highestBidBlock + nextBidderBlockLimit && auction.highestBidder != address(0),
"waiting for next bidder. cannot close auction"
);
...
contracts/Shortfall/Shortfall.sol#L214
According to the code nextBidderBlockLimit can be changed, e.x. admin would like to change it to the same as makerdao to 6 hours ~ 7188 blocks
ttl: Bid duration (for example, 6 hours). The auction ends if no new bid is placed during this time.
function updateNextBidderBlockLimit(uint256 _nextBidderBlockLimit) external {
_checkAccessAllowed("updateNextBidderBlockLimit(uint256)");
require(_nextBidderBlockLimit != 0, "_nextBidderBlockLimit must not be 0");
uint256 oldNextBidderBlockLimit = nextBidderBlockLimit;
nextBidderBlockLimit = _nextBidderBlockLimit;
emit NextBidderBlockLimitUpdated(oldNextBidderBlockLimit, _nextBidderBlockLimit);
}
contracts/Shortfall/Shortfall.sol#L293
That would mean that max lenght of auction would be nextBidderBlockLimit * MAX_BPS = 7188*10000
7188 *10000 / 28753 ~ 2499 days
Which is too much, without any way to restrict an auction duration.
Introduce auction time limit variable so the system will be for flexible for the admin without never ending auction just like makerDao has tau
tau: Auction duration (for example, 24 hours). The auction ends after this period under all circumstances.
[M-07] The Oracle returns incorrect prices because it does not call updatePrice before calling getUnderlyingPrice
Detailed description of the impact of this finding. The Oracle returns incorrect prices because it does not call updatePrice before calling getUnderlyingPrice
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
According to the comments from oracle file. updatePrice should always be called before calling getUnderlyingPrice.
/**
* @notice Updates the TWAP pivot oracle price.
* @dev This function should always be called before calling getUnderlyingPrice
* @param vToken vToken address
*/
function updatePrice(address vToken) external override {
(address pivotOracle, bool pivotOracleEnabled) = getOracle(vToken, OracleRole.PIVOT);
if (pivotOracle != address(0) && pivotOracleEnabled) {
//if pivot oracle is not TwapOracle it will revert so we need to catch the revert
try TwapInterface(pivotOracle).updateTwap(vToken) {} catch {}
}
}
/contracts/ResilientOracle.sol#L175
There are functions in the code that do not call updatePrice before calling getUnderlyingPrice while some functions call updatePrice. E.x. this function is not calling updatePrice calls _checkRedeemAllowed -> calls _getHypotheticalLiquiditySnapshot -> calls _safeGetUnderlyingPrice(asset)
function exitMarket(address vTokenAddress) external override returns (uint256) {
_checkActionPauseState(vTokenAddress, Action.EXIT_MARKET);
VToken vToken = VToken(vTokenAddress);
/* Get sender tokensHeld and amountOwed underlying from the vToken */
(uint256 tokensHeld, uint256 amountOwed, ) = _safeGetAccountSnapshot(vToken, msg.sender);
/* Fail if the sender has a borrow balance */
if (amountOwed != 0) {
revert NonzeroBorrowBalance();
}
/* Fail if the sender is not permitted to redeem all of their tokens */
_checkRedeemAllowed(vTokenAddress, msg.sender, tokensHeld);
Market storage marketToExit = markets[address(vToken)];
/* Return true if the sender is not already ‘in’ the market */
if (!marketToExit.accountMembership[msg.sender]) {
return NO_ERROR;
}
/* Set vToken account membership to false */
delete marketToExit.accountMembership[msg.sender];
/* Delete vToken from the account’s list of assets */
// load into memory for faster iteration
VToken[] memory userAssetList = accountAssets[msg.sender];
uint256 len = userAssetList.length;
uint256 assetIndex = len;
for (uint256 i; i < len; ++i) {
if (userAssetList[i] == vToken) {
assetIndex = i;
break;
}
}
// We *must* have found the asset in the list or our redundant data structure is broken
assert(assetIndex < len);
// copy last item in list to location of item to be removed, reduce length by 1
VToken[] storage storedList = accountAssets[msg.sender];
storedList[assetIndex] = storedList[storedList.length - 1];
storedList.pop();
emit MarketExited(vToken, msg.sender);
return NO_ERROR;
}
contracts/Comptroller.sol#L199
The same way you can track that these functions doesn't call updatePrice before calling getUnderlyingPrice
liquidateCalculateSeizeTokens
calls _safeGetUnderlyingPrice -> getUnderlyingPrice
getHypotheticalAccountLiquidity
calls _getHypotheticalLiquiditySnapshot -> _safeGetUnderlyingPrice -> getUnderlyingPrice
setCollateralFactor
inside Comptroller.sol
Inside PoolLens.sol
function getPoolBadDebt(address comptrollerAddress) external view returns (BadDebtSummary memory) {
uint256 totalBadDebtUsd;
// Get every market in the pool
ComptrollerViewInterface comptroller = ComptrollerViewInterface(comptrollerAddress);
VToken[] memory markets = comptroller.getAllMarkets();
PriceOracle priceOracle = comptroller.oracle();
BadDebt[] memory badDebts = new BadDebt[](markets.length);
BadDebtSummary memory badDebtSummary;
badDebtSummary.comptroller = comptrollerAddress;
badDebtSummary.badDebts = badDebts;
// // Calculate the bad debt is USD per market
for (uint256 i; i < markets.length; ++i) {
BadDebt memory badDebt;
badDebt.vTokenAddress = address(markets[i]);
badDebt.badDebtUsd =
VToken(address(markets[i])).badDebt() *
priceOracle.getUnderlyingPrice(address(markets[i]));
badDebtSummary.badDebts[i] = badDebt;
totalBadDebtUsd = totalBadDebtUsd + badDebt.badDebtUsd;
}
badDebtSummary.totalBadDebtUsd = totalBadDebtUsd;
return badDebtSummary;
}
contracts/Lens/PoolLens.sol#L268
function vTokenUnderlyingPrice(VToken vToken) public view returns (VTokenUnderlyingPrice memory) {
ComptrollerViewInterface comptroller = ComptrollerViewInterface(address(vToken.comptroller()));
PriceOracle priceOracle = comptroller.oracle();
return
VTokenUnderlyingPrice({
vToken: address(vToken),
underlyingPrice: priceOracle.getUnderlyingPrice(address(vToken))
});
}
contracts/Lens/PoolLens.sol#L408
I think it will be cleaner to place update price inside _safeGetUnderlyingPrice instead of inside every function like its now. Code wil be cleaner as well. Add updatePrice to other functions inside PoolLens as well
function _safeGetUnderlyingPrice(VToken asset) internal view returns (uint256) {
+ oracle.updatePrice(address(asset));
uint256 oraclePriceMantissa = oracle.getUnderlyingPrice(address(asset));
if (oraclePriceMantissa == 0) {
revert PriceError(address(asset));
}
return oraclePriceMantissa;
}
Detailed description of the impact of this finding. It is possible to have repayments Paused While Liquidations Enabled
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept. Lending & Borrowing DeFi platforms should never be able to enter a state where repayments are paused but liquidations are enabled, since this would unfairly prevent Borrowers from making their repayments while still allowing them to be liquidated. If repayments can be paused then liquidations must also be paused at the same time.
Right now if _checkActionPauseState(vToken, Action.REPAY)
is true and _checkActionPauseState(vTokenBorrowed, Action.LIQUIDATE);
is false than Liquidations is enable but repayment not.
function preRepayHook(address vToken, address borrower) external override {
_checkActionPauseState(vToken, Action.REPAY);
oracle.updatePrice(vToken);
if (!markets[vToken].isListed) {
revert MarketNotListed(address(vToken));
}
// Keep the flywheel moving
uint256 rewardDistributorsCount = rewardsDistributors.length;
for (uint256 i; i < rewardDistributorsCount; ++i) {
Exp memory borrowIndex = Exp({ mantissa: VToken(vToken).borrowIndex() });
rewardsDistributors[i].updateRewardTokenBorrowIndex(vToken, borrowIndex);
rewardsDistributors[i].distributeBorrowerRewardToken(vToken, borrower, borrowIndex);
}
}
contracts/Comptroller.sol#L390
function preLiquidateHook(
address vTokenBorrowed,
address vTokenCollateral,
address borrower,
uint256 repayAmount,
bool skipLiquidityCheck
) external override {
// Pause Action.LIQUIDATE on BORROWED TOKEN to prevent liquidating it.
// If we want to pause liquidating to vTokenCollateral, we should pause
// Action.SEIZE on it
_checkActionPauseState(vTokenBorrowed, Action.LIQUIDATE);
+ _checkActionPauseState(vTokenBorrowed, Action.REPAY);
oracle.updatePrice(vTokenBorrowed);
oracle.updatePrice(vTokenCollateral);
if (!markets[vTokenBorrowed].isListed) {
revert MarketNotListed(address(vTokenBorrowed));
}
if (!markets[vTokenCollateral].isListed) {
revert MarketNotListed(address(vTokenCollateral));
}
uint256 borrowBalance = VToken(vTokenBorrowed).borrowBalanceStored(borrower);
/* Allow accounts to be liquidated if the market is deprecated or it is a forced liquidation */
if (skipLiquidityCheck || isDeprecated(VToken(vTokenBorrowed))) {
if (repayAmount > borrowBalance) {
revert TooMuchRepay();
}
return;
}
/* The borrower must have shortfall and collateral > threshold in order to be liquidatable */
AccountLiquiditySnapshot memory snapshot = _getCurrentLiquiditySnapshot(borrower, _getLiquidationThreshold);
if (snapshot.totalCollateral <= minLiquidatableCollateral) {
/* The liquidator should use either liquidateAccount or healAccount */
revert MinimalCollateralViolated(minLiquidatableCollateral, snapshot.totalCollateral);
}
if (snapshot.shortfall == 0) {
revert InsufficientShortfall();
}
/* The liquidator may not repay more than what is allowed by the closeFactor */
uint256 maxClose = mul_ScalarTruncate(Exp({ mantissa: closeFactorMantissa }), borrowBalance);
if (repayAmount > maxClose) {
revert TooMuchRepay();
}
}