@bananapus/721-hook-v6 0.0.17 → 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.
Files changed (53) hide show
  1. package/ARCHITECTURE.md +4 -0
  2. package/CHANGE_LOG.md +47 -11
  3. package/README.md +19 -9
  4. package/RISKS.md +4 -0
  5. package/USER_JOURNEYS.md +4 -0
  6. package/package.json +2 -2
  7. package/src/JB721TiersHook.sol +87 -85
  8. package/src/abstract/JB721Hook.sol +2 -2
  9. package/src/libraries/JB721TiersHookLib.sol +4 -8
  10. package/test/TestAuditGaps.sol +147 -0
  11. package/test/fork/ERC20CashOutFork.t.sol +612 -0
  12. package/test/fork/IssueTokensForSplitsFork.t.sol +504 -0
  13. package/docs/book.css +0 -13
  14. package/docs/book.toml +0 -12
  15. package/docs/solidity.min.js +0 -74
  16. package/docs/src/README.md +0 -253
  17. package/docs/src/SUMMARY.md +0 -38
  18. package/docs/src/src/JB721TiersHook.sol/contract.JB721TiersHook.md +0 -645
  19. package/docs/src/src/JB721TiersHookDeployer.sol/contract.JB721TiersHookDeployer.md +0 -99
  20. package/docs/src/src/JB721TiersHookProjectDeployer.sol/contract.JB721TiersHookProjectDeployer.md +0 -288
  21. package/docs/src/src/JB721TiersHookStore.sol/contract.JB721TiersHookStore.md +0 -1096
  22. package/docs/src/src/README.md +0 -11
  23. package/docs/src/src/abstract/ERC721.sol/abstract.ERC721.md +0 -430
  24. package/docs/src/src/abstract/JB721Hook.sol/abstract.JB721Hook.md +0 -309
  25. package/docs/src/src/abstract/README.md +0 -5
  26. package/docs/src/src/interfaces/IJB721Hook.sol/interface.IJB721Hook.md +0 -29
  27. package/docs/src/src/interfaces/IJB721TiersHook.sol/interface.IJB721TiersHook.md +0 -203
  28. package/docs/src/src/interfaces/IJB721TiersHookDeployer.sol/interface.IJB721TiersHookDeployer.md +0 -25
  29. package/docs/src/src/interfaces/IJB721TiersHookProjectDeployer.sol/interface.IJB721TiersHookProjectDeployer.md +0 -64
  30. package/docs/src/src/interfaces/IJB721TiersHookStore.sol/interface.IJB721TiersHookStore.md +0 -265
  31. package/docs/src/src/interfaces/IJB721TokenUriResolver.sol/interface.IJB721TokenUriResolver.md +0 -12
  32. package/docs/src/src/interfaces/README.md +0 -9
  33. package/docs/src/src/libraries/JB721Constants.sol/library.JB721Constants.md +0 -14
  34. package/docs/src/src/libraries/JB721TiersRulesetMetadataResolver.sol/library.JB721TiersRulesetMetadataResolver.md +0 -68
  35. package/docs/src/src/libraries/JBBitmap.sol/library.JBBitmap.md +0 -82
  36. package/docs/src/src/libraries/JBIpfsDecoder.sol/library.JBIpfsDecoder.md +0 -61
  37. package/docs/src/src/libraries/README.md +0 -7
  38. package/docs/src/src/structs/JB721InitTiersConfig.sol/struct.JB721InitTiersConfig.md +0 -27
  39. package/docs/src/src/structs/JB721Tier.sol/struct.JB721Tier.md +0 -59
  40. package/docs/src/src/structs/JB721TierConfig.sol/struct.JB721TierConfig.md +0 -60
  41. package/docs/src/src/structs/JB721TiersHookFlags.sol/struct.JB721TiersHookFlags.md +0 -26
  42. package/docs/src/src/structs/JB721TiersMintReservesConfig.sol/struct.JB721TiersMintReservesConfig.md +0 -16
  43. package/docs/src/src/structs/JB721TiersRulesetMetadata.sol/struct.JB721TiersRulesetMetadata.md +0 -20
  44. package/docs/src/src/structs/JB721TiersSetDiscountPercentConfig.sol/struct.JB721TiersSetDiscountPercentConfig.md +0 -16
  45. package/docs/src/src/structs/JBBitmapWord.sol/struct.JBBitmapWord.md +0 -19
  46. package/docs/src/src/structs/JBDeploy721TiersHookConfig.sol/struct.JBDeploy721TiersHookConfig.md +0 -34
  47. package/docs/src/src/structs/JBLaunchProjectConfig.sol/struct.JBLaunchProjectConfig.md +0 -23
  48. package/docs/src/src/structs/JBLaunchRulesetsConfig.sol/struct.JBLaunchRulesetsConfig.md +0 -22
  49. package/docs/src/src/structs/JBPayDataHookRulesetConfig.sol/struct.JBPayDataHookRulesetConfig.md +0 -51
  50. package/docs/src/src/structs/JBPayDataHookRulesetMetadata.sol/struct.JBPayDataHookRulesetMetadata.md +0 -66
  51. package/docs/src/src/structs/JBQueueRulesetsConfig.sol/struct.JBQueueRulesetsConfig.md +0 -21
  52. package/docs/src/src/structs/JBStored721Tier.sol/struct.JBStored721Tier.md +0 -42
  53. package/docs/src/src/structs/README.md +0 -18
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/CHANGE_LOG.md CHANGED
@@ -113,15 +113,27 @@ The ERC721 collection `name` and `symbol` can now be changed after initializatio
113
113
  | `_setName(string memory)` | Updates the token collection name |
114
114
  | `_setSymbol(string memory)` | Updates the token collection symbol |
115
115
 
116
- ### 2.3 `beforePayRecordedWith` Override in `JB721TiersHook`
116
+ ### 2.3 `split.hook` Support in Tier Distribution
117
+
118
+ Tier split payouts now support the `split.hook` field (`IJBSplitHook`). The distribution priority follows the same order as `JBMultiTerminal`: hook > projectId > beneficiary. In v5, tier splits did not exist. In v6, this ensures full parity with core split behavior.
119
+
120
+ ### 2.4 DOS Protection on Split Distribution
121
+
122
+ All external calls during tier split distribution (split hooks, terminal `pay`/`addToBalanceOf`, and leftover distribution) are wrapped in try-catch. A reverting split recipient or terminal cannot brick payments to the project. On failure, funds are returned to the project balance and a `SplitPayoutReverted` or `AddToBalanceReverted` event is emitted with the revert reason. For ERC-20 split hook failures, tokens are transferred first and the hook callback is best-effort; for ERC-20 terminal failures, the approval is reset to zero.
123
+
124
+ ### 2.5 `splitPercent` Bounds Validation
125
+
126
+ `JB721TiersHookStore.recordAddTiers` now validates that each tier's `splitPercent` does not exceed `JBConstants.SPLITS_TOTAL_PERCENT`. If it does, it reverts with `JB721TiersHookStore_SplitPercentExceedsBounds(uint256 percent, uint256 limit)`.
127
+
128
+ ### 2.6 `beforePayRecordedWith` Override in `JB721TiersHook`
117
129
 
118
130
  v6 overrides `beforePayRecordedWith` in `JB721TiersHook` (not just the base `JB721Hook`). The override calculates per-tier split amounts and adjusts the minting weight so the terminal only mints project tokens for the portion of the payment that actually enters the project. It also sets the `JBPayHookSpecification.amount` to the total split amount so the terminal forwards those funds to the hook for distribution.
119
131
 
120
- ### 2.4 Pricing Decimals Validation
132
+ ### 2.7 Pricing Decimals Validation
121
133
 
122
134
  `initialize(...)` now reverts with `JB721TiersHook_InvalidPricingDecimals` if `tiersConfig.decimals > 18`.
123
135
 
124
- ### 2.5 `allowSetCustomToken` Pass-Through
136
+ ### 2.8 `allowSetCustomToken` Pass-Through
125
137
 
126
138
  The `JBPayDataHookRulesetMetadata` struct gained an `allowSetCustomToken` field. In v5, the project deployer hardcoded this to `false`. In v6, it passes through the value from the config.
127
139
 
@@ -133,8 +145,10 @@ The `JBPayDataHookRulesetMetadata` struct gained an `allowSetCustomToken` field.
133
145
 
134
146
  | Event | Signature | Description |
135
147
  |-------|-----------|-------------|
148
+ | `AddToBalanceReverted` | `AddToBalanceReverted(uint256 indexed projectId, address token, uint256 amount, bytes reason)` | Emitted when leftover distribution's `addToBalanceOf` call fails. Funds remain in the hook contract. |
136
149
  | `SetName` | `SetName(string indexed name, address caller)` | Emitted when the collection name is changed |
137
150
  | `SetSymbol` | `SetSymbol(string indexed symbol, address caller)` | Emitted when the collection symbol is changed |
151
+ | `SplitPayoutReverted` | `SplitPayoutReverted(uint256 indexed projectId, JBSplit split, uint256 amount, bytes reason, address caller)` | Emitted when a split payout reverts during distribution. The failed split's funds route to the project balance. |
138
152
 
139
153
  ### 3.2 New Event on `IJB721TiersHookStore`
140
154
 
@@ -144,7 +158,7 @@ The `JBPayDataHookRulesetMetadata` struct gained an `allowSetCustomToken` field.
144
158
 
145
159
  ### 3.3 Events Unchanged
146
160
 
147
- All other events (`AddPayCredits`, `AddTier`, `Mint`, `MintReservedNft`, `RemoveTier`, `SetBaseUri`, `SetContractUri`, `SetDiscountPercent`, `SetEncodedIPFSUri`, `SetTokenUriResolver`, `UsePayCredits`, `CleanTiers`, `HookDeployed`) remain identical.
161
+ All other events (`AddPayCredits`, `AddTier`, `Mint`, `MintReservedNft`, `RemoveTier`, `SetBaseUri`, `SetContractUri`, `SetDiscountPercent`, `SetEncodedIPFSUri`, `SetTokenUriResolver`, `UsePayCredits`, `CleanTiers`, `HookDeployed`) remain unchanged.
148
162
 
149
163
  ---
150
164
 
@@ -157,11 +171,17 @@ All other events (`AddPayCredits`, `AddTier`, `Mint`, `MintReservedNft`, `Remove
157
171
  | `JB721TiersHook_CurrencyMismatch` | `(uint256 paymentCurrency, uint256 tierCurrency)` | Reserved for currency mismatch detection |
158
172
  | `JB721TiersHook_InvalidPricingDecimals` | `(uint256 decimals)` | Reverts when `tiersConfig.decimals > 18` during initialization |
159
173
 
160
- ### 4.2 Store Errors with Added `tierId` Parameter
174
+ ### 4.2 New Error on `JB721TiersHookStore`
175
+
176
+ | Error | Signature | Description |
177
+ |-------|-----------|-------------|
178
+ | `JB721TiersHookStore_SplitPercentExceedsBounds` | `(uint256 percent, uint256 limit)` | Reverts when a tier's `splitPercent` exceeds `JBConstants.SPLITS_TOTAL_PERCENT` during `recordAddTiers` |
179
+
180
+ ### 4.3 Store Errors with Added `tierId` Parameter
161
181
 
162
182
  See Section 1.7 above. Eight store errors gained a `tierId` parameter for improved debuggability.
163
183
 
164
- ### 4.3 Errors Unchanged
184
+ ### 4.4 Errors Unchanged
165
185
 
166
186
  All other errors (`JB721Hook_InvalidPay`, `JB721Hook_InvalidCashOut`, `JB721Hook_UnauthorizedToken`, `JB721Hook_UnexpectedTokenCashedOut`, `JB721TiersHook_AlreadyInitialized`, `JB721TiersHook_NoProjectId`, `JB721TiersHook_Overspending`, `JB721TiersHook_MintReserveNftsPaused`, `JB721TiersHook_TierTransfersPaused`) remain unchanged.
167
187
 
@@ -240,15 +260,31 @@ In v5, `_packedPricingContext` packed three values: currency (bits 0-31), decima
240
260
 
241
261
  v5 reverted if `msg.value != 0` in `afterPayRecordedWith`. v6 removes this check because the hook now accepts forwarded native token payments for tier split distribution.
242
262
 
243
- ### 6.8 `JB721TiersHookStore.recordAddTiers` — Stores `splitPercent`
263
+ ### 6.8 `JB721TiersHookLib._sendPayoutToSplit` — `split.hook` Priority
264
+
265
+ Split payouts follow the same priority order as `JBMultiTerminal`: if `split.hook` is set, funds are sent to the hook via `processSplitWith`; otherwise if `split.projectId` is set, funds go to the project's primary terminal; otherwise funds go to `split.beneficiary`. For ERC-20 payments to hooks, tokens are transferred first and the hook callback is best-effort (failure does not revert).
266
+
267
+ ### 6.9 `JB721TiersHookLib` — Try-Catch on All External Calls
268
+
269
+ All external calls during split distribution (split hooks, terminal `pay`, terminal `addToBalanceOf`, and leftover `addToBalanceOf`) are wrapped in try-catch. On failure: native token calls return false (funds stay in the hook), ERC-20 terminal call approvals are reset to zero, and `SplitPayoutReverted` or `AddToBalanceReverted` events are emitted with the revert reason.
270
+
271
+ ### 6.10 `JB721TiersHookLib.calculateSplitAmounts` — Discount Applied Before Splits
272
+
273
+ Split amounts are calculated on the discount-adjusted (effective) tier price rather than the base price. This ensures the split amount matches the actual price the minter pays when a tier discount is active.
274
+
275
+ ### 6.11 `JB721TiersHookStore.recordAddTiers` — Validates `splitPercent`
276
+
277
+ `recordAddTiers` now reverts with `JB721TiersHookStore_SplitPercentExceedsBounds` if a tier's `splitPercent` exceeds `JBConstants.SPLITS_TOTAL_PERCENT`.
278
+
279
+ ### 6.12 `JB721TiersHookStore.recordAddTiers` — Stores `splitPercent`
244
280
 
245
281
  The store now stores `splitPercent` in the `JBStored721Tier` packed struct (replacing the `votingUnits` field in storage). The `votingUnits` value continues to be stored in the `_tierVotingUnitsOf` mapping.
246
282
 
247
- ### 6.9 `JB721TiersHookStore.recordAddTiers` — Emits `SetDefaultReserveBeneficiary`
283
+ ### 6.13 `JB721TiersHookStore.recordAddTiers` — Emits `SetDefaultReserveBeneficiary`
248
284
 
249
285
  When a tier config has `useReserveBeneficiaryAsDefault` set and the beneficiary differs from the current default, v6 emits the new `SetDefaultReserveBeneficiary` event.
250
286
 
251
- ### 6.10 `JB721TiersHookProjectDeployer` — `allowSetCustomToken` Pass-Through
287
+ ### 6.14 `JB721TiersHookProjectDeployer` — `allowSetCustomToken` Pass-Through
252
288
 
253
289
  In v5, the project deployer hardcoded `allowSetCustomToken: false` when constructing `JBRulesetMetadata`. In v6, it passes through `payDataRulesetConfig.metadata.allowSetCustomToken`.
254
290
 
@@ -261,7 +297,7 @@ In v5, the project deployer hardcoded `allowSetCustomToken: false` when construc
261
297
  | v5 | v6 | Notes |
262
298
  |----|----|-------|
263
299
  | `IJB721Hook` | `IJB721Hook` | Unchanged (import path updated) |
264
- | `IJB721TiersHook` | `IJB721TiersHook` | `pricingContext()` return changed; `setMetadata()` signature changed; added `PRICES()`, `SPLITS()`, `SetName`, `SetSymbol` |
300
+ | `IJB721TiersHook` | `IJB721TiersHook` | `pricingContext()` return changed; `setMetadata()` signature changed; added `PRICES()`, `SPLITS()`, `SetName`, `SetSymbol`, `SplitPayoutReverted`, `AddToBalanceReverted` |
265
301
  | `IJB721TiersHookDeployer` | `IJB721TiersHookDeployer` | Unchanged |
266
302
  | `IJB721TiersHookProjectDeployer` | `IJB721TiersHookProjectDeployer` | Unchanged |
267
303
  | `IJB721TiersHookStore` | `IJB721TiersHookStore` | Added `SetDefaultReserveBeneficiary` event; NatSpec added |
@@ -274,7 +310,7 @@ In v5, the project deployer hardcoded `allowSetCustomToken: false` when construc
274
310
  | `JB721TiersHook` | `JB721TiersHook` | Constructor gains `prices` and `splits`; tier splits system; code extracted to library |
275
311
  | `JB721TiersHookDeployer` | `JB721TiersHookDeployer` | Unchanged (import paths updated) |
276
312
  | `JB721TiersHookProjectDeployer` | `JB721TiersHookProjectDeployer` | `allowSetCustomToken` pass-through |
277
- | `JB721TiersHookStore` | `JB721TiersHookStore` | `splitPercent` storage; error params added; `SetDefaultReserveBeneficiary` event |
313
+ | `JB721TiersHookStore` | `JB721TiersHookStore` | `splitPercent` storage and validation; error params added; `SplitPercentExceedsBounds` error; `SetDefaultReserveBeneficiary` event |
278
314
  | `JB721Hook` (abstract) | `JB721Hook` (abstract) | `cashOutWeightOf`/`totalCashOutWeight` signatures simplified; `msg.value` check removed from `afterPayRecordedWith` |
279
315
  | `ERC721` (abstract) | `ERC721` (abstract) | Added `_setName()` and `_setSymbol()` |
280
316
 
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.17",
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(""));