20,000 USDC
View results
Submission Details
Severity: medium

Lender contract allow borrower to overcollateralize the Loan without a real need

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:

  1. 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.

  2. Alice execute a borrow by requesting 1 wei of loanToken and provides 1000 ether of collateral

  3. Lender start an auction for the loan

  4. 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

// SPDX-License-Identifier: UNLICENSED
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];
// calculate the accrued interest
(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

// SPDX-License-Identifier: UNLICENSED
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 allows the borrower to over-collateralize the loan without a real need
// Let's say that the borrower has 100 ether of collateral and want to take a loan
// The pool allows the user to provide the WHOLE amount of collateral
// and just getting back the minLoanSize that is not correlated at all with the maxLoanRatio
Pool memory pool = lender.getPoolInfo(poolId1);
// Borrower borrows from first pool
uint256 borrowerCollateral = 100 ether;
uint256 maxBorroweableAmount = ((pool.maxLoanRatio * borrowerCollateral) / (10 ** 18));
uint256 actualBorrowAmountRequested = pool.minLoanSize; // 1 ether
assertGt(maxBorroweableAmount, actualBorrowAmountRequested);
Borrow[] memory borrows = new Borrow[](1);
// Assert that the user can overcollateralize the loan
uint256 snapshot = vm.snapshot();
borrows[0] = Borrow({
poolId: poolId1,
debt: actualBorrowAmountRequested,
collateral: borrowerCollateral
});
vm.prank(borrower);
lender.borrow(borrows);
// Assert that the user could get a much higher debt given such collateral
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)

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.