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: 7/67
Findings: 4
Award: $1,842.44
π Selected for report: 3
π Solo Findings: 0
481.5825 USDC - $481.58
The amounts redeemed in overflow redemption can be calculated incorrectly due to incorrect accounting of the outstanding number of reserved tokens.
Project contributors are allowed to redeem their NFT tokens for a portion of the overflow (excessive funded amounts). The amount a contributor receives is calculated as overflow * (user's redemption rate / total redemption weight), where user's redemption weight is the total contribution floor of all their NFTs and total redemption weight is the total contribution floor of all minted NFTs. Since the total redemption weight is the sum of individual contributor redemption weights, the amount they can redeem is proportional to their contribution.
However, the total redemption weight calculation incorrectly accounts outstanding reserved tokens (JBTiered721DelegateStore.sol#L563-L566):
// Add the tier's contribution floor multiplied by the quantity minted. weight += (_storedTier.contributionFloor * (_storedTier.initialQuantity - _storedTier.remainingQuantity)) + _numberOfReservedTokensOutstandingFor(_nft, _i, _storedTier);
Specifically, the number of reserved tokens is added to the weight of minted tokens. This disrupts the redemption amount calculation formula since the total redemption weight is in fact not the sum of individual contributor redemption weights.
Manual review
Two options can be seen:
--- a/contracts/JBTiered721DelegateStore.sol +++ b/contracts/JBTiered721DelegateStore.sol @@ -562,8 +562,7 @@ contract JBTiered721DelegateStore is IJBTiered721DelegateStore { // Add the tier's contribution floor multiplied by the quantity minted. weight += (_storedTier.contributionFloor * - (_storedTier.initialQuantity - _storedTier.remainingQuantity)) + - _numberOfReservedTokensOutstandingFor(_nft, _i, _storedTier); + (_storedTier.initialQuantity - _storedTier.remainingQuantity + + _numberOfReservedTokensOutstandingFor(_nft, _i, _storedTier))); unchecked { ++_i;
#0 - c4-judge
2022-11-04T13:39:16Z
Picodes marked the issue as change severity
#1 - Picodes
2022-11-04T13:41:08Z
As the redeemed amounts are at stake, upgrading to High
#2 - c4-judge
2022-11-04T13:41:16Z
Picodes marked the issue as selected for report
#3 - c4-judge
2022-12-03T19:00:38Z
Picodes marked the issue as primary issue
802.6375 USDC - $802.64
A contributor won't get an NFT they're eligible for if the payment is made through a payment terminal that's supported by the project but not by the NFT delegate.
A Juicebox project can use multiple payment terminals to receive contributions (JBController.sol#L441-L442). Payment terminals are single token payment terminals (JBPayoutRedemptionPaymentTerminal.sol#L310) that support only one currency (JBSingleTokenPaymentTerminal.sol#L124-L132). Since projects can have multiple terminals, they can receive payments in multiple currencies.
However, the NFT delegate supports only one currency (JBTiered721Delegate.sol#L225):
function initialize( uint256 _projectId, IJBDirectory _directory, string memory _name, string memory _symbol, IJBFundingCycleStore _fundingCycleStore, string memory _baseUri, IJBTokenUriResolver _tokenUriResolver, string memory _contractUri, JB721PricingParams memory _pricing, IJBTiered721DelegateStore _store, JBTiered721Flags memory _flags ) public override { // Make the original un-initializable. require(address(this) != codeOrigin); // Stop re-initialization. require(address(store) == address(0)); // Initialize the sub class. JB721Delegate._initialize(_projectId, _directory, _name, _symbol); fundingCycleStore = _fundingCycleStore; store = _store; pricingCurrency = _pricing.currency; // @audit only one currency is supported pricingDecimals = _pricing.decimals; prices = _pricing.prices; ... }
When a payment is made in a currency that's supported by the project (via one of its terminals) but not by the NFT delegate, there's an attempt to convert the currency to a supported one (JBTiered721Delegate.sol#L527-L534):
if (_data.amount.currency == pricingCurrency) _value = _data.amount.value; else if (prices != IJBPrices(address(0))) _value = PRBMath.mulDiv( _data.amount.value, 10**pricingDecimals, prices.priceFor(_data.amount.currency, pricingCurrency, _data.amount.decimals) ); else return;
However, since prices
is optional (it can be set to the zero address, as seen from the snippet), the conversion step can be skipped. When this happens, the contributor gets no NFT due to the early return
even though the amount of their contribution might still be eligible for a tiered NFT.
Manual review
Short term, consider reverting when a different currency is used and prices
is not set. Long term, consider supporting multiple currencies in the NFT delegate.
#0 - trust1995
2022-10-23T22:33:33Z
This is a leak of value, but requires user mistake (it should never set prices to 0, it is not supported API).
#1 - drgorillamd
2022-10-24T10:20:15Z
This is poor project management from the project owner (not adding the appropriate price feed), not a vulnerability
#2 - c4-judge
2022-11-07T17:15:43Z
Picodes marked the issue as selected for report
#3 - Picodes
2022-11-07T17:19:16Z
As this finding:
prices
)I believe Medium severity to be appropriate
#4 - c4-judge
2022-11-07T17:19:36Z
Picodes marked the issue as change severity
#5 - c4-judge
2022-11-08T08:51:02Z
Picodes marked the issue as primary issue
#6 - c4-judge
2022-11-18T08:48:56Z
Picodes marked the issue as duplicate of #83
#7 - c4-judge
2022-12-07T07:27:26Z
Simon-Busch marked the issue as not a duplicate
#8 - c4-judge
2022-12-07T07:27:32Z
Simon-Busch marked the issue as primary issue
π 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
37.8829 USDC - $37.88
Project owner can mint NFTs without contributing, and these NFTs will still be eligible for redeeming a portion of an overflow. Since overflow is shared among all contributors, users who actually contributed will get a smaller share of the overflow. Another exploit scenario this issue can lead to is emission of voting power to an address controlled by project owner, since every NFT give its owner some amount of votes that can be used in government voting.
Overflow is the excessive amount of contributed assets that wasn't distributed and is left in project's treasury. Project contributors can also redeem a portion of an overflow, which is proportional to their contribution:
By default, overflow also serves a means for allowing community members to exit with a portion of the treasury's funds in hand. Any funds in overflow are reclaimable by the project's community by redeeming community tokens along a bonding curve defined by the project's current redemption rate.
In the case of NFT rewards, the total contribution amount of a contributor is the sum of contribution floors of all NFTs that they're holding (JBTiered721DelegateStore.sol#L514-L540):
/** @notice The cumulative weight the given token IDs have in redemptions compared to the `totalRedemptionWeight`. @param _nft The NFT for which the redemption weight is being calculated. @param _tokenIds The IDs of the tokens to get the cumulative redemption weight of. @return weight The weight. */ function redemptionWeightOf(address _nft, uint256[] calldata _tokenIds) public view override returns (uint256 weight) { // Get a reference to the total number of tokens. uint256 _numberOfTokenIds = _tokenIds.length; // Add each token's tier's contribution floor to the weight. for (uint256 _i; _i < _numberOfTokenIds; ) { weight += _storedTierOf[_nft][tierIdOfToken(_tokenIds[_i])].contributionFloor; unchecked { ++_i; } } }
The contribution floor of an NFT is in fact it's price (JBTiered721DelegateStore.sol#L1055-L1056):
// Make sure the amount meets the tier's contribution floor. if (_storedTier.contributionFloor > leftoverAmount) revert INSUFFICIENT_AMOUNT();
Thus, contributors have to pay as little as the contribution floor of an NFT. However, project owner is allowed to mint NFTs to an arbitrary address without paying (JBTiered721Delegate.sol#L480-L512):
function mintFor(uint16[] memory _tierIds, address _beneficiary) public override onlyOwner returns (uint256[] memory tokenIds) { // Record the mint. The returned token IDs correspond to the tiers passed in. (tokenIds, ) = store.recordMint( // @audit minting without a payment type(uint256).max, // force the mint. // @audit maximal payment amount is assumed _tierIds, true // manual mint ); // Keep a reference to the number of tokens being minted. uint256 _numberOfTokens = _tierIds.length; // Keep a reference to the token ID being iterated on. uint256 _tokenId; for (uint256 _i; _i < _numberOfTokens; ) { // Set the token ID. _tokenId = tokenIds[_i]; // Mint the token. _mint(_beneficiary, _tokenId); emit Mint(_tokenId, _tierIds[_i], _beneficiary, 0, msg.sender); unchecked { ++_i; } } }
Such NFTs:
Thus, a project owner can abuse this feature to get a portion of an overflow, or a higher voting power in a DAO.
Manual review
Consider disallowing project owners to mint NFTs without contributing. If this is still required, consider minting NFTs with limited functionality (i.e. without the right for a redemption and without voting power) in the mintFor
function.
#0 - trust1995
2022-10-23T22:30:40Z
I believe this to be intended design. Users can see if the flag allowing owner minting is set or not, and decide for themselves if this is a risk they are willing to take.
#1 - drgorillamd
2022-10-24T10:21:09Z
Duplicate #215
#2 - Picodes
2022-11-07T17:32:45Z
I do agree with @trust1995, especially as there is the flag allowManualMint
.
Downgrading to QA
#3 - c4-judge
2022-11-07T18:11:28Z
Picodes changed the severity to QA (Quality Assurance)
#4 - c4-judge
2022-11-07T18:11:53Z
Picodes marked the issue as grade-b
π 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
520.3363 USDC - $520.34
The cost of NFT delegate deployments can be significantly reduced by deploying proxies instead of clones of the implementation.
This function is used to deploy new NFT delegates (JBTiered721DelegateDeployer.sol#L115):
function _clone(address _targetAddress) internal returns (address _out) { assembly { // Get deployed/runtime code size let _codeSize := extcodesize(_targetAddress) // Get a bit of freemem to land the bytecode, not updated as we'll leave this scope right after create(..) let _freeMem := mload(0x40) // Shift the length to the length placeholder, in the constructor let _mask := mul(_codeSize, 0x100000000000000000000000000000000000000000000000000000000) // Insert the length in the correct sport (after the PUSH3 / 0x62) let _initCode := or(_mask, 0x62000000600081600d8239f3fe00000000000000000000000000000000000000) // Store the deployment bytecode mstore(_freeMem, _initCode) // Copy the bytecode (our initialise part is 13 bytes long) extcodecopy(_targetAddress, add(_freeMem, 13), 0, _codeSize) // Deploy the copied bytecode _out := create(0, _freeMem, _codeSize) }
It copies the code of an existing contract (JBTiered721Delegate
, JB721TieredGovernance
, or JB721GlobalGovernance
) and deploys a new contract with the same code. This is a costly operation because each of the three contracts is a big contract with a lot of code. It'll be much cheaper to deploy non-upgradable proxies instead.
Manual review.
Consider using the Clones library from OpenZeppelinβit deploys and absolutely minimal non-upgradable proxy contract. Such proxies, however, cannot be verified on Etherscan. Some more info.
#0 - c4-judge
2022-11-08T17:47:56Z
Picodes marked the issue as grade-a
#1 - Picodes
2022-11-08T18:25:12Z
Depending on the number of deployments this could be the biggest gas saving so far
#2 - c4-judge
2022-11-08T18:25:19Z
Picodes marked the issue as selected for report
#3 - drgorillamd
2022-11-17T13:45:04Z
@Picodes we didn't use proxies for 2 reasons (it would have obviously been easier;):
+ even if using a non-upgradeable proxy, some users have concern with such (I know, ux/docs/education is out of scope;)
In summary, not convinced this would be the biggest gas saving, on an overall basis
#4 - Picodes
2022-11-17T17:13:36Z
Indeed it totally depend on the usage!
Giving this option to users could easily save a lot of gas for project that expect only a few transactions. I also selected this report as it's the only one suggesting this.
The deployment of the clone contract would be only <50k
gas and then per call <2k
(700 for the DELEGATECALL
, 2600
for the cold address and then the memory expansion) so it'd be worth it for projects with less than a few hundred transactions