Summary
When a Loan is transferred to a new pool, the loan.auctionLength is not updated to the new pool auctionLength value
Vulnerability Details
In the giveLoan function, a loan can be transferred to a new pool only if the terms of the pool are more favorable to the borrower. These conditions must be me (on top of other requirements)
In the buyLoan the scenario is different because you can buy a loan only if that loan has been actioned and at that point the borrower is forced to accept the new pool's terms. There are still some requirements to be matched, but the main one is just that newPool.interestRate > currentAuctionRate.
In both scenarios, the properties interestRate and auctionLength should be updated to follow the pool's terms, but only the interestRate is really updated.
Because of this, the loan auctionLength will always be equal to the starting pool's terms and is updated only if the loan is refinanced.
Impact
The loan will always use the original pool auctionLength while instead should use the new pool auctionLength.
This will impact the lender that wants to start an auction and the lenders that will want to buy that auction because they are forced to obey to the original (the very first) pool's auctionLength.
Tools Used
Manual review + 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)
)
);
}
}
AuctionLengthNotUpdated.t.sol
pragma solidity ^0.8.13;
import "./BaseLender.sol";
contract AuctionLengthNotUpdatedTest is BaseLender {
function setUp() override public {
super.setUp();
}
function setupAndBorrow() private returns (bytes32, bytes32, uint256) {
vm.prank(lender1);
bytes32 poolId1 = lender.setPool(Pool({
lender: lender1,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 ether,
poolBalance: 1000 ether,
maxLoanRatio: 2 ether,
auctionLength: 1 days,
interestRate: 1000,
outstandingLoans: 0
}));
vm.prank(lender2);
bytes32 poolId2 = lender.setPool(Pool({
lender: lender2,
loanToken: address(loanToken),
collateralToken: address(collateralToken),
minLoanSize: 100 ether,
poolBalance: 1000 ether,
maxLoanRatio: 2 ether,
auctionLength: 2 days,
interestRate: 1000,
outstandingLoans: 0
}));
borrow(borrower, poolId1);
assertEq(lender.getPoolInfo(poolId1).auctionLength, 1 days);
assertEq(lender.getPoolInfo(poolId2).auctionLength, 2 days);
assertEq(lender.getLoanInfo(0).auctionLength, lender.getPoolInfo(poolId1).auctionLength);
return (poolId1, poolId2, 0);
}
function testAuctionLengthNotUpdatedByGiveLoan() public {
(bytes32 poolId1, bytes32 poolId2, uint256 loanId) = setupAndBorrow();
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = loanId;
bytes32[] memory poolIds = new bytes32[](1);
poolIds[0] = poolId2;
vm.prank(lender1);
lender.giveLoan(loanIds, poolIds);
assertEq(lender.getLoanInfo(loanId).auctionLength, lender.getPoolInfo(poolId1).auctionLength);
assertFalse(lender.getLoanInfo(loanId).auctionLength == lender.getPoolInfo(poolId2).auctionLength);
}
function testAuctionLengthNotUpdatedByBuyLoan() public {
(bytes32 poolId1, bytes32 poolId2, uint256 loanId) = setupAndBorrow();
uint256[] memory loanIds = new uint256[](1);
loanIds[0] = loanId;
vm.prank(lender1);
lender.startAuction(loanIds);
vm.warp(block.timestamp + 12 hours);
vm.prank(lender2);
lender.buyLoan(loanId, poolId2);
assertEq(lender.getLoanInfo(loanId).auctionLength, lender.getPoolInfo(poolId1).auctionLength);
assertFalse(lender.getLoanInfo(loanId).auctionLength == lender.getPoolInfo(poolId2).auctionLength);
}
}
Recommendations
On both buyLoan and giveLoan the loans[loanId].auctionLength should be updated to newPool.auctionLength