Platform: Code4rena
Start Date: 05/04/2022
Pot Size: $30,000 USDC
Total HM: 10
Participants: 47
Period: 3 days
Judge: gzeon
Total Solo HM: 4
Id: 106
League: ETH
Rank: 4/47
Findings: 4
Award: $2,574.34
π Selected for report: 3
π Solo Findings: 1
π Selected for report: WatchPug
Also found by: Dravee, berndartmueller, hake, jah, minhquanym
181.4136 USDC - $181.41
function closeLoan(uint256 loanId, address sendCollateralTo) external override notClosed(loanId) { require(IERC721(borrowTicketContract).ownerOf(loanId) == msg.sender, "NFTLoanFacilitator: borrow ticket holder only"); Loan storage loan = loanInfo[loanId]; require(loan.lastAccumulatedTimestamp == 0, "NFTLoanFacilitator: has lender, use repayAndCloseLoan"); loan.closed = true; IERC721(loan.collateralContractAddress).transferFrom(address(this), sendCollateralTo, loan.collateralTokenId); emit Close(loanId); }
The sendCollateralTo
will receive the collateral NFT when closeLoan()
is called. However, if sendCollateralTo
is a contract address that does not support ERC721, the collateral NFT can be frozen in the contract.
As per the documentation of EIP-721:
A wallet/broker/auction application MUST implement the wallet interface if it will accept safe transfers.
Ref: https://eips.ethereum.org/EIPS/eip-721
Change to:
function closeLoan(uint256 loanId, address sendCollateralTo) external override notClosed(loanId) { require(IERC721(borrowTicketContract).ownerOf(loanId) == msg.sender, "NFTLoanFacilitator: borrow ticket holder only"); Loan storage loan = loanInfo[loanId]; require(loan.lastAccumulatedTimestamp == 0, "NFTLoanFacilitator: has lender, use repayAndCloseLoan"); loan.closed = true; IERC721(loan.collateralContractAddress).safeTransferFrom(address(this), sendCollateralTo, loan.collateralTokenId); emit Close(loanId); }
#0 - wilsoncusack
2022-04-08T00:03:27Z
Copying comment from #11
We use transferFrom and _mint instead of the safe versions to save gas. We think it is a reasonable expectation that users calling this should know what they are doing, we feel this OK especially because other major protocols like Uniswap do this https://github.com/Uniswap/v3-periphery/blob/main/contracts/NonfungiblePositionManager.sol#L156
#1 - gzeoneth
2022-04-15T13:00:40Z
I believe Med Risk is a fair assessment given the mixed/inconsistent usage of safeTransferFrom
and transferFrom
in the contract.
π Selected for report: WatchPug
Also found by: CertoraInc, hickuphh3
497.7053 USDC - $497.71
{ uint256 previousInterestRate = loan.perAnumInterestRate; uint256 previousDurationSeconds = loan.durationSeconds; require(interestRate <= previousInterestRate, 'NFTLoanFacilitator: rate too high'); require(durationSeconds >= previousDurationSeconds, 'NFTLoanFacilitator: duration too low'); require((previousLoanAmount * requiredImprovementRate / SCALAR) <= amountIncrease || previousDurationSeconds + (previousDurationSeconds * requiredImprovementRate / SCALAR) <= durationSeconds || (previousInterestRate != 0 // do not allow rate improvement if rate already 0 && previousInterestRate - (previousInterestRate * requiredImprovementRate / SCALAR) >= interestRate), "NFTLoanFacilitator: proposed terms must be better than existing terms"); }
The requiredImprovementRate
represents the percentage of improvement required of at least one of the terms when buying out from a previous lender.
However, when previousInterestRate
is less than 10
and requiredImprovementRate
is 100
, due to precision loss, the new interestRate
is allowed to be the same as the previous one.
Making such an expected constraint absent.
createLoan()
with maxPerAnumInterest
= 10, received loanId
= 1lend()
with interestRate
= 9 for loanId
= 1lend()
with interestRate
= 9 (and all the same other terms with Bob) and buys out loanId
= 1Charlie is expected to provide at least 10% better terms, but actually bought out Bob with the same terms.
Consider using: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.5.0/contracts/utils/math/Math.sol#L39-L42
And change the check to:
(previousInterestRate != 0 // do not allow rate improvement if rate already 0 && previousInterestRate - Math.ceilDiv(previousInterestRate * requiredImprovementRate, SCALAR) >= interestRate)
#0 - wilsoncusack
2022-04-10T16:15:45Z
#1 - gzeoneth
2022-04-15T13:03:54Z
Sponsor confirmed with fix
π Selected for report: WatchPug
1843.3528 USDC - $1,843.35
IERC721Mintable(borrowTicketContract).mint(mintBorrowTicketTo, id);
function mint(address to, uint256 tokenId) external override loanFacilitatorOnly { _mint(to, tokenId); }
If mintBorrowTicketTo
is a contract that does not implement the onERC721Received
method, in the current implementation of createLoan()
, the tx will still be successfully, and the loan will be created.
This can be a problem if mintBorrowTicketTo
can not handle ERC721 properly, as the BorrowTicket
NFT will be used later to get back the user's funds.
Consider using safeMint
in NFTLoanTicket.sol#mint()
:
function mint(address to, uint256 tokenId) external override loanFacilitatorOnly { _safeMint(to, tokenId); }
#0 - wilsoncusack
2022-04-08T01:06:26Z
Similar to #83
#1 - gzeoneth
2022-04-15T14:36:58Z
Not really a duplicate because it is the mint function. Fund can be lost by losing the borrow ticket.
#2 - wilsoncusack
2022-04-16T01:27:10Z
Not really a duplicate because it is the mint function. Fund can be lost by losing the borrow ticket.
Agree not really duplicate. I think my response is the same, which is comparing to Uniswap v3 nft which also would mean loss of funds if lost
from #83
We use transferFrom and _mint instead of the safe versions to save gas. We think it is a reasonable expectation that users calling this should know what they are doing, we feel this OK especially because other major protocols like Uniswap do this https://github.com/Uniswap/v3-periphery/blob/main/contracts/NonfungiblePositionManager.sol#L156
#4 - wilsoncusack
2022-04-16T11:11:58Z
Fair. This was an intentional change to save gas but I should have been consistent.
#5 - wilsoncusack
2022-04-16T11:22:16Z
π Selected for report: IllIllI
Also found by: 0v3rf10w, 0x1f8b, 0xDjango, 0xkatana, BouSalman, CertoraInc, Dravee, FSchmoede, Hawkeye, Kenshin, Meta0xNull, PPrieditis, Ruhum, TerrierLover, VAD37, WatchPug, berndartmueller, csanuragjain, hake, horsefacts, hubble, m9800, rayn, reassor, robee, samruna, securerodd, shenwilly, sorrynotsorry, t11s, teryanarmen, tintin, z3s
51.8678 USDC - $51.87
/** * @notice updates the percent improvement required of at least one loan term when buying out lender * a loan that already has a lender. E.g. setting this value to 10 means duration or amount * must be 10% higher or interest rate must be 10% lower. * @dev Cannot be 0. */ function updateRequiredImprovementRate(uint256 _improvementRate) external onlyOwner { require(_improvementRate > 0, 'NFTLoanFacilitator: 0 improvement rate'); requiredImprovementRate = _improvementRate; emit UpdateRequiredImprovementRate(_improvementRate); }
"10 means duration or amount must be 10% higher" should be "100 means duration or amount must be 10% higher".
/** * See {INFTLoanFacilitator-INTEREST_RATE_DECIMALS}. * @dev lowest non-zero APR possible = (1/10^3) = 0.001 = 0.1% */ uint8 public constant override INTEREST_RATE_DECIMALS = 3;
The current interface is:
/** * @notice The magnitude of SCALAR * @dev 10^INTEREST_RATE_DECIMALS = 1 = 100% */ function INTEREST_RATE_DECIMALS() external returns (uint8);
Should be:
/** * @notice The magnitude of SCALAR * @dev 10^INTEREST_RATE_DECIMALS = 1 = 100% */ function INTEREST_RATE_DECIMALS() external view returns (uint8);
Otherwise, it can not be used in view context, eg:
function interestRateString(NFTLoanFacilitator nftLoanFacilitator, uint256 perAnumInterestRate) private view returns (string memory) { return UintStrings.decimalString( perAnumInterestRate, nftLoanFacilitator.INTEREST_RATE_DECIMALS() - 2, true ); }
#0 - wilsoncusack
2022-04-08T14:19:18Z
INTEREST_RATE_DECIMALS is read internally several lines below