Platform: Code4rena
Start Date: 07/04/2022
Pot Size: $100,000 USDC
Total HM: 20
Participants: 62
Period: 7 days
Judge: LSDan
Total Solo HM: 11
Id: 107
League: ETH
Rank: 10/62
Findings: 3
Award: $1,687.18
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: hickuphh3
Also found by: 0x1f8b, AuditsAreUS, Foundation, Kthere, Meta0xNull, WatchPug, rayn
https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/vaults/NFTVault.sol#L360-L381 https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/vaults/NFTVault.sol#L347-L353 https://github.com/code-423n4/2022-04-jpegd/blob/e72861a9ccb707ced9015166fbded5c97c6991b6/contracts/lock/JPEGLock.sol#L49-L63
If the same NFT index is locked more than once then the initial locker will lose their funds, they will not be recoverable.
The function lockFor()
does not account for the case where the nftIndex
is already in use. If this index is already in use positions[_nftIndex]
is overwritten with the new value and the old users funds a lost.
The function finalizePendingNFTValueETH()
may be called multiple times for the same nftIndex
so long as their is pendingValue
set by the DAO.
function finalizePendingNFTValueETH(uint256 _nftIndex) external validNFTIndex(_nftIndex) { uint256 pendingValue = pendingNFTValueETH[_nftIndex]; require(pendingValue > 0, "no_pending_value"); uint256 toLockJpeg = (((pendingValue * _ethPriceUSD() * settings.creditLimitRate.numerator) / settings.creditLimitRate.denominator) * settings.valueIncreaseLockRate.numerator) / settings.valueIncreaseLockRate.denominator / _jpegPriceUSD(); //lock JPEG using JPEGLock jpegLocker.lockFor(msg.sender, _nftIndex, toLockJpeg); nftTypes[_nftIndex] = CUSTOM_NFT_HASH; nftValueETH[_nftIndex] = pendingValue; //clear pending value pendingNFTValueETH[_nftIndex] = 0; }
There are no restrictions in setPendingNFTValueETH()
on whether the token has previously been set.
function setPendingNFTValueETH(uint256 _nftIndex, uint256 _amountETH) external validNFTIndex(_nftIndex) onlyRole(DAO_ROLE) { pendingNFTValueETH[_nftIndex] = _amountETH; }
The function lockFor()
will overwrite positions[_nftIndex]
irrelevant of the current state.
function lockFor( address _account, uint256 _nftIndex, uint256 _lockAmount ) external onlyOwner nonReentrant { jpeg.safeTransferFrom(_account, address(this), _lockAmount); positions[_nftIndex] = LockPosition({ owner: _account, unlockAt: block.timestamp + lockTime, lockAmount: _lockAmount }); emit Lock(_account, _nftIndex, _lockAmount); }
Consider updating the function lockFor()
to handle the case where some value is already locked for this nftIndex
.
One option is to unlock the previous amount and transfer those tokens to the previous owner before updating the position.
An alternate option is to revert if there is already a position with lockAmount > 0
.
#0 - spaghettieth
2022-04-13T12:05:54Z
Duplicate of #10
🌟 Selected for report: AuditsAreUS
Also found by: minhquanym
There is a division before multiplication bug in NFTVault._calculateAdditionalInterest()
which may result in no interesting being accrued and will have significant rounding issues for tokens with small decimal places.
This issue occurs since an intermediate calculation of interestPerSec
may round to zero and therefore the multiplication by elapsedTime
may remain zero.
Furthermore, even if interestPerSec > 0
there will still be rounding errors as a result of doing division before multiplication and _calculatedInterest()
will be understated.
This issue is significant as one divisor is 365 days = 30,758,400 (excluding the rate). Since many ERC20 tokens such as USDC and USDT only have 6 decimal places a numerator of less 30 * 10^6 will round to zero.
The rate also multiplies into the denominator. e.g. If the rate is 1% then the denominator will be equivalent to 1 / rate * 30 * 10^6 = 3,000 * 10^6
.
The order of operations for the interest calculations
totalDebtAmount
settings.debtInterestApr.numerator
settings.debtInterestApr.denominator
365 days
elapsedTime
If the intermediate value of interestPerSec = 0
then the multiplication by elapsedTime
will still be zero and no interested will be accrued.
Excerpt from NFTVault._calculateAdditionalInterest()
.
uint256 interestPerYear = (totalDebtAmount * settings.debtInterestApr.numerator) / settings.debtInterestApr.denominator; uint256 interestPerSec = interestPerYear / 365 days; return elapsedTime * interestPerSec;
This issue may be resolved by performing the multiplication by elapsedTime
before the division by the denominator or 365 days
.
uint256 interestAccrued = (elapsedTime * totalDebtAmount * settings.debtInterestApr.numerator) / settings.debtInterestApr.denominator / 365 days; return interestAccrued;
#0 - spaghettieth
2022-04-13T11:40:32Z
Duplicate of #40
#1 - dmvt
2022-04-26T16:33:39Z
I'm going to leave this as the primary and make #40 a duplicate. This report makes sense as a medium to me because it involves a calculation error that can lead to the protocol functioning incorrectly.
🌟 Selected for report: Dravee
Also found by: 0x1f8b, 0xDjango, 0xkatana, AuditsAreUS, Cityscape, Foundation, Funen, Hawkeye, IllIllI, JC, JMukesh, Jujic, Kthere, PPrieditis, Picodes, Ruhum, TerrierLover, TrungOre, WatchPug, berndartmueller, catchup, cccz, cmichel, delfin454000, dy, ellahi, hickuphh3, horsefacts, hubble, hyh, ilan, jayjonah8, kebabsec, kenta, minhquanym, pauliax, rayn, reassor, rfa, robee, samruna
151.5077 USDC - $151.51
NFTVault.closePosition()
does not enforce that a position has not been liquidated.
As a result it is possible for the owner to close a position with closePosition()
after a debt has been liquidated so long as it is insured since the position will only be deleted if it is not insured or has been repurchased.
The function closePosition()
does not enforce that a position is not liquidated.
function closePosition(uint256 _nftIndex) external validNFTIndex(_nftIndex) { accrue(); require(msg.sender == positionOwner[_nftIndex], "unauthorized"); require(_getDebtAmount(_nftIndex) == 0, "position_not_repaid"); positionOwner[_nftIndex] = address(0); delete positions[_nftIndex]; positionIndexes.remove(_nftIndex); // transfer nft back to owner if nft was deposited if (nftContract.ownerOf(_nftIndex) == address(this)) { nftContract.safeTransferFrom(address(this), msg.sender, _nftIndex); } emit PositionClosed(msg.sender, _nftIndex); }
Consider adding the check to see if the position is liquidated in closePosition()
. e.g.
require(positions[_nftIndex].liquidatedAt == 0, "liquidated");
#0 - spaghettieth
2022-04-13T11:53:39Z
Duplicate of #79
#1 - dmvt
2022-04-26T14:44:23Z
See comment on #79
#2 - JeeberC4
2022-05-02T19:06:34Z
Generating QA Report as judge downgraded issue. Preserving original title: Insured Liquidations Can Be Closed By Owner Before Repurchase