@bananapus/721-hook-v6 0.0.18 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  NFT tier system for Juicebox V6. Allows projects to attach tiered NFT minting to payments and use NFTs as cash-out hooks. Supports on-chain and off-chain metadata, category-based sorting, and configurable pricing with discounts.
6
6
 
7
+ The design permits a high theoretical tier ceiling, but several important reads and cash-out calculations still scale
8
+ with `maxTierId`. In practice, this should be treated as a curated-catalog hook with an explicit operating envelope,
9
+ not as a guarantee that very large catalogs are comfortable to run on-chain.
10
+
7
11
  ## Contract Map
8
12
 
9
13
  ```
package/README.md CHANGED
@@ -55,7 +55,7 @@ For projects using `forge` to manage dependencies (not recommended):
55
55
  forge install Bananapus/nana-721-hook
56
56
  ```
57
57
 
58
- If you're using `forge` to manage dependencies, add `@bananapus/721-hook/=lib/nana-721-hook/` to `remappings.txt`. You'll also need to install `nana-721-hook`'s dependencies and add similar remappings for them.
58
+ If you're using `forge` to manage dependencies, you'll also need to install `nana-721-hook`'s dependencies. With `libs = ["node_modules", "lib"]` in `foundry.toml`, Foundry auto-resolves import paths — no `remappings.txt` needed.
59
59
 
60
60
  ### Develop
61
61
 
@@ -136,13 +136,7 @@ This example deploys `nana-721-hook` to the Sepolia testnet using the specified
136
136
 
137
137
  To view test coverage, run `npm run coverage` to generate an LCOV test report. You can use an extension like [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) to view coverage in your editor.
138
138
 
139
- If you're using Nomic Foundation's [Solidity](https://marketplace.visualstudio.com/items?itemName=NomicFoundation.hardhat-solidity) extension in VSCode, you may run into LSP errors because the extension cannot find dependencies outside of `lib`. You can often fix this by running:
140
-
141
- ```bash
142
- forge remappings >> remappings.txt
143
- ```
144
-
145
- This makes the extension aware of default remappings.
139
+ If you're using Nomic Foundation's [Solidity](https://marketplace.visualstudio.com/items?itemName=NomicFoundation.hardhat-solidity) extension in VSCode, you may run into LSP errors because the extension cannot find dependencies outside of `lib`. You can often fix this by running `forge remappings > remappings.txt` to generate a remappings file for the extension. This file is gitignored and only needed for editor support.
146
140
 
147
141
  ## Repository Layout
148
142
 
@@ -219,7 +213,8 @@ Each pay/cash out hook can then execute custom behavior based on the custom data
219
213
 
220
214
  ### Mechanism
221
215
 
222
- A project using a 721 tiers hook can specify any number of NFT tiers (up to 65,535 total).
216
+ A project using a 721 tiers hook can specify a large number of NFT tiers in theory (up to 65,535 total), but the
217
+ practical operating envelope is much smaller because several important paths still scale with `maxTierId`.
223
218
 
224
219
  - NFT tiers can be removed by the project owner as long as they are not locked (`cannotBeRemoved`). After removing tiers, call `cleanTiers()` on the store to optimize tier iteration.
225
220
  - NFT tiers can be added by the project owner as long as they respect the hook's `flags`. Tiers must be sorted by category in ascending order — the store reverts with `JB721TiersHookStore_InvalidCategorySortOrder` if not. The flags specify if newly added tiers can have votes (voting units), if new tiers can have non-zero reserve frequencies, if new tiers can allow on-demand minting by the project's owner, and if overspending is allowed.
@@ -246,6 +241,21 @@ Additional notes:
246
241
  - If enabled by the project owner, holders can burn their NFTs to reclaim funds from the project. These cash outs are proportional to the NFTs price, relative to the combined price of all the NFTs (including pending reserves in the denominator).
247
242
  - NFT cash outs can be enabled by setting `useDataHookForCashOut` to `true` in the project's `JBRulesetMetadata`. If NFT cash outs are enabled, project token cash outs are disabled -- attempting to cash out fungible tokens when the data hook is active will revert.
248
243
  - Per-tier voting units can be configured: either custom voting units or the tier's price as the default. Voting power is computed per-address across all tiers.
244
+
245
+ ### Supported Operating Envelope
246
+
247
+ The current implementation is best suited to curated catalogs, not effectively unbounded consumer storefronts.
248
+
249
+ - Treat roughly `<= 100` active tiers as the comfortable operating envelope for projects that expect frequent
250
+ `balanceOf`, governance reads, or NFT cash outs.
251
+ - Treat `100-200` tiers as an advanced configuration that should be used only with deliberate gas budgeting and
252
+ frontend/operator awareness.
253
+ - Above that range, the on-chain reads and cash-out accounting are still functionally correct, but several important
254
+ paths become increasingly expensive because they iterate the tier set.
255
+
256
+ The test suite proves survivability at `100` and `200` tiers, and also proves that `balanceOf` and
257
+ `totalCashOutWeight` become materially more expensive at `100` tiers than at `10` tiers. That evidence should be read
258
+ as a scope boundary, not as encouragement to target the `uint16.max` theoretical tier ceiling in production.
249
259
  - The hook declares support for ERC-2981 (royalties) via `supportsInterface`, but does not implement the `royaltyInfo` function. This is intended for future extension.
250
260
 
251
261
  ### Setup
package/RISKS.md CHANGED
@@ -33,6 +33,10 @@
33
33
 
34
34
  - **`totalCashOutWeight` iterates ALL tier IDs** (1 to `maxTierIdOf`), including removed tiers with minted NFTs. Called during every `beforeCashOutRecordedWith`. At ~2-3k gas per tier, 500+ tiers approaches block gas limits. Could block all NFT cash-outs if an attacker with `ADJUST_721_TIERS` permission adds thousands of tiers.
35
35
  - **`balanceOf`, `votingUnitsOf`, `totalSupplyOf` iterate all tiers.** Same pattern: loop from `maxTierIdOf` down to 1. These are view functions but called by governance contracts.
36
+ - **Theoretical max is not the supported operating envelope.** The store permits up to 65,535 tiers, but the practical
37
+ comfort zone is far lower. The test suite demonstrates survivability at 100 to 200 tiers and also demonstrates that
38
+ `balanceOf` and `totalCashOutWeight` become materially more expensive at 100 tiers than at 10 tiers. Treat large
39
+ catalogs as an explicit gas-budgeting exercise, not as a default deployment shape.
36
40
  - **`tiersOf` traverses removed tiers.** Removed tiers are skipped via bitmap but still traversed in the linked list. `cleanTiers()` must be called separately to compact. `cleanTiers()` is permissionless and idempotent.
37
41
  - **Minting from many tiers in one payment.** `recordMint` loops per tier ID: storage read (stored tier + bitmap check) per iteration. 50 tiers in one payment ~5-7M gas (tested, fits in 30M block). 100+ tiers in a single mint is feasible but consumes most of the block.
38
42
  - **`recordAddTiers` sort-insertion cost.** Adding a low-category tier to a hook with many existing higher-category tiers iterates the entire sorted list to find the insertion point. O(n) per added tier.
package/USER_JOURNEYS.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Every user-facing operation in the tiered 721 hook system, with exact entry points, parameters, state changes, events, and edge cases.
4
4
 
5
+ These journeys describe functional behavior, not a promise that the theoretical `uint16.max` tier ceiling is a
6
+ production-ready catalog size. Several important reads and cash-out calculations scale with `maxTierId`, so large-tier
7
+ deployments should be evaluated against the repo's documented operating envelope before launch.
8
+
5
9
  ---
6
10
 
7
11
  ## 1. Pay and Receive NFTs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@bananapus/address-registry-v6": "^0.0.10",
21
- "@bananapus/core-v6": "^0.0.17",
21
+ "@bananapus/core-v6": "^0.0.23",
22
22
  "@bananapus/ownable-v6": "^0.0.10",
23
23
  "@bananapus/permission-ids-v6": "^0.0.10",
24
24
  "@openzeppelin/contracts": "^5.6.1",
@@ -210,7 +210,8 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
210
210
  issueTokensForSplits: STORE.flagsOf(address(this)).issueTokensForSplits
211
211
  });
212
212
 
213
- hookSpecifications[0] = JBPayHookSpecification({hook: this, amount: totalSplitAmount, metadata: splitMetadata});
213
+ hookSpecifications[0] =
214
+ JBPayHookSpecification({hook: this, noop: false, amount: totalSplitAmount, metadata: splitMetadata});
214
215
  }
215
216
 
216
217
  /// @notice The combined cash out weight of the NFTs with the specified token IDs.
@@ -620,104 +621,105 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
620
621
  }
621
622
  }
622
623
 
623
- /// @notice Process a payment, minting NFTs and updating credits as necessary.
624
- /// @dev Pay credits are tracked per beneficiary, not per payer. When the payer differs from the beneficiary,
625
- /// the payer's existing credits are NOT applied to the mint. Only the beneficiary's credits are combined with
626
- /// the incoming payment value. Leftover funds after minting are stored as credits for the beneficiary.
627
- /// @param context Payment context provided by the terminal after it has recorded the payment in the terminal store.
628
- function _processPayment(JBAfterPayRecordedContext calldata context) internal virtual override {
629
- // Normalize the payment value based on the pricing context.
630
- uint256 value;
631
- {
632
- bool valid;
633
- (value, valid) = JB721TiersHookLib.normalizePaymentValue({
634
- packedPricingContext: _packedPricingContext,
635
- prices: PRICES,
636
- projectId: PROJECT_ID,
637
- amountValue: context.amount.value,
638
- amountCurrency: context.amount.currency,
639
- amountDecimals: context.amount.decimals
640
- });
641
- if (!valid) return;
624
+ /// @notice Mint NFTs from the specified tiers and update the beneficiary's pay credits.
625
+ /// @param value The normalized payment value.
626
+ /// @param context Payment context provided by the terminal.
627
+ function _mintAndUpdateCredits(uint256 value, JBAfterPayRecordedContext calldata context) internal {
628
+ // Keep a reference to the number of NFT credits the beneficiary already has.
629
+ uint256 payCredits = payCreditsOf[context.beneficiary];
630
+
631
+ // Set the leftover amount as the initial value.
632
+ uint256 leftoverAmount = value;
633
+
634
+ // If the payer is the beneficiary, combine their NFT credits with the amount paid.
635
+ uint256 unusedPayCredits;
636
+ if (context.payer == context.beneficiary) {
637
+ leftoverAmount += payCredits;
638
+ } else {
639
+ // Otherwise, the payer's NFT credits won't be used, and we keep track of the unused credits.
640
+ unusedPayCredits = payCredits;
642
641
  }
643
642
 
644
- // Scope block to free stack slots before the distributeAll call below.
645
- {
646
- // Keep a reference to the number of NFT credits the beneficiary already has.
647
- uint256 payCredits = payCreditsOf[context.beneficiary];
643
+ // Keep a reference to the boolean indicating whether paying more than the price of the NFTs being minted
644
+ // is allowed. Defaults to the collection's flag.
645
+ bool allowOverspending = !STORE.flagsOf(address(this)).preventOverspending;
648
646
 
649
- // Set the leftover amount as the initial value.
650
- uint256 leftoverAmount = value;
647
+ // Resolve the metadata.
648
+ (bool found, bytes memory metadata) = JBMetadataResolver.getDataFor({
649
+ id: JBMetadataResolver.getId({purpose: "pay", target: METADATA_ID_TARGET}), metadata: context.payerMetadata
650
+ });
651
651
 
652
- // If the payer is the beneficiary, combine their NFT credits with the amount paid.
653
- uint256 unusedPayCredits;
654
- if (context.payer == context.beneficiary) {
655
- leftoverAmount += payCredits;
656
- } else {
657
- // Otherwise, the payer's NFT credits won't be used, and we keep track of the unused credits.
658
- unusedPayCredits = payCredits;
659
- }
652
+ if (found) {
653
+ // Keep a reference to the IDs of the tier be to minted.
654
+ uint16[] memory tierIdsToMint;
660
655
 
661
- // Keep a reference to the boolean indicating whether paying more than the price of the NFTs being minted
662
- // is allowed. Defaults to the collection's flag.
663
- bool allowOverspending = !STORE.flagsOf(address(this)).preventOverspending;
656
+ // Keep a reference to the payer's flag indicating whether overspending is allowed.
657
+ bool payerAllowsOverspending;
664
658
 
665
- // Resolve the metadata.
666
- (bool found, bytes memory metadata) = JBMetadataResolver.getDataFor({
667
- id: JBMetadataResolver.getId({purpose: "pay", target: METADATA_ID_TARGET}),
668
- metadata: context.payerMetadata
669
- });
659
+ // Decode the metadata.
660
+ (payerAllowsOverspending, tierIdsToMint) = abi.decode(metadata, (bool, uint16[]));
670
661
 
671
- if (found) {
672
- // Keep a reference to the IDs of the tier be to minted.
673
- uint16[] memory tierIdsToMint;
662
+ // Make sure overspending is allowed if requested.
663
+ if (allowOverspending && !payerAllowsOverspending) {
664
+ allowOverspending = false;
665
+ }
674
666
 
675
- // Keep a reference to the payer's flag indicating whether overspending is allowed.
676
- bool payerAllowsOverspending;
667
+ // Mint NFTs from the tiers as specified.
668
+ if (tierIdsToMint.length != 0) {
669
+ // slither-disable-next-line reentrancy-events,reentrancy-no-eth
670
+ leftoverAmount =
671
+ _mintAll({amount: leftoverAmount, mintTierIds: tierIdsToMint, beneficiary: context.beneficiary});
672
+ }
673
+ }
677
674
 
678
- // Decode the metadata.
679
- (payerAllowsOverspending, tierIdsToMint) = abi.decode(metadata, (bool, uint16[]));
675
+ // If overspending isn't allowed, revert.
676
+ if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
680
677
 
681
- // Make sure overspending is allowed if requested.
682
- if (allowOverspending && !payerAllowsOverspending) {
683
- allowOverspending = false;
684
- }
678
+ // Update NFT credits if they changed.
679
+ uint256 newPayCredits = leftoverAmount + unusedPayCredits;
685
680
 
686
- // Mint NFTs from the tiers as specified.
687
- if (tierIdsToMint.length != 0) {
688
- // slither-disable-next-line reentrancy-events,reentrancy-no-eth
689
- leftoverAmount = _mintAll({
690
- amount: leftoverAmount, mintTierIds: tierIdsToMint, beneficiary: context.beneficiary
691
- });
692
- }
681
+ if (newPayCredits != payCredits) {
682
+ if (newPayCredits > payCredits) {
683
+ emit AddPayCredits({
684
+ amount: newPayCredits - payCredits,
685
+ newTotalCredits: newPayCredits,
686
+ account: context.beneficiary,
687
+ caller: _msgSender()
688
+ });
689
+ } else {
690
+ emit UsePayCredits({
691
+ amount: payCredits - newPayCredits,
692
+ newTotalCredits: newPayCredits,
693
+ account: context.beneficiary,
694
+ caller: _msgSender()
695
+ });
693
696
  }
694
697
 
695
- // If overspending isn't allowed, revert.
696
- if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
697
-
698
- // Update NFT credits if they changed.
699
- uint256 newPayCredits = leftoverAmount + unusedPayCredits;
700
-
701
- if (newPayCredits != payCredits) {
702
- if (newPayCredits > payCredits) {
703
- emit AddPayCredits({
704
- amount: newPayCredits - payCredits,
705
- newTotalCredits: newPayCredits,
706
- account: context.beneficiary,
707
- caller: _msgSender()
708
- });
709
- } else {
710
- emit UsePayCredits({
711
- amount: payCredits - newPayCredits,
712
- newTotalCredits: newPayCredits,
713
- account: context.beneficiary,
714
- caller: _msgSender()
715
- });
716
- }
717
-
718
- payCreditsOf[context.beneficiary] = newPayCredits;
719
- }
698
+ payCreditsOf[context.beneficiary] = newPayCredits;
720
699
  }
700
+ }
701
+
702
+ /// @notice Process a payment, minting NFTs and updating credits as necessary.
703
+ /// @dev Pay credits are tracked per beneficiary, not per payer. When the payer differs from the beneficiary,
704
+ /// the payer's existing credits are NOT applied to the mint. Only the beneficiary's credits are combined with
705
+ /// the incoming payment value. Leftover funds after minting are stored as credits for the beneficiary.
706
+ /// @param context Payment context provided by the terminal after it has recorded the payment in the terminal store.
707
+ function _processPayment(JBAfterPayRecordedContext calldata context) internal virtual override {
708
+ // Normalize the payment value based on the pricing context.
709
+ bool valid;
710
+ uint256 value;
711
+ (value, valid) = JB721TiersHookLib.normalizePaymentValue({
712
+ packedPricingContext: _packedPricingContext,
713
+ prices: PRICES,
714
+ projectId: PROJECT_ID,
715
+ amountValue: context.amount.value,
716
+ amountCurrency: context.amount.currency,
717
+ amountDecimals: context.amount.decimals
718
+ });
719
+ if (!valid) return;
720
+
721
+ // Mint NFTs from the specified tiers and update the beneficiary's pay credits.
722
+ _mintAndUpdateCredits({value: value, context: context});
721
723
 
722
724
  // Distribute any forwarded funds to tier split groups.
723
725
  if (context.hookMetadata.length != 0 && context.forwardedAmount.value != 0) {
@@ -102,7 +102,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
102
102
 
103
103
  // Use this contract as the only cash out hook.
104
104
  hookSpecifications = new JBCashOutHookSpecification[](1);
105
- hookSpecifications[0] = JBCashOutHookSpecification({hook: this, amount: 0, metadata: bytes("")});
105
+ hookSpecifications[0] = JBCashOutHookSpecification({hook: this, noop: false, amount: 0, metadata: bytes("")});
106
106
 
107
107
  uint256[] memory decodedTokenIds;
108
108
 
@@ -136,7 +136,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
136
136
  // Forward the received weight and use this contract as the only pay hook.
137
137
  weight = context.weight;
138
138
  hookSpecifications = new JBPayHookSpecification[](1);
139
- hookSpecifications[0] = JBPayHookSpecification({hook: this, amount: 0, metadata: bytes("")});
139
+ hookSpecifications[0] = JBPayHookSpecification({hook: this, noop: false, amount: 0, metadata: bytes("")});
140
140
  }
141
141
 
142
142
  /// @notice Required by the IJBRulesetDataHook interfaces. Return false to not leak any permissions.
@@ -175,14 +175,10 @@ library JB721TiersHookLib {
175
175
  view
176
176
  returns (uint256 totalSplitAmount, bytes memory hookMetadata)
177
177
  {
178
- bytes memory data;
179
- {
180
- bool found;
181
- (found, data) = JBMetadataResolver.getDataFor({
182
- id: JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget}), metadata: metadata
183
- });
184
- if (!found) return (0, bytes(""));
185
- }
178
+ (bool found, bytes memory data) = JBMetadataResolver.getDataFor({
179
+ id: JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget}), metadata: metadata
180
+ });
181
+ if (!found) return (0, bytes(""));
186
182
 
187
183
  (, uint16[] memory tierIdsToMint) = abi.decode(data, (bool, uint16[]));
188
184
  if (tierIdsToMint.length == 0) return (0, bytes(""));
@@ -424,6 +424,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
424
424
 
425
425
  /// @dev The block gas limit on mainnet is 30M. We use a generous limit for safety.
426
426
  uint256 constant BLOCK_GAS_LIMIT = 30_000_000;
427
+ uint256 constant OPERATING_ENVELOPE_SOFT_LIMIT = 200;
427
428
 
428
429
  // ---------------------------------------------------------------
429
430
  // Test 1: Add 100 tiers in a single adjustTiers call
@@ -446,6 +447,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
446
447
  reserveBeneficiary: reserveBeneficiary,
447
448
  encodedIPFSUri: tokenUris[i % 10],
448
449
  // forge-lint: disable-next-line(unsafe-typecast)
450
+ // forge-lint: disable-next-line(unsafe-typecast)
449
451
  category: uint24(i + 1), // Ascending categories
450
452
  discountPercent: 0,
451
453
  allowOwnerMint: false,
@@ -498,6 +500,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
498
500
  reserveBeneficiary: reserveBeneficiary,
499
501
  encodedIPFSUri: tokenUris[i % 10],
500
502
  // forge-lint: disable-next-line(unsafe-typecast)
503
+ // forge-lint: disable-next-line(unsafe-typecast)
501
504
  category: uint24(i + 1),
502
505
  discountPercent: 0,
503
506
  allowOwnerMint: false,
@@ -552,6 +555,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
552
555
  reserveBeneficiary: reserveBeneficiary,
553
556
  encodedIPFSUri: tokenUris[i % 10],
554
557
  // forge-lint: disable-next-line(unsafe-typecast)
558
+ // forge-lint: disable-next-line(unsafe-typecast)
555
559
  category: uint24(i + 1),
556
560
  discountPercent: 0,
557
561
  allowOwnerMint: false,
@@ -579,6 +583,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
579
583
  uint16[] memory tierIdsToMint = new uint16[](10);
580
584
  uint256 totalCost;
581
585
  for (uint256 i; i < 10; i++) {
586
+ // forge-lint: disable-next-line(unsafe-typecast)
582
587
  // forge-lint: disable-next-line(unsafe-typecast)
583
588
  tierIdsToMint[i] = uint16(i + 1);
584
589
  totalCost += (i + 1) * 1e15;
@@ -651,6 +656,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
651
656
  reserveBeneficiary: reserveBeneficiary,
652
657
  encodedIPFSUri: tokenUris[i % 10],
653
658
  // forge-lint: disable-next-line(unsafe-typecast)
659
+ // forge-lint: disable-next-line(unsafe-typecast)
654
660
  category: uint24(i + 1),
655
661
  discountPercent: 0,
656
662
  allowOwnerMint: false,
@@ -699,6 +705,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
699
705
  reserveBeneficiary: reserveBeneficiary,
700
706
  encodedIPFSUri: tokenUris[i % 10],
701
707
  // forge-lint: disable-next-line(unsafe-typecast)
708
+ // forge-lint: disable-next-line(unsafe-typecast)
702
709
  category: uint24(i + 1),
703
710
  discountPercent: 0,
704
711
  allowOwnerMint: false,
@@ -759,6 +766,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
759
766
  reserveBeneficiary: reserveBeneficiary,
760
767
  encodedIPFSUri: tokenUris[i % 10],
761
768
  // forge-lint: disable-next-line(unsafe-typecast)
769
+ // forge-lint: disable-next-line(unsafe-typecast)
762
770
  category: uint24(i + 1),
763
771
  discountPercent: 0,
764
772
  allowOwnerMint: false,
@@ -830,6 +838,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
830
838
  reserveBeneficiary: reserveBeneficiary,
831
839
  encodedIPFSUri: tokenUris[i % 10],
832
840
  // forge-lint: disable-next-line(unsafe-typecast)
841
+ // forge-lint: disable-next-line(unsafe-typecast)
833
842
  category: uint24(i + 1),
834
843
  discountPercent: 0,
835
844
  allowOwnerMint: false,
@@ -856,6 +865,7 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
856
865
  uint16[] memory tierIdsToMint = new uint16[](50);
857
866
  uint256 totalCost;
858
867
  for (uint256 i; i < 50; i++) {
868
+ // forge-lint: disable-next-line(unsafe-typecast)
859
869
  // forge-lint: disable-next-line(unsafe-typecast)
860
870
  tierIdsToMint[i] = uint16(i + 1);
861
871
  totalCost += (i + 1) * 1e15;
@@ -900,4 +910,141 @@ contract TestAuditGaps_GasLimits is UnitTestSetup {
900
910
 
901
911
  emit log_named_uint("Gas used to mint from 50 tiers in single payment", gasUsed);
902
912
  }
913
+
914
+ /// @notice The expensive read paths scale with tier count, not just with the beneficiary's holdings.
915
+ /// This test exists to prove that a 100-tier catalog is materially more expensive than a 10-tier catalog even
916
+ /// when the queried user owns zero NFTs.
917
+ function test_operatingEnvelope_balanceOf_100tiersIsMateriallyMoreExpensiveThan10tiers() public {
918
+ uint256 gasFor10 = _measureBalanceOfGas({tierCount: 10});
919
+ uint256 gasFor100 = _measureBalanceOfGas({tierCount: 100});
920
+
921
+ assertGt(gasFor100, gasFor10 * 4, "100-tier balanceOf should be materially more expensive than 10 tiers");
922
+ emit log_named_uint("Gas used for balanceOf (10 tiers)", gasFor10);
923
+ emit log_named_uint("Gas used for balanceOf (100 tiers)", gasFor100);
924
+ }
925
+
926
+ /// @notice Cash-out accounting also scales with the catalog size because totalCashOutWeight walks the tier set.
927
+ /// We use a ratio check instead of an absolute snapshot so the test stays stable across compiler changes while
928
+ /// still proving the production-scale cost increase.
929
+ function test_operatingEnvelope_totalCashOutWeight_100tiersIsMateriallyMoreExpensiveThan10tiers() public {
930
+ uint256 gasFor10 = _measureTotalCashOutWeightGas({tierCount: 10, mintedCount: 10});
931
+ uint256 gasFor100 = _measureTotalCashOutWeightGas({tierCount: 100, mintedCount: 10});
932
+
933
+ assertGt(
934
+ gasFor100, gasFor10 * 4, "100-tier totalCashOutWeight should be materially more expensive than 10 tiers"
935
+ );
936
+ emit log_named_uint("Gas used for totalCashOutWeight (10 tiers)", gasFor10);
937
+ emit log_named_uint("Gas used for totalCashOutWeight (100 tiers)", gasFor100);
938
+ }
939
+
940
+ function _measureBalanceOfGas(uint256 tierCount) internal returns (uint256 gasUsed) {
941
+ defaultTierConfig.initialSupply = 10;
942
+ defaultTierConfig.reserveFrequency = 0;
943
+
944
+ ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
945
+ IJB721TiersHookStore hookStore = targetHook.STORE();
946
+
947
+ vm.prank(address(targetHook));
948
+ hookStore.recordAddTiers(_sequentialTierConfigs(tierCount, 1e15, 10));
949
+
950
+ uint256 gasBefore = gasleft();
951
+ hookStore.balanceOf(address(targetHook), beneficiary);
952
+ gasUsed = gasBefore - gasleft();
953
+ }
954
+
955
+ function _measureTotalCashOutWeightGas(uint256 tierCount, uint256 mintedCount) internal returns (uint256 gasUsed) {
956
+ defaultTierConfig.initialSupply = 10;
957
+ defaultTierConfig.reserveFrequency = 0;
958
+
959
+ ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
960
+ IJB721TiersHookStore hookStore = targetHook.STORE();
961
+
962
+ vm.prank(address(targetHook));
963
+ hookStore.recordAddTiers(_sequentialTierConfigs(tierCount, 1e15, 10));
964
+
965
+ mockAndExpect(
966
+ address(mockJBDirectory),
967
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
968
+ abi.encode(true)
969
+ );
970
+
971
+ uint16[] memory tierIdsToMint = new uint16[](mintedCount);
972
+ uint256 totalCost;
973
+ for (uint256 i; i < mintedCount; i++) {
974
+ // forge-lint: disable-next-line(unsafe-typecast)
975
+ tierIdsToMint[i] = uint16(i + 1);
976
+ totalCost += (i + 1) * 1e15;
977
+ }
978
+
979
+ bytes[] memory data = new bytes[](1);
980
+ data[0] = abi.encode(false, tierIdsToMint);
981
+ bytes4[] memory ids = new bytes4[](1);
982
+ ids[0] = metadataHelper.getId("pay", address(targetHook));
983
+ bytes memory payerMetadata = metadataHelper.createMetadata(ids, data);
984
+
985
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
986
+ payer: beneficiary,
987
+ projectId: projectId,
988
+ rulesetId: 0,
989
+ amount: JBTokenAmount({
990
+ token: JBConstants.NATIVE_TOKEN,
991
+ value: totalCost,
992
+ decimals: 18,
993
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
994
+ }),
995
+ forwardedAmount: JBTokenAmount({
996
+ token: JBConstants.NATIVE_TOKEN,
997
+ value: 0,
998
+ decimals: 18,
999
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1000
+ }),
1001
+ weight: 10e18,
1002
+ newlyIssuedTokenCount: 0,
1003
+ beneficiary: beneficiary,
1004
+ hookMetadata: bytes(""),
1005
+ payerMetadata: payerMetadata
1006
+ });
1007
+
1008
+ vm.prank(mockTerminalAddress);
1009
+ targetHook.afterPayRecordedWith(payContext);
1010
+
1011
+ uint256 gasBefore = gasleft();
1012
+ hookStore.totalCashOutWeight(address(targetHook));
1013
+ gasUsed = gasBefore - gasleft();
1014
+ }
1015
+
1016
+ function _sequentialTierConfigs(
1017
+ uint256 tierCount,
1018
+ uint104 priceStep,
1019
+ uint32 initialSupply
1020
+ )
1021
+ internal
1022
+ view
1023
+ returns (JB721TierConfig[] memory newTiers)
1024
+ {
1025
+ require(tierCount <= OPERATING_ENVELOPE_SOFT_LIMIT, "test helper only sized for envelope coverage");
1026
+
1027
+ newTiers = new JB721TierConfig[](tierCount);
1028
+ for (uint256 i; i < tierCount; i++) {
1029
+ newTiers[i] = JB721TierConfig({
1030
+ price: uint104((i + 1) * priceStep),
1031
+ initialSupply: initialSupply,
1032
+ votingUnits: 0,
1033
+ reserveFrequency: 0,
1034
+ reserveBeneficiary: reserveBeneficiary,
1035
+ encodedIPFSUri: tokenUris[i % 10],
1036
+ // forge-lint: disable-next-line(unsafe-typecast)
1037
+ category: uint24(i + 1),
1038
+ discountPercent: 0,
1039
+ allowOwnerMint: false,
1040
+ useReserveBeneficiaryAsDefault: false,
1041
+ transfersPausable: false,
1042
+ cannotBeRemoved: false,
1043
+ cannotIncreaseDiscountPercent: false,
1044
+ useVotingUnits: false,
1045
+ splitPercent: 0,
1046
+ splits: new JBSplit[](0)
1047
+ });
1048
+ }
1049
+ }
903
1050
  }