Platform: Code4rena
Start Date: 18/10/2022
Pot Size: $50,000 USDC
Total HM: 13
Participants: 67
Period: 5 days
Judge: Picodes
Total Solo HM: 7
Id: 172
League: ETH
Rank: 4/67
Findings: 3
Award: $3,520.32
🌟 Selected for report: 2
🚀 Solo Findings: 1
🌟 Selected for report: berndartmueller
2675.4584 USDC - $2,675.46
https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721DelegateStore.sol#L1224-L1259 https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/JBTiered721DelegateStore.sol#L566 https://github.com/jbx-protocol/juice-nft-rewards/blob/f9893b1497098241dd3a664956d8016ff0d0efd0/contracts/abstract/JB721Delegate.sol#L142
If the reserved rate of a tier is set to a value > JBConstants.MAX_RESERVED_RATE
, the JBTiered721DelegateStore._numberOfReservedTokensOutstandingFor
function will return way more outstanding reserved tokens (up to ~6 times more than allowed - 2^16 - 1 due to the manual cast of reservedRate
to uint16
divided by JBConstants.MAX_RESERVED_RATE = 10_000
). This inflated value is used in the JBTiered721DelegateStore.totalRedemptionWeight
function to calculate the cumulative redemption weight of all tokens across all tiers.
This higher-than-expected redemption weight will lower the reclaimAmount
calculated in the JB721Delegate.redeemParams
function. Depending on the values of _data.overflow
and _redemptionWeight
, the calculated reclaimAmount
can be 0 (due to rounding down, see here) or a smaller than anticipated value, leading to burned NFT tokens from the user and no redemptions.
The owner of an NFT contract can add tiers with higher than usual reserved rates (and mint an appropriate number of NFTs to bypass all conditions in the JBTiered721DelegateStore._numberOfReservedTokensOutstandingFor
), which will lead to a lower-than-expected redemption amount for users.
JBTiered721DelegateStore._numberOfReservedTokensOutstandingFor
function _numberOfReservedTokensOutstandingFor( address _nft, uint256 _tierId, JBStored721Tier memory _storedTier ) internal view returns (uint256) { // Invalid tier or no reserved rate? if (_storedTier.initialQuantity == 0 || _storedTier.reservedRate == 0) return 0; // No token minted yet? Round up to 1. if (_storedTier.initialQuantity == _storedTier.remainingQuantity) return 1; // The number of reserved tokens of the tier already minted. uint256 _reserveTokensMinted = numberOfReservesMintedFor[_nft][_tierId]; // If only the reserved token (from the rounding up) has been minted so far, return 0. if (_storedTier.initialQuantity - _reserveTokensMinted == _storedTier.remainingQuantity) return 0; // Get a reference to the number of tokens already minted in the tier, not counting reserves or burned tokens. uint256 _numberOfNonReservesMinted = _storedTier.initialQuantity - _storedTier.remainingQuantity - _reserveTokensMinted; // Store the numerator common to the next two calculations. uint256 _numerator = uint256(_numberOfNonReservesMinted * _storedTier.reservedRate); // Get the number of reserved tokens mintable given the number of non reserved tokens minted. This will round down. uint256 _numberReservedTokensMintable = _numerator / JBConstants.MAX_RESERVED_RATE; // Round up. if (_numerator - JBConstants.MAX_RESERVED_RATE * _numberReservedTokensMintable > 0) ++_numberReservedTokensMintable; // Return the difference between the amount mintable and the amount already minted. return _numberReservedTokensMintable - _reserveTokensMinted; }
JBTiered721DelegateStore.totalRedemptionWeight
The JBTiered721DelegateStore._numberOfReservedTokensOutstandingFor
function is called from within the JBTiered721DelegateStore.totalRedemptionWeight
function. This allows for inflating the total redemption weight.
function totalRedemptionWeight(address _nft) public view override returns (uint256 weight) { // Keep a reference to the greatest tier ID. uint256 _maxTierId = maxTierIdOf[_nft]; // Keep a reference to the tier being iterated on. JBStored721Tier memory _storedTier; // Add each token's tier's contribution floor to the weight. for (uint256 _i; _i < _maxTierId; ) { // Keep a reference to the stored tier. _storedTier = _storedTierOf[_nft][_i + 1]; // Add the tier's contribution floor multiplied by the quantity minted. weight += (_storedTier.contributionFloor * (_storedTier.initialQuantity - _storedTier.remainingQuantity)) + _numberOfReservedTokensOutstandingFor(_nft, _i, _storedTier); unchecked { ++_i; } } }
JBTiered721Delegate._totalRedemptionWeight
JBTiered721DelegateStore.totalRedemptionWeight
is called in the JBTiered721Delegate._totalRedemptionWeight
function.
function _totalRedemptionWeight() internal view virtual override returns (uint256) { return store.totalRedemptionWeight(address(this)); }
abstract/JB721Delegate.redeemParams
This JBTiered721Delegate._totalRedemptionWeight
function is then called in the JB721Delegate.redeemParams
function, which ultimately calculates the reclaimAmount
given an overflow and _decodedTokenIds
.
uint256 _base = PRBMath.mulDiv(_data.overflow, _redemptionWeight, _total);
in line 142 will lead to a lower _base
due to the inflated denumerator _total
.
function redeemParams(JBRedeemParamsData calldata _data) external view override returns ( uint256 reclaimAmount, string memory memo, JBRedemptionDelegateAllocation[] memory delegateAllocations ) { // Make sure fungible project tokens aren't being redeemed too. if (_data.tokenCount > 0) revert UNEXPECTED_TOKEN_REDEEMED(); // Check the 4 bytes interfaceId and handle the case where the metadata was not intended for this contract if ( _data.metadata.length < 4 || bytes4(_data.metadata[0:4]) != type(IJB721Delegate).interfaceId ) { revert INVALID_REDEMPTION_METADATA(); } // Set the only delegate allocation to be a callback to this contract. delegateAllocations = new JBRedemptionDelegateAllocation[](1); delegateAllocations[0] = JBRedemptionDelegateAllocation(this, 0); // If redemption rate is 0, nothing can be reclaimed from the treasury if (_data.redemptionRate == 0) return (0, _data.memo, delegateAllocations); // Decode the metadata (, uint256[] memory _decodedTokenIds) = abi.decode(_data.metadata, (bytes4, uint256[])); // Get a reference to the redemption rate of the provided tokens. uint256 _redemptionWeight = _redemptionWeightOf(_decodedTokenIds); // Get a reference to the total redemption weight. uint256 _total = _totalRedemptionWeight(); // @audit-info Uses the inflated total redemption weight // Get a reference to the linear proportion. uint256 _base = PRBMath.mulDiv(_data.overflow, _redemptionWeight, _total); // These conditions are all part of the same curve. Edge conditions are separated because fewer operation are necessary. if (_data.redemptionRate == JBConstants.MAX_REDEMPTION_RATE) return (_base, _data.memo, delegateAllocations); // Return the weighted overflow, and this contract as the delegate so that tokens can be deleted. return ( PRBMath.mulDiv( _base, _data.redemptionRate + PRBMath.mulDiv( _redemptionWeight, JBConstants.MAX_REDEMPTION_RATE - _data.redemptionRate, _total ), JBConstants.MAX_REDEMPTION_RATE ), _data.memo, delegateAllocations ); }
Manual review
Consider validating the tier reserved rate reservedRate
in the JBTiered721DelegateStore.recordAddTiers
function to ensure the reserved rate is not greater than JBConstants.MAX_RESERVED_RATE
.
#0 - c4-judge
2022-11-08T11:10:58Z
Picodes marked the issue as satisfactory
🌟 Selected for report: berndartmueller
Also found by: 0x1f8b, 0x4non, 0xNazgul, 0xSmartContract, Aymen0909, BClabs, Diana, Jeiwan, Lambda, LeoS, RaoulSchaffranek, RaymondFam, RedOneN, ReyAdmirado, Rolezn, SaharAP, Trust, V_B, __141345__, a12jmx, bharg4v, brgltd, carlitox477, ch0bu, chaduke, cloudjunky, cryptostellar5, cryptphi, csanuragjain, d3e4, delfin454000, erictee, fatherOfBlocks, hansfriese, ignacio, joestakey, karanctf, ladboy233, lukris02, mcwildy, minhtrng, peanuts, ret2basic, seyni, slowmoses, svskaushik, tnevler, yixxas
444.5957 USDC - $444.60
JBTiered721Delegate.tokenURI
should throw an error if _tokenId
is not a valid NFTAccording to EIP-721
and specifically, the metadata extension, the tokenURI
function should throw an error if _tokenId
is not a valid NFT. Contrary, the current implementation returns an empty string.
function tokenURI(uint256 _tokenId) public view override returns (string memory) { // A token without an owner doesn't have a URI. if (_owners[_tokenId] == address(0)) return ''; // @audit-info Should throw instead of returning an empty string // Get a reference to the URI resolver. IJBTokenUriResolver _resolver = store.tokenUriResolverOf(address(this)); // If a token URI resolver is provided, use it to resolve the token URI. if (address(_resolver) != address(0)) return _resolver.getUri(_tokenId); // Return the token URI for the token's tier. return JBIpfsDecoder.decode( store.baseUriOf(address(this)), store.encodedTierIPFSUriOf(address(this), _tokenId) ); }
Consider throwing an error if _tokenId
is not a valid NFT.
An IPFS hash specifies the hash function and length of the hash in the first two bytes of the hash. The first two bytes are 0x1220, where 12 denotes that this is the SHA256 hash function and 20 is the length of the hash in bytes (32 bytes).
Although SHA256 is 32 bytes and is currently the most common IPFS hash function, other content could use a hash function that is larger than 32 bytes. The current implementation limits the usage to the SHA256 hash function and a hash length of 32 bytes.
libraries/JBIpfsDecoder.sol#L28
function decode(string memory _baseUri, bytes32 _hexString) external pure returns (string memory) { // Concatenate the hex string with the fixed IPFS hash part (0x12 and 0x20) bytes memory completeHexString = abi.encodePacked(bytes2(0x1220), _hexString); // Convert the hex string to an hash string memory ipfsHash = _toBase58(completeHexString); // Concatenate with the base URI return string(abi.encodePacked(_baseUri, ipfsHash)); }
Consider using a more generic implementation that can handle different hash functions and lengths and allow the user to choose.
The token id is composed of the given tier id _tierId
and the number of the token _tokenNumber
in the tier. The tier id is limited to 16 bits, which means that there can theoretically only exist 65,535 tiers (this is very unlikely as this would have more serious consequences on other parts of the system and will cause a serious denial of service caused by unbounded loops. Still, theoretically, it's possible and there is no check in place).
If more than 65,535 tiers exist, the 16 bits reserved for the tier id will be surpassed and overwritten by _tokenNumber
. This will lead to token id collisions with other tiers with a lower tier id.
JBTiered721DelegateStore._generateTokenId
function _generateTokenId(uint256 _tierId, uint256 _tokenNumber) internal pure returns (uint256 tokenId) { // The tier ID in the first 16 bits. tokenId = _tierId; // The token number in the rest. tokenId |= _tokenNumber << 16; }
Consider reverting if the _tierId
is > 16 bits.
#0 - drgorillamd
2022-10-25T22:53:57Z
L01: Doc L02: Mitigated by a custom uri resolver (if/when ipfs hashes change their length and/or algo) L03: Mitigated
#1 - c4-judge
2022-11-08T18:05:57Z
Picodes marked the issue as grade-a
#2 - c4-judge
2022-11-08T18:21:48Z
Picodes marked the issue as selected for report
🌟 Selected for report: Jeiwan
Also found by: 0x1f8b, 0x4non, 0x5rings, 0xSmartContract, Awesome, Aymen0909, Bnke0x0, CodingNameKiki, Diana, DimSon, JC, JrNet, LeoS, RaymondFam, ReyAdmirado, Saintcode_, Shinchan, __141345__, berndartmueller, bharg4v, brgltd, carlitox477, ch0bu, chaduke, cryptostellar5, emrekocak, gogo, lukris02, martin, mcwildy, sakman, trustindistrust, zishansami
400.2587 USDC - $400.26
JBTiered721DelegateStore.recordAddTiers
A tier is considered removed if the bit in the _isTierRemoved
bitmap is set accordingly. The function JBTiered721DelegateStore.recordAddTiers
adds new tiers and sorts them by contributionFloor
, no matter if a tier is removed or not. Therefore, the _isTierRemoved
bitmap reads are unnecessary and can be safely removed.
JBTiered721DelegateStore.sol#L724-L725
JBTiered721DelegateStore.sol#L717
Consider removing the _isTierRemoved
bitmap reads.
#0 - c4-judge
2022-11-08T17:59:33Z
Picodes marked the issue as grade-b
#1 - c4-judge
2022-11-08T18:05:31Z
Picodes marked the issue as grade-a