20,000 USDC
View results
Submission Details
Severity: high
Valid

`Lender.refinance` is double deducting the refinanced debt amount from the new pool balance

Summary

The new pool balance is deducted by debt amount twice during the refinance execution.

Vulnerability Details

The current implementation of the refinance function is updating the pool.poolBalance in two different places. The result of the operation is that the pool.poolBalance will be reduced by debt amount twice.

The first and correct one is at line L636 where the function updates both the poolBalance and outstandingLoans

// now lets deduct our tokens from the new pool
_updatePoolBalance(poolId, pools[poolId].poolBalance - debt);
pools[poolId].outstandingLoans += debt;

The problem is that at the very end, the function updates a second time the pool balance at line L698 by executing pools[poolId].poolBalance -= debt;

If at the beginning the pool had a poolBalance of 100 ETH and the debt was of 10 ETH at the end, the correct poolBalance should be of 90 ETH but ends up being 80 ETH.

Because of this, many logics of the contract for that specific pool will stop working. See the Impact questions for more details.

Impact

The pool balance is deducted by debt amount twice during the refinance execution. Because of this, all the functions that will involve the pool balance will not work properly:

  • Pool owner will not be able to withdraw their loanToken

  • setPool will not work as expected by the pool owner because the pool.poolBalance has less amount

  • New borrowers won't be able to borrow from the pool at some point because the pool is accounting for less loanToken in poolBalance

  • giveLoan will revert at some point when the pool needs to receive the loan from another pool

  • And so on...

Tools Used

Manual + foundry test

BaseLender.sol utility base test

// 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];
}
}
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)
)
);
}
}

LenderRefinanceDoubleAccounting.t.sol

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "./BaseLender.sol";
contract LenderRefinanceDoubleAccounting is BaseLender {
function setUp() override public {
super.setUp();
}
function test_refinanceDoubleAccountingDebt() public {
// Lender1 create a pool with 1000 ether of poolBalance
bytes32 pool1 = createPool(lender1);
// Borrower borrow borrow 100 ether of debt token providing 100 ether of collateral
borrow(borrower, pool1);
// Lender2 create a pool with 1000 ether of poolBalance
bytes32 pool2 = createPool(lender2);
Pool memory p2Before = lender.getPoolInfo(pool2);
// borrower wants to refinance the Loan by "switching" to the second pool offered by Lender2
vm.startPrank(borrower);
uint refinancedDebt = 100 ether;
Refinance memory r = Refinance({
loanId: 0,
poolId: pool2,
debt: refinancedDebt,
collateral: 100 ether
});
Refinance[] memory rs = new Refinance[](1);
rs[0] = r;
// After the refinance the pool2 poolBalance should be equal to the poolBalance minus the refinanced debt amount (100 ether in this case)
// but because of the issue instead of being 900 ether (1000 ether - 100 ether of debt) it's equal to 800 ether
lender.refinance(rs);
Pool memory p2After = lender.getPoolInfo(pool2);
assertEq(p2After.poolBalance, p2Before.poolBalance - (refinancedDebt * 2));
}
}

Recommendations

The pool balance is already updated by the following code

// now lets deduct our tokens from the new pool
_updatePoolBalance(poolId, pools[poolId].poolBalance - debt);

They can remove the final code instruction pools[poolId].poolBalance -= debt; that would remove another time the debt from the pool balance (already updated).

Support

FAQs

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