Summary
The borrow and refinance function allows the borrower to over collateralize their loan without a real need to do that.
Once the loan is auctioned and then seized, the borrower will lose the whole collateral provided.
Vulnerability Details
The borrow
function allows the borrower to open a Loan
on a specific lender's pool by specifying both the collateral
and debt
amount of the loan.
Unlike other lending platform, a Loan
cannot be liquidated instantly if the Loan becomes liquidable and there's no such thing as a borrower health factor associated with the borrower's account.
There is no reason for the borrower to over-collateralize their Loan when it's created or refinanced, the only check that should be needed is that the Loan's loanRatio
should be less than the pool's maxLoanRatio
.
With the current implementation, depending on the configuration of the pool, a borrower could borrow 2000 USDT
by providing 1000 ETH
as collateral when in reality the needed collateral could have been 2 ETH
.
If the lender starts an auction and then someone seizes the Loan, the protocol will send all the 1000 ETH
to the lender
while in reality he should just receive 2 ETH
(+ fees, liquidation bonus and so on). The rest of the collateral should be returned to the borrower
.
Impact
The borrower can over collateralize their Loan without a real need to do that (there's not an active liquidation process and there's not a health factor related to the borrower's account).
When an auction ends, the whole amount of collateral of the borrower will be sized without any correlation to the actual amount of debt that the borrower has taken for such a loan.
Example:
Pool1 allows the borrowers to borrow 10 ether
of loanToken
by providing 100 ether
of collateralToken
. The pool's minLoanSize
is equal to 1 wei
.
Alice execute a borrow by requesting 1 wei
of loanToken
and provides 1000 ether
of collateral
Lender start an auction for the loan
Auction ends and someone calls seizeLoan
. The whole collateral of the borrower will be sent to the loan.lender
without any correlation to the actual debt of the loan that has been taken.
Tools Used
Manual + foundry test
BaseLender.sol
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Lender.sol";
import {ERC20} from "solady/src/tokens/ERC20.sol";
contract TERC20 is ERC20 {
function name() public pure override returns (string memory) {
return "Test ERC20";
}
function symbol() public pure override returns (string memory) {
return "TERC20";
}
function mint(address _to, uint256 _amount) public {
_mint(_to, _amount);
}
}
contract WrappedLender is Lender {
function getLoanDebtDetail(uint256 loanId) external view returns (uint256 fullDebt, uint256 interest, uint256 fees) {
Loan memory loan = loans[loanId];
(interest, fees) = _calculateInterest(loan);
fullDebt = loan.debt + interest + fees;
}
function getPoolInfo(bytes32 poolId) external view returns (Pool memory) {
return pools[poolId];
}
function getLoanInfo(uint256 loanId) external view returns (Loan memory) {
return loans[loanId];
}
}
contract BaseLender is Test {
WrappedLender public lender;
TERC20 public loanToken;
TERC20 public collateralToken;
address public lender1 = address(0x1);
address public lender2 = address(0x2);
address public borrower = address(0x3);
address public fees = address(0x4);
function setUp() public virtual {
lender = new WrappedLender();
loanToken = new TERC20();
collateralToken = new TERC20();
loanToken.mint(address(lender1), 100000 ether);
loanToken.mint(address(lender2), 100000 ether);
collateralToken.mint(address(borrower), 100000 ether);
vm.startPrank(lender1);
loanToken.approve(address(lender), 1000000 ether);
collateralToken.approve(address(lender), 1000000 ether);
vm.startPrank(lender2);
loanToken.approve(address(lender), 1000000 ether);
collateralToken.approve(address(lender), 1000000 ether);
vm.startPrank(borrower);
loanToken.approve(address(lender), 1000000 ether);
collateralToken.approve(address(lender), 1000000 ether);
}
function borrow(address _borrower, bytes32 poolId) public {
vm.startPrank(_borrower);
Borrow memory b = Borrow({
poolId: poolId,
debt: 100 ether,
collateral: 100 ether
});
Borrow[] memory borrows = new Borrow[](1);
borrows[0] = b;
lender.borrow(borrows);
vm.stopPrank();
}
function createPool(address _lender) public returns (bytes32){
vm.startPrank(_lender);
Pool memory p = Pool({
lender: _lender,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 ether,
poolBalance: 1000 ether,
maxLoanRatio: 2 ether,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
});
bytes32 poolId = lender.setPool(p);
vm.stopPrank();
return poolId;
}
function startAuction(address _lender, uint256 loanId) public {
uint256[] memory loans = new uint256[](1);
loans[0] = loanId;
vm.prank(_lender);
lender.startAuction(loans);
}
function seizeLoan(uint256 loanId) public {
uint256[] memory loans = new uint256[](1);
loans[0] = loanId;
lender.seizeLoan(loans);
}
function getPoolId(address _lender) public returns (bytes32) {
return keccak256(
abi.encode(
address(_lender),
address(loanToken),
address(collateralToken)
)
);
}
}
BorrowerCanOvercollaralize.t.sol
pragma solidity ^0.8.13;
import "./BaseLender.sol";
contract BorrowerCanOvercollaralize is BaseLender {
function setUp() override public {
super.setUp();
}
function testBorrowerCanOvercollateralize() public {
vm.prank(lender1);
uint256 poolMaxLoanRatio = 2 ether;
bytes32 poolId1 = lender.setPool(Pool({
lender: lender1,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 1 ether,
poolBalance: 1000 ether,
maxLoanRatio: poolMaxLoanRatio,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
}));
Pool memory pool = lender.getPoolInfo(poolId1);
uint256 borrowerCollateral = 100 ether;
uint256 maxBorroweableAmount = ((pool.maxLoanRatio * borrowerCollateral) / (10 ** 18));
uint256 actualBorrowAmountRequested = pool.minLoanSize;
assertGt(maxBorroweableAmount, actualBorrowAmountRequested);
Borrow[] memory borrows = new Borrow[](1);
uint256 snapshot = vm.snapshot();
borrows[0] = Borrow({
poolId: poolId1,
debt: actualBorrowAmountRequested,
collateral: borrowerCollateral
});
vm.prank(borrower);
lender.borrow(borrows);
vm.revertTo(snapshot);
borrows[0] = Borrow({
poolId: poolId1,
debt: maxBorroweableAmount,
collateral: borrowerCollateral
});
vm.prank(borrower);
lender.borrow(borrows);
}
}
Recommendations
The borrow
and refinance
function should allow the user to take a loan only based on one parameter (debt or collateral) and calculate the other (depending on the needed) based on the pool's maxLoanRatio
.
For example, when the user's calls borrow
he could just specify the collateral
amount and the debt that he can receive is calculated by debt = (collateral * pool.maxLoanRatio) / (10 ** 18)
If the protocol does want to keep the borrow
and refinance
logic as it is right now, it should at least give back the borrower
part of the collateral (if the loan was over-collateralized) when the liquidation happens (given the loan terms)