Stable Midnight Canary
High
Borrower can borrow & repay in the same transaction making the lender pay fee without locking collateral
Current implementation allows borrowers to borrow ERC20 tokens and repay the taken loan in the same transaction. This way the debt payed will be the same as the amount taken from the lender and this way borrower will have to pay only gas fees which are negligible in L2 chains. On the other side, the fee payed for the lending operation is payed by the lender. This provides a low cost attack vector for malicious actor to drain lenders funds.
Root cause lies in the fact that borrowers can repay the loan in the same transaction without paying some interest for it and in the same time lenders are the one who pay the fee for lending in acceptLoanOffer()
scenario.
There need to be an active loan offer proposal, which is very likely as this is the whole idea of the protocol.
No specific external pre-conditions.
In the same transaction user calls acceptLoanOffer()
and then repays it.
Lender funds will be drained and the protocol will become unusable.
acceptOffer()
:
https://github.com/sherlock-audit/2024-09-predict-fun/blob/41e70f9eed3f00dd29aba4038544150f5b35dccb/predict-dot-loan/contracts/PredictDotLoan.sol#L976
repay()
:
https://github.com/sherlock-audit/2024-09-predict-fun/blob/41e70f9eed3f00dd29aba4038544150f5b35dccb/predict-dot-loan/contracts/PredictDotLoan.sol#L454
pow()
:
https://github.com/sherlock-audit/2024-09-predict-fun/blob/41e70f9eed3f00dd29aba4038544150f5b35dccb/predict-dot-loan/contracts/libraries/InterestLib.sol#L17
fee is payed by the lender:
uint256 protocolFee = _transferLoanAmountAndProtocolFee(lender, borrower, fulfillAmount);
function _transferLoanAmountAndProtocolFee(
address from,
address to,
uint256 loanAmount
) private returns (uint256 protocolFee) {
protocolFee = (loanAmount * protocolFeeBasisPoints) / 10_000;
LOAN_TOKEN.safeTransferFrom(from, to, loanAmount - protocolFee);
if (protocolFee > 0) {
LOAN_TOKEN.safeTransferFrom(from, protocolFeeRecipient, protocolFee);
}
}
As it can be seen from the following snippets, the debt calculated will be equal to the loanAmount as the time elapsed will be zero.
function repay(uint256 loanId) external nonReentrant {
Loan storage loan = loans[loanId];
_assertAuthorizedCaller(loan.borrower);
LoanStatus status = loan.status;
if (status != LoanStatus.Active) {
if (status != LoanStatus.Called) {
revert InvalidLoanStatus();
}
}
uint256 debt = _calculateDebt(loan.loanAmount, loan.interestRatePerSecond, _calculateLoanTimeElapsed(loan));
loan.status = LoanStatus.Repaid;
LOAN_TOKEN.safeTransferFrom(msg.sender, loan.lender, debt);
CTF.safeTransferFrom(address(this), msg.sender, loan.positionId, loan.collateralAmount, "");
emit LoanRepaid(loanId, debt);
}
function _calculateDebt(
uint256 loanAmount,
uint256 interestRatePerSecond,
uint256 timeElapsed
) private pure returns (uint256 debt) {
debt = (loanAmount * interestRatePerSecond.pow(timeElapsed)) / InterestLib.ONE;
}
function pow(uint256 _base, uint256 _exponent) public pure returns (uint256) {
if (_exponent == 0) {
return ONE;
} else if (_exponent % 2 == 0) {
uint256 half = pow(_base, _exponent / 2);
return half * half / ONE;
} else {
return _base * pow(_base, _exponent - 1) / ONE;
}
}
Consider introducing a state variable which saves the block.timestamp
where last borrower last borrowed lends and forbid repaying it in the same block.timestamp
.