@bananapus/721-hook-v6 0.0.19 → 0.0.21

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 (55) hide show
  1. package/ADMINISTRATION.md +39 -29
  2. package/ARCHITECTURE.md +48 -5
  3. package/AUDIT_INSTRUCTIONS.md +85 -12
  4. package/CHANGE_LOG.md +15 -1
  5. package/README.md +211 -210
  6. package/RISKS.md +18 -1
  7. package/SKILLS.md +107 -37
  8. package/STYLE_GUIDE.md +2 -2
  9. package/USER_JOURNEYS.md +44 -19
  10. package/foundry.toml +1 -1
  11. package/package.json +5 -5
  12. package/script/Deploy.s.sol +1 -1
  13. package/script/helpers/Hook721DeploymentLib.sol +1 -1
  14. package/src/JB721TiersHook.sol +1 -1
  15. package/src/JB721TiersHookDeployer.sol +1 -1
  16. package/src/JB721TiersHookProjectDeployer.sol +1 -1
  17. package/src/JB721TiersHookStore.sol +12 -1
  18. package/src/abstract/ERC721.sol +1 -1
  19. package/src/abstract/JB721Hook.sol +3 -3
  20. package/src/libraries/JB721TiersHookLib.sol +17 -3
  21. package/test/721HookAttacks.t.sol +1 -1
  22. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +1 -1
  23. package/test/Fork.t.sol +1 -1
  24. package/test/TestAuditGaps.sol +1 -1
  25. package/test/TestSafeTransferReentrancy.t.sol +1 -1
  26. package/test/TestVotingUnitsLifecycle.t.sol +1 -1
  27. package/test/audit/AuditRegressions.t.sol +83 -0
  28. package/test/audit/CodexNemesis_CrossCurrencySplitNoPrices.t.sol +122 -0
  29. package/test/audit/USDTVoidReturnCompat.t.sol +301 -0
  30. package/test/fork/ERC20CashOutFork.t.sol +1 -1
  31. package/test/fork/ERC20TierSplitFork.t.sol +1 -1
  32. package/test/fork/IssueTokensForSplitsFork.t.sol +1 -1
  33. package/test/invariants/TierLifecycleInvariant.t.sol +1 -1
  34. package/test/invariants/TieredHookStoreInvariant.t.sol +1 -1
  35. package/test/invariants/handlers/TierLifecycleHandler.sol +1 -1
  36. package/test/invariants/handlers/TierStoreHandler.sol +1 -1
  37. package/test/regression/BrokenTerminalDoesNotDos.t.sol +1 -1
  38. package/test/regression/CacheTierLookup.t.sol +1 -1
  39. package/test/regression/ProjectDeployerRulesets.t.sol +1 -1
  40. package/test/regression/ReserveBeneficiaryOverwrite.t.sol +1 -1
  41. package/test/regression/SplitDistributionBugs.t.sol +1 -1
  42. package/test/regression/SplitNoBeneficiary.t.sol +1 -1
  43. package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +1 -1
  44. package/test/unit/JBBitmap.t.sol +1 -1
  45. package/test/unit/JBIpfsDecoder.t.sol +1 -1
  46. package/test/unit/TierSupplyReserveCheck.t.sol +1 -1
  47. package/test/unit/adjustTier_Unit.t.sol +1 -1
  48. package/test/unit/deployer_Unit.t.sol +1 -1
  49. package/test/unit/getters_constructor_Unit.t.sol +4 -1
  50. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +1 -1
  51. package/test/unit/pay_CrossCurrency_Unit.t.sol +1 -1
  52. package/test/unit/pay_Unit.t.sol +1 -1
  53. package/test/unit/redeem_Unit.t.sol +1 -1
  54. package/test/unit/splitHookDistribution_Unit.t.sol +1 -1
  55. package/test/unit/tierSplitRouting_Unit.t.sol +1 -1
package/ADMINISTRATION.md CHANGED
@@ -6,7 +6,7 @@ Admin privileges and their scope in nana-721-hook-v6.
6
6
 
7
7
  ### Hook Owner (JBOwnable)
8
8
 
9
- - **Assigned by**: `initialize()` transfers ownership to the caller (line 285 of `JB721TiersHook.sol`). When deployed via `JB721TiersHookProjectDeployer.launchProjectFor()`, ownership is transferred to the project NFT (line 101 of `JB721TiersHookProjectDeployer.sol`), meaning the project owner controls the hook.
9
+ - **Assigned by**: `initialize()` transfers ownership to the caller. When deployed via `JB721TiersHookProjectDeployer.launchProjectFor()`, ownership is transferred to the project NFT, meaning the project owner controls the hook.
10
10
  - **Scope**: Per-hook instance. Each cloned hook has its own independent owner.
11
11
  - **Inheritance**: `JBOwnable` supports both EOA ownership and project-based ownership (owner = holder of the project's ERC-721 NFT). When ownership is transferred to a project via `transferOwnershipToProject()`, whoever owns that project NFT becomes the hook's owner.
12
12
 
@@ -20,7 +20,7 @@ Admin privileges and their scope in nana-721-hook-v6.
20
20
 
21
21
  - **Assigned by**: The project's `JBDirectory` configuration.
22
22
  - **Scope**: Only a contract registered as a terminal for the hook's project in `JBDirectory` can call `afterPayRecordedWith()` and `afterCashOutRecordedWith()`.
23
- - **Verification**: `DIRECTORY.isTerminalOf(projectId, IJBTerminal(msg.sender))` is checked at lines 195-197 and 236-237 of `JB721Hook.sol`.
23
+ - **Verification**: `DIRECTORY.isTerminalOf(projectId, IJBTerminal(msg.sender))` is checked in `JB721Hook.sol`.
24
24
 
25
25
  ### Store Callers (msg.sender Trust Model)
26
26
 
@@ -34,49 +34,49 @@ Admin privileges and their scope in nana-721-hook-v6.
34
34
 
35
35
  | Function | Permission ID | Checked Against | What It Does |
36
36
  |----------|--------------|-----------------|--------------|
37
- | `adjustTiers()` (line 322) | `ADJUST_721_TIERS` | `owner()` | Adds new tiers and/or soft-removes existing tiers. Sets tier split groups in JBSplits. |
38
- | `mintFor()` (line 338) | `MINT_721` | `owner()` | Manually mints NFTs from tiers that have `allowOwnerMint` enabled. Bypasses price checks (passes `type(uint256).max` as amount). |
39
- | `setDiscountPercentOf()` (line 389) | `SET_721_DISCOUNT_PERCENT` | `owner()` | Sets the discount percentage for a single tier. |
40
- | `setDiscountPercentsOf()` (line 399) | `SET_721_DISCOUNT_PERCENT` | `owner()` | Batch-sets discount percentages for multiple tiers. |
41
- | `setMetadata()` (line 430) | `SET_721_METADATA` | `owner()` | Updates collection name, symbol, baseURI, contractURI, tokenUriResolver, and/or per-tier encoded IPFS URIs. Empty strings leave values unchanged. |
42
- | `initialize()` (line 223) | None (one-time) | `PROJECT_ID == 0` check | Initializes a cloned hook. Can only be called once. Transfers ownership to caller on completion. |
37
+ | `adjustTiers()` | `ADJUST_721_TIERS` | `owner()` | Adds new tiers and/or soft-removes existing tiers. Sets tier split groups in JBSplits. |
38
+ | `mintFor()` | `MINT_721` | `owner()` | Manually mints NFTs from tiers that have `allowOwnerMint` enabled. Bypasses price checks (passes `type(uint256).max` as amount). |
39
+ | `setDiscountPercentOf()` | `SET_721_DISCOUNT_PERCENT` | `owner()` | Sets the discount percentage for a single tier. |
40
+ | `setDiscountPercentsOf()` | `SET_721_DISCOUNT_PERCENT` | `owner()` | Batch-sets discount percentages for multiple tiers. |
41
+ | `setMetadata()` | `SET_721_METADATA` | `owner()` | Updates collection name, symbol, baseURI, contractURI, tokenUriResolver, and/or per-tier encoded IPFS URIs. Empty strings leave values unchanged. |
42
+ | `initialize()` | None (one-time) | `PROJECT_ID == 0` check | Initializes a cloned hook. Can only be called once. Transfers ownership to caller on completion. |
43
43
 
44
44
  ### JB721TiersHookProjectDeployer
45
45
 
46
46
  | Function | Permission ID | Checked Against | What It Does |
47
47
  |----------|--------------|-----------------|--------------|
48
- | `launchProjectFor()` (line 74) | None | Anyone can call | Creates a new project with a 721 hook. Ownership goes to the specified `owner` address. |
49
- | `launchRulesetsFor()` (line 115) | `QUEUE_RULESETS` + `SET_TERMINALS` | Project NFT owner | Deploys a hook and launches rulesets for an existing project. |
50
- | `queueRulesetsOf()` (line 164) | `QUEUE_RULESETS` | Project NFT owner | Deploys a hook and queues rulesets for an existing project. |
48
+ | `launchProjectFor()` | None | Anyone can call | Creates a new project with a 721 hook. Ownership goes to the specified `owner` address. |
49
+ | `launchRulesetsFor()` | `QUEUE_RULESETS` + `SET_TERMINALS` | Project NFT owner | Deploys a hook and launches rulesets for an existing project. |
50
+ | `queueRulesetsOf()` | `QUEUE_RULESETS` | Project NFT owner | Deploys a hook and queues rulesets for an existing project. |
51
51
 
52
52
  ### JB721TiersHookDeployer
53
53
 
54
54
  | Function | Permission ID | Checked Against | What It Does |
55
55
  |----------|--------------|-----------------|--------------|
56
- | `deployHookFor()` (line 68) | None | Anyone can call | Clones and initializes a new hook instance. Ownership starts with the deployer contract, then is transferred to `msg.sender`. |
56
+ | `deployHookFor()` | None | Anyone can call | Clones and initializes a new hook instance. Ownership starts with the deployer contract, then is transferred to `msg.sender`. |
57
57
 
58
58
  ### JB721Hook (Abstract Base)
59
59
 
60
60
  | Function | Required Caller | What It Does |
61
61
  |----------|----------------|--------------|
62
- | `afterPayRecordedWith()` (line 231) | Project terminal | Processes payment, mints NFTs. Verifies caller via `DIRECTORY.isTerminalOf()`. |
63
- | `afterCashOutRecordedWith()` (line 183) | Project terminal | Burns NFTs on cash out. Verifies caller via `DIRECTORY.isTerminalOf()` and that `msg.value == 0`. |
62
+ | `afterPayRecordedWith()` | Project terminal | Processes payment, mints NFTs. Verifies caller via `DIRECTORY.isTerminalOf()`. |
63
+ | `afterCashOutRecordedWith()` | Project terminal | Burns NFTs on cash out. Verifies caller via `DIRECTORY.isTerminalOf()` and that `msg.value == 0`. |
64
64
 
65
65
  ### JB721TiersHookStore (No Access Control -- msg.sender Keyed)
66
66
 
67
67
  | Function | Caller | What It Does |
68
68
  |----------|--------|--------------|
69
- | `recordAddTiers()` (line 772) | Hook contract | Adds tiers to the caller's namespace. Category sort order enforced. |
70
- | `recordRemoveTierIds()` (line 1139) | Hook contract | Marks tiers as removed in bitmap. Respects `cannotBeRemoved` flag. |
71
- | `recordMint()` (line 1020) | Hook contract | Records mints, decrements supply, enforces price and reserve checks. |
72
- | `recordMintReservesFor()` (line 1103) | Hook contract | Mints reserved NFTs from a tier. |
73
- | `recordBurn()` (line 995) | Hook contract | Increments burn counter for token IDs. |
74
- | `recordFlags()` (line 1010) | Hook contract | Sets behavioral flags for the caller's hook. |
75
- | `recordSetTokenUriResolver()` (line 1193) | Hook contract | Sets the token URI resolver. |
76
- | `recordSetEncodedIPFSUriOf()` (line 1187) | Hook contract | Sets the encoded IPFS URI for a tier. |
77
- | `recordSetDiscountPercentOf()` (line 1161) | Hook contract | Updates a tier's discount percent. Enforces bounds and `cannotIncreaseDiscountPercent`. |
78
- | `recordTransferForTier()` (line 1201) | Hook contract | Updates per-tier balance tracking on transfer. |
79
- | `cleanTiers()` (line 726) | Anyone | Reorganizes the tier sorting linked list to skip removed tiers. Pure bookkeeping, no value at risk. |
69
+ | `recordAddTiers()` | Hook contract | Adds tiers to the caller's namespace. Category sort order enforced. |
70
+ | `recordRemoveTierIds()` | Hook contract | Marks tiers as removed in bitmap. Respects `cannotBeRemoved` flag. |
71
+ | `recordMint()` | Hook contract | Records mints, decrements supply, enforces price and reserve checks. |
72
+ | `recordMintReservesFor()` | Hook contract | Mints reserved NFTs from a tier. |
73
+ | `recordBurn()` | Hook contract | Increments burn counter for token IDs. |
74
+ | `recordFlags()` | Hook contract | Sets behavioral flags for the caller's hook. |
75
+ | `recordSetTokenUriResolver()` | Hook contract | Sets the token URI resolver. |
76
+ | `recordSetEncodedIPFSUriOf()` | Hook contract | Sets the encoded IPFS URI for a tier. |
77
+ | `recordSetDiscountPercentOf()` | Hook contract | Updates a tier's discount percent. Enforces bounds and `cannotIncreaseDiscountPercent`. |
78
+ | `recordTransferForTier()` | Hook contract | Updates per-tier balance tracking on transfer. |
79
+ | `cleanTiers()` | Anyone | Reorganizes the tier sorting linked list to skip removed tiers. Pure bookkeeping, no value at risk. |
80
80
 
81
81
  ## Permission System
82
82
 
@@ -123,6 +123,16 @@ The following are set at deploy/initialization time and **cannot be changed afte
123
123
  | Per-tier `price` | `recordAddTiers()` | The base price (and cash-out weight) of NFTs in the tier |
124
124
  | Per-tier `category` | `recordAddTiers()` | The category grouping for sort order |
125
125
 
126
+ ## Clone Pattern
127
+
128
+ `JB721TiersHook` is deployed as an implementation contract and then cloned via `LibClone.clone()` in `JB721TiersHookDeployer`. Each clone is a minimal proxy that delegates all calls to the implementation.
129
+
130
+ **Admin implications:**
131
+ - The implementation contract cannot be self-destructed or modified after deployment. Even if it could be, clones would break since they `delegatecall` to the implementation address.
132
+ - Each clone has its own storage (including `PROJECT_ID`, ownership, and tier data). The implementation's storage is unused.
133
+ - `METADATA_ID_TARGET` is set to the original implementation address, ensuring consistent metadata ID derivation across all clones.
134
+ - The `initialize()` function uses a `PROJECT_ID == 0` guard (not OpenZeppelin `Initializable`) to prevent re-initialization. This is safe because `PROJECT_ID` is set during initialization and cannot return to zero.
135
+
126
136
  ## Ruleset-Level Pauses
127
137
 
128
138
  Two behaviors are controlled by the project's current ruleset metadata (packed into the 14-bit `metadata` field of `JBRulesetMetadata`), parsed by `JB721TiersRulesetMetadataResolver`:
@@ -142,10 +152,10 @@ What the hook owner **cannot** do:
142
152
  - **Cannot change tier prices after creation.** The `price` field in `JBStored721Tier` is set once in `recordAddTiers()` and never modified. Cash-out weight is always based on the original price.
143
153
  - **Cannot change reserve frequency after creation.** The `reserveFrequency` is immutable per tier.
144
154
  - **Cannot reduce a tier's initial supply.** Supply can only decrease through minting and burning.
145
- - **Cannot remove a tier marked `cannotBeRemoved`.** The store enforces this in `recordRemoveTierIds()` (line 1151).
146
- - **Cannot increase a tier's discount if `cannotIncreaseDiscountPercent` is set.** The store enforces this in `recordSetDiscountPercentOf()` (line 1176).
147
- - **Cannot mint from tiers without `allowOwnerMint`.** The `mintFor()` function passes `isOwnerMint: true` to the store, which checks the flag (line 1060).
148
- - **Cannot re-initialize a hook.** The `initialize()` function reverts if `PROJECT_ID != 0` (line 237).
155
+ - **Cannot remove a tier marked `cannotBeRemoved`.** The store enforces this in `recordRemoveTierIds()`.
156
+ - **Cannot increase a tier's discount if `cannotIncreaseDiscountPercent` is set.** The store enforces this in `recordSetDiscountPercentOf()`.
157
+ - **Cannot mint from tiers without `allowOwnerMint`.** The `mintFor()` function passes `isOwnerMint: true` to the store, which checks the flag.
158
+ - **Cannot re-initialize a hook.** The `initialize()` function reverts if `PROJECT_ID != 0`.
149
159
  - **Cannot change the pricing currency, decimals, or prices contract.** `PRICES` is immutable (set in constructor), and the currency/decimals in `_packedPricingContext` are set once during initialization.
150
160
  - **Cannot bypass the flag restrictions.** Once `noNewTiersWithReserves`, `noNewTiersWithVotes`, or `noNewTiersWithOwnerMinting` are set, all future tiers added via `adjustTiers()` must comply.
151
161
  - **Cannot mint more reserves than the formula allows.** Reserve mints are bounded by `ceil(nonReserveMints / reserveFrequency)`.
package/ARCHITECTURE.md CHANGED
@@ -20,7 +20,11 @@ src/
20
20
  │ ├── JB721Hook.sol — Base ERC-721 + pay/cashout hook integration
21
21
  │ └── ERC721.sol — Minimal ERC-721 implementation
22
22
  ├── libraries/
23
- └── JB721TiersHookLib.sol Split calculations, price normalization, weight math, fund distribution
23
+ ├── JB721Constants.sol Global constants (DISCOUNT_DENOMINATOR)
24
+ │ ├── JB721TiersHookLib.sol — Split calculations, price normalization, weight math, fund distribution
25
+ │ ├── JB721TiersRulesetMetadataResolver.sol — Bit-packed 721 ruleset metadata (transfer/reserve pause flags)
26
+ │ ├── JBBitmap.sol — Bitmap utilities for tier removal tracking
27
+ │ └── JBIpfsDecoder.sol — IPFS CID encoding/decoding for token URIs
24
28
  ├── interfaces/ — All interfaces (IJB721TiersHook, etc.)
25
29
  └── structs/ — Tier config, mint context, cash-out structs
26
30
  ```
@@ -35,23 +39,46 @@ User → JBMultiTerminal.pay(metadata)
35
39
  → convertSplitAmounts(): convert to payment token denomination (if currencies differ)
36
40
  → calculateWeight(): adjust weight down by split fraction
37
41
  → JBTerminalStore records payment
38
- → afterPayRecordedWith()
42
+ → afterPayRecordedWith() → _processPayment()
43
+ → Normalize payment value to tier pricing currency
39
44
  → Decode tier IDs from metadata
40
45
  → For each tier:
41
46
  → Validate: not removed, not paused, supply available
42
47
  → Check price (with optional discount, normalized to tier pricing currency)
43
48
  → Mint NFT to beneficiary
49
+ → Leftover amount stored as pay credits (revert if overspending not allowed)
44
50
  → Distribute split funds (priority: split.hook > split.projectId > split.beneficiary)
45
- → Leftover amount optionally mints best-available tiers
46
51
  ```
47
52
 
53
+ #### Split Amount and Price Normalization
54
+
55
+ Tiers can be priced in a different currency than the payment token (e.g. tiers priced in USD while payments arrive in ETH). The split/weight pipeline in `beforePayRecordedWith` resolves this in two steps:
56
+
57
+ 1. **`calculateSplitAmounts`** — For each tier the payer wants to mint, looks up its `splitPercent` (the fraction of the tier price that should be routed to the tier's split group rather than into the project treasury). Computes `effectivePrice * splitPercent / SPLITS_TOTAL_PERCENT` for each tier, where `effectivePrice` accounts for any active discount. Returns the total and a per-tier breakdown, all denominated in the **tier pricing currency**.
58
+
59
+ 2. **`convertSplitAmounts`** — If the payment currency differs from the tier pricing currency, converts every per-tier split amount (and the total) into the **payment token denomination** using `JBPrices`. This is necessary because the terminal will subtract the split amount from the payment value, so both must be in the same unit.
60
+
61
+ After conversion, `calculateWeight` scales the terminal's minting weight down by the fraction of the payment that was routed to splits (unless the `issueTokensForSplits` flag is set, in which case the full weight is preserved).
62
+
63
+ During `afterPayRecordedWith`, the payment value is separately normalized into the tier pricing currency via `normalizePaymentValue` so that tier prices can be compared against what was paid. The forwarded split funds are then distributed to each tier's split group by `distributeAll`.
64
+
65
+ ### Discount Mechanism
66
+
67
+ Each tier has a `discountPercent` (0-200 scale where 200 = 100% discount / free mint). The project owner can adjust it via `setDiscountPercentOf`, subject to a per-tier `cannotIncreaseDiscountPercent` flag that prevents raising the discount once set. Discounts can only be decreased unless the tier explicitly allows increases.
68
+
69
+ Key behaviors:
70
+ - **Minting**: The store applies the discount when checking the price during `recordMint`, so payers pay less.
71
+ - **Split calculations**: `calculateSplitAmounts` applies the discount to the tier price before computing the split portion, so splits are proportional to the actual amount paid.
72
+ - **Cash-out weight**: Uses the **original undiscounted price**, not the effective price. This means a discounted NFT carries the same cash-out weight as a full-price one. Project owners should be aware that heavily discounted mints dilute the cash-out pool at full weight while contributing less to the treasury.
73
+
48
74
  ### NFT Cash Out
49
75
  ```
50
76
  Holder → JBMultiTerminal.cashOutTokensOf()
51
77
  → JB721TiersHook.afterCashOutRecordedWith()
52
78
  → Burn specified NFT token IDs
53
- → Each NFT's cash-out weight contributes to reclaim amount
54
- Weight = tier.initialQuantity * tier.price (or custom)
79
+ → Each NFT's cash-out weight = tier.price (full price, ignoring discounts)
80
+ Total cash-out weight (denominator) = sum of (tier.price * (outstanding + pending)) across all tiers
81
+ → where outstanding = minted - burned, pending = pending reserve mints
55
82
  ```
56
83
 
57
84
  ### Tier Management
@@ -69,6 +96,22 @@ Owner → JB721TiersHook.adjustTiers()
69
96
  | Pay hook | `IJBPayHook` | Called after payment recorded |
70
97
  | Cash out hook | `IJBCashOutHook` | Called during cash out |
71
98
 
99
+ ## Design Decisions
100
+
101
+ 1. **Clone-based deployment** — `JB721TiersHookDeployer` uses Solady's `LibClone` to deploy minimal proxies of a reference `JB721TiersHook` instance. This keeps deployment cost low and consistent regardless of the hook contract's bytecode size. Each clone is initialized via `initialize()` (not a constructor), which sets the project ID, tiers, and flags. Deterministic deploys are supported via `cloneDeterministic` with a caller-scoped salt.
102
+
103
+ 2. **Separate store contract** — `JB721TiersHookStore` holds all tier data, mint tracking, and pricing logic in a standalone contract shared across hook instances. This serves two purposes: it keeps the hook contract under the EIP-170 size limit (24 KB), and it allows the store to act as the `msg.sender` for state mutations (tier additions use `msg.sender` as the hook key), providing a natural access-control boundary.
104
+
105
+ 3. **Category sorting instead of price sorting** — Tiers are stored in a linked list sorted by `category` (a uint24 grouping field), not by price. This lets projects organize NFTs into logical groups (e.g. membership tiers, collectibles, special editions) and query them by group. The `InvalidCategorySortOrder` error enforces that new tiers are added in non-decreasing category order.
106
+
107
+ 4. **Cash-out weight uses `initialSupply * price`** — The `totalCashOutWeight` denominator sums `price * (outstanding + pendingReserves)` across all tiers, where outstanding = minted minus burned. Each individual NFT's weight is simply its tier's `price`. Using the original undiscounted price (rather than what was actually paid) ensures that cash-out values are stable and predictable: discounts are treated as transient purchase incentives, not permanent reductions in an NFT's share of the treasury.
108
+
109
+ 5. **Library extraction for EIP-170 compliance** — `JB721TiersHookLib` contains split calculations, price normalization, fund distribution, tier adjustment logic, and IPFS URI decoding. These are called via `external` library functions (which deploy as a separate contract) or via `DELEGATECALL`. This pattern keeps the hook contract's deployed bytecode under the 24 KB limit while preserving the ability to emit events from the hook's address.
110
+
111
+ 6. **Discount denominator of 200** — The `discountPercent` field is a uint8 with a denominator of 200 (`JB721Constants.DISCOUNT_DENOMINATOR`). A value of 200 represents 100% discount (free mint), giving 0.5% granularity. The `cannotIncreaseDiscountPercent` flag on each tier lets project owners create promotional discounts that can be reduced but never increased beyond their initial level.
112
+
113
+ 7. **Split fund distribution with try-catch** — All external calls during split distribution (to split hooks, terminals, and beneficiaries) are wrapped in try-catch. A reverting recipient does not brick payments for the entire project.
114
+
72
115
  ## Dependencies
73
116
  - `@bananapus/core-v6` — Core protocol interfaces
74
117
  - `@bananapus/ownable-v6` — JB-aware ownership
@@ -4,6 +4,66 @@ You are auditing the Juicebox V6 tiered NFT hook system. This hook allows Juiceb
4
4
 
5
5
  Read [ARCHITECTURE.md](./ARCHITECTURE.md) first for data flow context. Read [RISKS.md](./RISKS.md) for 19 known risks with test coverage mapping. Then come back here.
6
6
 
7
+ ## Compiler and Version Info
8
+
9
+ | Setting | Value |
10
+ |---------|-------|
11
+ | Solidity version | 0.8.28 |
12
+ | EVM target | cancun |
13
+ | Optimizer | enabled, 200 runs |
14
+ | via-IR | not enabled |
15
+ | Fuzz runs | 4,096 |
16
+ | Invariant runs | 1,024 (depth 100) |
17
+
18
+ Source: [`foundry.toml`](./foundry.toml)
19
+
20
+ ## Previous Audit Findings
21
+
22
+ A Nemesis automated audit was conducted on 2026-03-17. Results are in [`.audit/findings/nemesis-verified.md`](./.audit/findings/nemesis-verified.md). Summary:
23
+
24
+ | ID | Severity | Title | Status |
25
+ |----|----------|-------|--------|
26
+ | NM-001 | MEDIUM | Unprotected external calls in tier split distribution cascade to full payment revert | **Remediated** -- hook callbacks, terminal calls, and native-token sends in `_sendPayoutToSplit` are now wrapped in try-catch; ERC-20 beneficiary `safeTransfer` remains unwrapped (reverts only if the token itself reverts) |
27
+ | NM-002 | LOW | `_addToBalance` silently drops funds when no primary terminal | Open |
28
+ | NM-003 | LOW | Missing `splitPercent` bounds validation in `recordAddTiers` | **Remediated** -- `SplitPercentExceedsBounds` check added at `JB721TiersHookStore.sol:866` |
29
+ | NM-004 | LOW | Implementation contract initializable | Open (no fund risk) |
30
+ | NM-005 | LOW | `setMetadata` uses non-standard sentinel for tokenUriResolver | Open (documented behavior) |
31
+
32
+ No prior formal audit with finding IDs from an external security firm has been conducted. See [RISKS.md](./RISKS.md) for the project's own risk assessment.
33
+
34
+ ## Error Reference
35
+
36
+ | Error | Contract | Trigger Condition |
37
+ |-------|----------|-------------------|
38
+ | `JB721TiersHookStore_CantMintManually(uint256 tierId)` | JB721TiersHookStore | `recordMint` called with `isManualMint=true` on a tier with `allowOwnerMint=false` |
39
+ | `JB721TiersHookStore_CantRemoveTier(uint256 tierId)` | JB721TiersHookStore | `recordRemoveTierIds` called on a tier with `cannotBeRemoved=true` |
40
+ | `JB721TiersHookStore_DiscountPercentExceedsBounds(uint256 percent, uint256 limit)` | JB721TiersHookStore | `recordSetDiscountPercentOf` or `recordAddTiers` with `discountPercent > DISCOUNT_DENOMINATOR (200)` |
41
+ | `JB721TiersHookStore_DiscountPercentIncreaseNotAllowed(uint256 percent, uint256 storedPercent)` | JB721TiersHookStore | `recordSetDiscountPercentOf` increases discount on a tier with `cannotIncreaseDiscountPercent=true` |
42
+ | `JB721TiersHookStore_InsufficientPendingReserves(uint256 count, uint256 numberOfPendingReserves)` | JB721TiersHookStore | `recordMintReservesFor` called with `count > pendingReserves` |
43
+ | `JB721TiersHookStore_InsufficientSupplyRemaining(uint256 tierId)` | JB721TiersHookStore | `recordMint` when `remainingSupply < pendingReserves` after decrement |
44
+ | `JB721TiersHookStore_InvalidCategorySortOrder(uint256 tierCategory, uint256 previousTierCategory)` | JB721TiersHookStore | `recordAddTiers` with tiers not in ascending category order |
45
+ | `JB721TiersHookStore_InvalidQuantity(uint256 quantity, uint256 limit)` | JB721TiersHookStore | `recordAddTiers` with `initialSupply >= _ONE_BILLION` |
46
+ | `JB721TiersHookStore_ManualMintingNotAllowed(uint256 tierId)` | JB721TiersHookStore | `recordMint` called as manual mint when `noNewTiersWithOwnerMinting` flag is set and tier allows it |
47
+ | `JB721TiersHookStore_MaxTiersExceeded(uint256 numberOfTiers, uint256 limit)` | JB721TiersHookStore | `recordAddTiers` would push `maxTierIdOf` above `type(uint16).max` (65,535) |
48
+ | `JB721TiersHookStore_PriceExceedsAmount(uint256 price, uint256 leftoverAmount)` | JB721TiersHookStore | `recordMint` when tier's (discounted) price exceeds remaining payment amount |
49
+ | `JB721TiersHookStore_ReserveFrequencyNotAllowed(uint256 tierId)` | JB721TiersHookStore | `recordAddTiers` with `reserveFrequency > 0` when `noNewTiersWithReserves` flag is set |
50
+ | `JB721TiersHookStore_SplitPercentExceedsBounds(uint256 percent, uint256 limit)` | JB721TiersHookStore | `recordAddTiers` with `splitPercent` exceeding bounds |
51
+ | `JB721TiersHookStore_TierRemoved(uint256 tierId)` | JB721TiersHookStore | `recordMint` or `recordSetDiscountPercentOf` called on a removed tier |
52
+ | `JB721TiersHookStore_UnrecognizedTier(uint256 tierId)` | JB721TiersHookStore | Any operation referencing a `tierId` that does not exist (> `maxTierIdOf` or never created) |
53
+ | `JB721TiersHookStore_VotingUnitsNotAllowed(uint256 tierId)` | JB721TiersHookStore | `recordAddTiers` with `votingUnits > 0` when `noNewTiersWithVotes` flag is set |
54
+ | `JB721TiersHookStore_ZeroInitialSupply(uint256 tierId)` | JB721TiersHookStore | `recordAddTiers` with `initialSupply == 0` |
55
+ | `JB721TiersHook_AlreadyInitialized(uint256 projectId)` | JB721TiersHook | `initialize()` called on a hook that already has `PROJECT_ID != 0` |
56
+ | `JB721TiersHook_CurrencyMismatch(uint256 paymentCurrency, uint256 tierCurrency)` | JB721TiersHook | Payment currency differs from tier pricing currency and no price feed is configured |
57
+ | `JB721TiersHook_InvalidPricingDecimals(uint256 decimals)` | JB721TiersHook | `initialize()` with `pricingDecimals > 18` |
58
+ | `JB721TiersHook_MintReserveNftsPaused()` | JB721TiersHook | `mintPendingReservesFor` called when `mintPendingReservesPaused` ruleset flag is active |
59
+ | `JB721TiersHook_NoProjectId()` | JB721TiersHook | `initialize()` called with `projectId == 0` |
60
+ | `JB721TiersHook_Overspending(uint256 leftoverAmount)` | JB721TiersHook | Payment has leftover after minting and `allowOverspending` metadata flag is false |
61
+ | `JB721TiersHook_TierTransfersPaused()` | JB721TiersHook | NFT transfer attempted on a tier with `transfersPausable=true` when `transfersPaused` ruleset flag is active |
62
+ | `JB721Hook_InvalidCashOut()` | JB721Hook | `afterCashOutRecordedWith` called by a non-terminal address |
63
+ | `JB721Hook_InvalidPay()` | JB721Hook | `afterPayRecordedWith` called by a non-terminal address |
64
+ | `JB721Hook_UnauthorizedToken(uint256 tokenId, address holder)` | JB721Hook | `afterCashOutRecordedWith` with a token ID not owned by the cash-out holder |
65
+ | `JB721Hook_UnexpectedTokenCashedOut()` | JB721Hook | `beforeCashOutRecordedWith` called with `cashOutCount > 0` (fungible tokens cannot be cashed out through this hook) |
66
+
7
67
  ## Architecture
8
68
 
9
69
  Four contracts, one library:
@@ -14,7 +74,7 @@ Four contracts, one library:
14
74
  | `JB721TiersHookStore` | ~1230 | All tier state. Keyed by `msg.sender` (the hook address). Manages tier CRUD, supply tracking, reserve accounting, bitmap-based removal, sorted linked list, transfer balance tracking, voting units, discount enforcement. |
15
75
  | `JB721TiersHookDeployer` | ~115 | Deploys hook clones (Solady `LibClone`). Optional deterministic addressing via salt. Atomic deploy + initialize + ownership transfer. Registers with `JBAddressRegistry`. |
16
76
  | `JB721TiersHookProjectDeployer` | ~420 | Convenience: launches a project + hook in one transaction. Converts `JBPayDataHookRulesetConfig` to `JBRulesetConfig` with `useDataHookForPay: true` hardcoded. Also supports `launchRulesetsFor` and `queueRulesetsOf`. |
17
- | `JB721TiersHookLib` (library) | ~607 | Extracted logic for EIP-170 compliance. Tier adjustments, split amount calculation, price normalization, weight adjustment, split fund distribution, token URI resolution. Called via DELEGATECALL from the hook. |
77
+ | `JB721TiersHookLib` (library) | ~634 | Extracted logic for EIP-170 compliance. Tier adjustments, split amount calculation, price normalization, weight adjustment, split fund distribution, token URI resolution. Called via DELEGATECALL from the hook. |
18
78
 
19
79
  Supporting:
20
80
  - `JB721Hook` (abstract, ~270 lines) -- Base ERC-721 with `beforePayRecordedWith`, `beforeCashOutRecordedWith`, `afterPayRecordedWith`, `afterCashOutRecordedWith`. Terminal authorization checks.
@@ -270,6 +330,19 @@ These must hold. If you can break any, it's a finding:
270
330
  9. **Removal idempotency**: Removing an already-removed tier is a no-op (bitmap set is idempotent).
271
331
  10. **NFT supply cap**: Minted count per tier never exceeds `initialSupply` (same as invariant 1, but auditors should verify the `_ONE_BILLION - 1` cap prevents token ID overflow into the next tier).
272
332
 
333
+ ## Anti-Patterns to Hunt
334
+
335
+ | Pattern | Where to Look | Why It's Dangerous |
336
+ |---------|--------------|-------------------|
337
+ | DELEGATECALL from hook to library | `JB721TiersHook` → `JB721TiersHookLib` | Library executes in the hook's storage context. A subtle mismatch in storage layout assumptions could corrupt state. |
338
+ | `safeTransfer` before callback | `_sendPayoutToSplit` ERC-20 path | Tokens leave the contract before the hook callback. The function returns `true` regardless of callback success to prevent double-spend in leftover accounting. |
339
+ | `forceApprove` + external call | `_sendPayoutToSplit` terminal path | If the external call fails, the approval is reset to zero. But between `forceApprove` and the failure, the approval exists. Can an attacker exploit this window? |
340
+ | `mulDiv` rounding in price normalization | `normalizePaymentValue`, `convertSplitAmounts` | Rounding through the conversion chain (normalize → calculate splits → convert back) can compound. Verify rounding favors the protocol. |
341
+ | Bitmap-based removal with iteration by maxTierIdOf | `totalCashOutWeight`, `cleanTiers` | `totalCashOutWeight` iterates up to `maxTierIdOf`, not by sorted list. If many tiers are added and removed, gas cost grows unboundedly. |
342
+ | Clone initialization guard | `JB721TiersHookDeployer` | `initialize()` is guarded by `PROJECT_ID != 0`, not OpenZeppelin's `Initializable`. Verify the implementation contract cannot be initialized. |
343
+ | `_mint` instead of `_safeMint` | `JB721TiersHook` | No `onERC721Received` callback. Prevents mint-time DoS but contracts won't detect incoming NFTs. |
344
+ | Token ID overflow at tier boundary | `_generateTokenId` | `tokenId = tierId * 1_000_000_000 + tokenNumber`. If `tokenNumber` reaches `_ONE_BILLION`, it overflows into the next tier's namespace. Supply cap enforcement (`_ONE_BILLION - 1`) prevents this -- verify the enforcement is complete. |
345
+
273
346
  ## Testing Setup
274
347
 
275
348
  ```bash
@@ -295,23 +368,22 @@ forge test --gas-report
295
368
 
296
369
  | Category | Files | Coverage |
297
370
  |----------|------:|---------|
298
- | Unit tests | 10 | adjustTier, deployer, getters/constructor, mintFor/mintReservesFor, pay, redeem, tierSplitRouting, splitHookDistribution, JBBitmap, JBIpfsDecoder |
371
+ | Unit tests | 13 | adjustTier, deployer, getters/constructor, mintFor/mintReservesFor, pay, redeem, tierSplitRouting, splitHookDistribution, JBBitmap, JBIpfsDecoder, pay_CrossCurrency, JB721TiersRulesetMetadataResolver, TierSupplyReserveCheck |
299
372
  | Invariant tests | 2 + 2 handlers | TierLifecycleInvariant (6), TieredHookStoreInvariant (3) |
300
373
  | Attack tests | 1 | 10 adversarial scenarios |
301
- | Regression tests | 3 | L34, L35, L36 |
374
+ | Regression tests | 6 | BrokenTerminalDoesNotDos, CacheTierLookup, ProjectDeployerRulesets, ReserveBeneficiaryOverwrite, SplitDistributionBugs, SplitNoBeneficiary |
302
375
  | E2E tests | 1 | Full lifecycle |
303
- | Fork tests | 1 | Live chain state |
304
- | Cross-currency | 1 | 9 tests for price feed behavior |
376
+ | Fork tests | 3 | ERC20CashOutFork, ERC20TierSplitFork, IssueTokensForSplitsFork |
305
377
  | Supply edge cases | 1 | M6 -- 4 targeted tests |
378
+ | Reentrancy tests | 1 | TestSafeTransferReentrancy -- safeTransfer reentrancy scenarios |
379
+ | Voting units tests | 1 | TestVotingUnitsLifecycle -- voting power through mint/burn/transfer |
306
380
 
307
- ### Notable Coverage Gaps
381
+ ### Coverage Gaps
308
382
 
309
- 1. ~~No reentrancy test for split distribution `.call{value}` or `terminal.pay()` path.~~ Mitigated: all external calls in `_sendPayoutToSplit` are now wrapped in try-catch. `TestAuditGaps_Reentrancy` confirms reentrancy is blocked by terminal check.
310
- 2. No gas limit test for operations with hundreds of tiers.
311
- 3. No test for malicious/reverting token URI resolver.
312
- 4. No test for `initialize()` front-running on deterministic clones.
313
- 5. No fuzz test for discount percent edge cases with very small prices.
314
- 6. ~~No test for cross-terminal reentry through split `terminal.pay()` callback.~~ Mitigated: terminal calls are wrapped in try-catch; hook state is fully settled before distribution begins.
383
+ 1. No gas limit test for operations with hundreds of tiers.
384
+ 2. No test for malicious/reverting token URI resolver.
385
+ 3. No test for `initialize()` front-running on deterministic clones.
386
+ 4. No fuzz test for discount percent edge cases with very small prices.
315
387
 
316
388
  ## How to Report Findings
317
389
 
@@ -338,3 +410,4 @@ For each finding:
338
410
  - Is `DISCOUNT_DENOMINATOR = 200` surprising but correct? (It is.)
339
411
  - Does the store's `msg.sender`-keyed trust model handle the case? (The store trusts the hook.)
340
412
  - Is the economic attack profitable after the core protocol's 2.5% fee on cash outs?
413
+
package/CHANGE_LOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  This document describes all changes between `nana-721-hook` (v5) and `nana-721-hook-v6` (v6).
4
4
 
5
+ ## Summary
6
+
7
+ - **Tier splits system**: Each NFT tier can now route a percentage of payments to split recipients instead of the project treasury, with full DOS protection via try-catch on all external calls.
8
+ - **`votingUnits` replaced by `splitPercent`** in packed tier storage — voting units moved to a separate mapping to free the storage slot for the more widely-needed split feature.
9
+ - **New `JB721TiersHookLib` library**: Code extracted from the hook to stay within the EIP-170 contract size limit (24KB).
10
+ - **Mutable collection metadata**: `name` and `symbol` can now be changed post-deployment via `setMetadata()`.
11
+ - **Prices moved to constructor immutable**: `IJBPrices` is now a constructor parameter instead of being packed in tier config.
12
+
5
13
  ---
6
14
 
7
15
  ## 1. Breaking Changes
@@ -47,6 +55,8 @@ This document describes all changes between `nana-721-hook` (v5) and `nana-721-h
47
55
  | `votingUnits` (`uint32`) | Present | Removed | Replaced by `splitPercent` |
48
56
  | `splitPercent` (`uint32`) | Not present | Added | Percentage of tier price routed to splits |
49
57
 
58
+ > **Why this change**: On-chain voting units were rarely used by projects, while per-tier payment splits were the most requested feature. Reusing the same `uint32` storage slot avoided expanding the packed struct and kept gas costs unchanged. Voting units are still available via the `_tierVotingUnitsOf` mapping.
59
+
50
60
  ### 1.7 Error Signature Changes (Store)
51
61
 
52
62
  Several store errors gained a `tierId` parameter for better debugging:
@@ -64,7 +74,11 @@ Several store errors gained a `tierId` parameter for better debugging:
64
74
 
65
75
  ### 1.8 Solidity Version Change
66
76
 
67
- All contracts upgraded from `pragma solidity 0.8.23` to `pragma solidity 0.8.26`.
77
+ All contracts upgraded from `pragma solidity 0.8.23` to `pragma solidity 0.8.28`.
78
+
79
+ ### 1.9 Cross-Repo Impact
80
+
81
+ > The tier splits system affects `revnet-core-v6`, `croptop-core-v6`, and `nana-omnichain-deployers-v6`, which all deploy 721 hooks and must account for the new `splitPercent`/`splits` fields in `JB721TierConfig`. The `issueTokensForSplits` flag in `JB721TiersHookFlags` is force-set to `false` by `revnet-core-v6` to prevent dilution.
68
82
 
69
83
  ---
70
84