@bananapus/721-hook-v6 0.0.21 → 0.0.23

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/ADMINISTRATION.md CHANGED
@@ -39,7 +39,7 @@ Admin privileges and their scope in nana-721-hook-v6.
39
39
  | `setDiscountPercentOf()` | `SET_721_DISCOUNT_PERCENT` | `owner()` | Sets the discount percentage for a single tier. |
40
40
  | `setDiscountPercentsOf()` | `SET_721_DISCOUNT_PERCENT` | `owner()` | Batch-sets discount percentages for multiple tiers. |
41
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. |
42
+ | `initialize()` | None (one-time) | `_initialized` flag check | Initializes a cloned hook. Can only be called once. Transfers ownership to caller on completion. |
43
43
 
44
44
  ### JB721TiersHookProjectDeployer
45
45
 
@@ -131,7 +131,7 @@ The following are set at deploy/initialization time and **cannot be changed afte
131
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
132
  - Each clone has its own storage (including `PROJECT_ID`, ownership, and tier data). The implementation's storage is unused.
133
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.
134
+ - The `initialize()` function uses an `_initialized` bool flag to prevent re-initialization. The implementation contract's constructor sets `_initialized = true`, blocking direct initialization. Clones start with `_initialized = false` and set it to `true` during `initialize()`.
135
135
 
136
136
  ## Ruleset-Level Pauses
137
137
 
@@ -155,7 +155,7 @@ What the hook owner **cannot** do:
155
155
  - **Cannot remove a tier marked `cannotBeRemoved`.** The store enforces this in `recordRemoveTierIds()`.
156
156
  - **Cannot increase a tier's discount if `cannotIncreaseDiscountPercent` is set.** The store enforces this in `recordSetDiscountPercentOf()`.
157
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`.
158
+ - **Cannot re-initialize a hook.** The `initialize()` function reverts if `_initialized` is already true.
159
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.
160
160
  - **Cannot bypass the flag restrictions.** Once `noNewTiersWithReserves`, `noNewTiersWithVotes`, or `noNewTiersWithOwnerMinting` are set, all future tiers added via `adjustTiers()` must comply.
161
161
  - **Cannot mint more reserves than the formula allows.** Reserve mints are bounded by `ceil(nonReserveMints / reserveFrequency)`.
package/ARCHITECTURE.md CHANGED
@@ -36,7 +36,7 @@ src/
36
36
  User → JBMultiTerminal.pay(metadata)
37
37
  → beforePayRecordedWith()
38
38
  → calculateSplitAmounts(): per-tier split amounts (in tier pricing denomination)
39
- convertSplitAmounts(): convert to payment token denomination (if currencies differ)
39
+ convertAndCapSplitAmounts(): convert to payment token denomination (if currencies differ)
40
40
  → calculateWeight(): adjust weight down by split fraction
41
41
  → JBTerminalStore records payment
42
42
  → afterPayRecordedWith() → _processPayment()
@@ -56,7 +56,7 @@ Tiers can be priced in a different currency than the payment token (e.g. tiers p
56
56
 
57
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
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.
59
+ 2. **`convertAndCapSplitAmounts`** — 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`, and caps the total at the actual payment value. This is necessary because the terminal will subtract the split amount from the payment value, so both must be in the same unit.
60
60
 
61
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
62
 
@@ -110,7 +110,7 @@ Owner → JB721TiersHook.adjustTiers()
110
110
 
111
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
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.
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. Failed amounts are accumulated separately during the loop and routed to the project balance afterward, ensuring proportional redistribution -- later recipients receive only their configured share, not an inflated share from earlier failures.
114
114
 
115
115
  ## Dependencies
116
116
  - `@bananapus/core-v6` — Core protocol interfaces
@@ -19,17 +19,17 @@ Source: [`foundry.toml`](./foundry.toml)
19
19
 
20
20
  ## Previous Audit Findings
21
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:
22
+ An automated audit was conducted on 2026-03-17. Summary:
23
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) |
24
+ | Severity | Title | Status |
25
+ |----------|-------|--------|
26
+ | 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
+ | LOW | `_addToBalance` silently drops funds when no primary terminal | **Remediated** -- reverts when `primaryTerminalOf` returns `address(0)` and there are leftover funds |
28
+ | LOW | Missing `splitPercent` bounds validation in `recordAddTiers` | **Remediated** -- `SplitPercentExceedsBounds` check added at `JB721TiersHookStore.sol:866` |
29
+ | LOW | Implementation contract initializable | **Remediated** -- constructor now sets `_initialized = true` on the implementation |
30
+ | LOW | `setMetadata` uses non-standard sentinel for tokenUriResolver | Open (documented behavior) |
31
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.
32
+ See [RISKS.md](./RISKS.md) for the project's own risk assessment.
33
33
 
34
34
  ## Error Reference
35
35
 
@@ -52,7 +52,7 @@ No prior formal audit with finding IDs from an external security firm has been c
52
52
  | `JB721TiersHookStore_UnrecognizedTier(uint256 tierId)` | JB721TiersHookStore | Any operation referencing a `tierId` that does not exist (> `maxTierIdOf` or never created) |
53
53
  | `JB721TiersHookStore_VotingUnitsNotAllowed(uint256 tierId)` | JB721TiersHookStore | `recordAddTiers` with `votingUnits > 0` when `noNewTiersWithVotes` flag is set |
54
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` |
55
+ | `JB721TiersHook_AlreadyInitialized(uint256 projectId)` | JB721TiersHook | `initialize()` called on a hook where `_initialized` is already true |
56
56
  | `JB721TiersHook_CurrencyMismatch(uint256 paymentCurrency, uint256 tierCurrency)` | JB721TiersHook | Payment currency differs from tier pricing currency and no price feed is configured |
57
57
  | `JB721TiersHook_InvalidPricingDecimals(uint256 decimals)` | JB721TiersHook | `initialize()` with `pricingDecimals > 18` |
58
58
  | `JB721TiersHook_MintReserveNftsPaused()` | JB721TiersHook | `mintPendingReservesFor` called when `mintPendingReservesPaused` ruleset flag is active |
@@ -295,11 +295,11 @@ Verify that:
295
295
  ### 5. Initialization and Clone Security
296
296
 
297
297
  `JB721TiersHookDeployer` creates minimal proxy clones:
298
- - `initialize()` is guarded by `PROJECT_ID != 0` (not `Initializable`)
298
+ - `initialize()` is guarded by an `_initialized` bool flag
299
299
  - Ownership is transferred to `_msgSender()` inside `initialize`, then to the deployer caller in `deployHookFor`
300
300
 
301
301
  Verify that:
302
- - The implementation contract (HOOK) cannot be initialized (its `PROJECT_ID` is 0 by default -- can someone call initialize on it?)
302
+ - The implementation contract's constructor sets `_initialized = true`, preventing initialization.
303
303
  - Deterministic salt derivation (`keccak256(abi.encode(_msgSender(), salt))`) prevents cross-deployer address collision
304
304
  - Front-running `deployHookFor` cannot hijack ownership
305
305
 
@@ -339,7 +339,7 @@ These must hold. If you can break any, it's a finding:
339
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
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
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. |
342
+ | Clone initialization guard | `JB721TiersHookDeployer` | `initialize()` is guarded by an `_initialized` bool flag. The implementation contract's constructor sets `_initialized = true`. Verify clones cannot be re-initialized. |
343
343
  | `_mint` instead of `_safeMint` | `JB721TiersHook` | No `onERC721Received` callback. Prevents mint-time DoS but contracts won't detect incoming NFTs. |
344
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
345
 
package/CHANGE_LOG.md CHANGED
@@ -105,7 +105,7 @@ Contains functions extracted from `JB721TiersHook` to stay within the EIP-170 co
105
105
  | `recordAddTiersFor(...)` | Records new tiers and sets their split groups (used during initialization) |
106
106
  | `normalizePaymentValue(...)` | Normalizes payment value based on pricing context |
107
107
  | `calculateSplitAmounts(...)` | Calculates per-tier split amounts for a pay event |
108
- | `convertSplitAmounts(...)` | Converts split amounts between currencies |
108
+ | `convertAndCapSplitAmounts(...)` | Converts split amounts between currencies, capped at payment value |
109
109
  | `calculateWeight(...)` | Adjusts minting weight to account for split amounts |
110
110
  | `distributeAll(...)` | Pulls tokens and distributes forwarded funds to tier split recipients |
111
111
  | `resolveTokenURI(...)` | Resolves the token URI (moved IPFS decoding out of hook) |
@@ -133,7 +133,7 @@ Tier split payouts now support the `split.hook` field (`IJBSplitHook`). The dist
133
133
 
134
134
  ### 2.4 DOS Protection on Split Distribution
135
135
 
136
- 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.
136
+ 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, the failed amount is accumulated separately and routed to the project balance after the distribution loop completes. This proportional redistribution ensures that later split recipients receive only their configured share, not an inflated share from the failed recipient's funds. 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.
137
137
 
138
138
  ### 2.5 `splitPercent` Bounds Validation
139
139
 
@@ -280,7 +280,7 @@ Split payouts follow the same priority order as `JBMultiTerminal`: if `split.hoo
280
280
 
281
281
  ### 6.9 `JB721TiersHookLib` — Try-Catch on All External Calls
282
282
 
283
- 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.
283
+ All external calls during split distribution (split hooks, terminal `pay`, terminal `addToBalanceOf`, and leftover `addToBalanceOf`) are wrapped in try-catch. On failure: the failed amount is accumulated separately and routed to the project balance after the distribution loop, ensuring proportional (not equal) redistribution among remaining recipients. 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.
284
284
 
285
285
  ### 6.10 `JB721TiersHookLib.calculateSplitAmounts` — Discount Applied Before Splits
286
286
 
package/RISKS.md CHANGED
@@ -6,7 +6,7 @@
6
6
  - **Tier configuration is partially immutable.** Once created: `price`, `initialSupply`, `reserveFrequency`, `category`, `votingUnits`, `splitPercent` are permanent. Mutable: `discountPercent` (owner-controlled, subject to `cannotIncreaseDiscountPercent`), `encodedIPFSUri` (owner-controlled).
7
7
  - **Category sort order is enforced only at insertion.** `recordAddTiers` reverts `InvalidCategorySortOrder` if tiers are not ascending by category. The sorted linked list (`_tierIdAfter`) depends on this invariant across all `adjustTiers` calls. Direct store callers could corrupt the list.
8
8
  - **`useReserveBeneficiaryAsDefault` has global side effects.** Setting this on ANY new tier overwrites `defaultReserveBeneficiaryOf` for ALL existing tiers that lack a tier-specific `_reserveBeneficiaryOf` entry. Documented but dangerous when calling `adjustTiers` on hooks with existing tiers.
9
- - **Clone initialization is one-shot, atomic.** `initialize()` guards via `PROJECT_ID != 0`. Since `PROJECT_ID` is a storage variable (not immutable), both the implementation and fresh clones start at zero. After `initialize()` sets it, any subsequent call reverts. Deployer contracts call deploy+initialize in a single transaction, preventing front-running. Ownership transfers to `_msgSender()` at the end of `initialize`.
9
+ - **Clone initialization is one-shot, atomic.** `initialize()` guards via an `_initialized` bool flag. The implementation contract's constructor sets `_initialized = true`, blocking direct initialization. Clones start with `_initialized = false` and set it to `true` during `initialize()`. After `initialize()` sets it, any subsequent call reverts. Deployer contracts call deploy+initialize in a single transaction, preventing front-running. Ownership transfers to `_msgSender()` at the end of `initialize`.
10
10
  - **`balanceOf(address(0))` reverts.** Unlike the standard ERC-721 `balanceOf` which may return zero, the hook overrides `balanceOf` to revert with `JB721TiersHook_ZeroAddress` when called with `address(0)`. This guards against misleading zero-balance results for the zero address.
11
11
  - **`tokenURI` reverts for nonexistent tokens.** Calling `tokenURI` with a token ID that has never been minted reverts with `ERC721NonexistentToken(tokenId)`. The check is `_ownerOf(tokenId) == address(0)`.
12
12
  - **JBDirectory is trusted for terminal authentication.** `afterPayRecordedWith` and `afterCashOutRecordedWith` check `DIRECTORY.isTerminalOf()`. If the directory is compromised, arbitrary addresses can invoke pay/cashout hooks.
@@ -27,7 +27,7 @@
27
27
  ## 3. Reentrancy Surface
28
28
 
29
29
  - **Split hook callbacks (`processSplitWith`).** During `afterPayRecordedWith` -> `_processPayment` -> `distributeAll`, the library calls `split.hook.processSplitWith{value}()` for each split with a hook. This executes arbitrary code. At callback time: NFTs already minted, `payCreditsOf` updated, `remainingSupply` decremented in the store. Reentering `afterPayRecordedWith` requires terminal authentication and processes as an independent payment. All split hook and terminal calls are wrapped in try-catch to prevent a single reverting recipient from bricking all payments to the project. For native token hooks, a revert returns false (ETH stays in the contract and routes to project balance). For ERC20 hooks, tokens are transferred before the callback; a revert still returns true because the tokens have already left the contract. Tested: `TestAuditGaps_Reentrancy` confirms reentrancy is blocked by terminal check.
30
- - **Split beneficiary ETH sends.** `_sendPayoutToSplit` uses `beneficiary.call{value: amount}("")`. If the beneficiary reverts, the function returns `false` and funds route to the project's balance via `_addToBalance`. Does not revert the entire payment.
30
+ - **Split beneficiary ETH sends.** `_sendPayoutToSplit` uses `beneficiary.call{value: amount}("")`. If the beneficiary reverts, the function returns `false` and the failed amount is accumulated separately, then routed to the project's balance after the distribution loop via `addToBalanceOf`. Later split recipients receive only their proportional share, not the failed recipient's share. Does not revert the entire payment.
31
31
  - **Terminal `.pay()` / `.addToBalanceOf()` during split distribution.** For project-targeted splits, the library calls the target project's primary terminal via try-catch. A reverting terminal returns false, routing the funds to the project's balance instead. For ERC20 terminal calls, approval is reset to zero on failure to prevent dangling approvals. The target terminal could call back into the hook, but the hook's state is fully settled (supply, credits, mint state). Reentrancy through this path cannot double-mint or corrupt state.
32
32
  - **`afterCashOutRecordedWith` execution order.** Burns tokens via `_burn()` -> `_update()` -> `STORE.recordTransferForTier()` in a loop, then calls `STORE.recordBurn()`. ERC721 `_update` triggers the store's tier balance decrement. Burns go to `address(0)`, so no `onERC721Received` callback.
33
33
  - **No `ReentrancyGuard`.** Protection relies on state ordering (all `STORE.record*` calls before external calls), terminal authentication checks, and try-catch wrapping of all external calls in `_sendPayoutToSplit`. `_mint()` uses the non-safe variant, avoiding `onERC721Received` callbacks during minting.
package/SKILLS.md CHANGED
@@ -60,7 +60,7 @@ Tiered ERC-721 NFT hook for Juicebox V6 that mints NFTs when a project is paid a
60
60
  | `votingUnitsOf(hook, account)` | `JB721TiersHookStore` | Returns total voting units for an address across all tiers. |
61
61
  | `tierVotingUnitsOf(hook, account, tierId)` | `JB721TiersHookStore` | Returns voting units for an address within a specific tier. |
62
62
  | `calculateSplitAmounts(store, hook, metadataIdTarget, metadata)` | `JB721TiersHookLib` | Decodes tier IDs from metadata, computes per-tier split amounts from effective price and `splitPercent`. |
63
- | `convertSplitAmounts(totalSplitAmount, splitMetadata, packedPricingContext, prices, projectId, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts split amounts from tier pricing denomination to payment token denomination via `JBPrices`. |
63
+ | `convertAndCapSplitAmounts(totalSplitAmount, splitMetadata, packedPricingContext, prices, projectId, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts split amounts from tier pricing denomination to payment token denomination via `JBPrices`, capped at the actual payment value. |
64
64
  | `distributeAll(directory, splits, projectId, hookAddress, token, amount, decimals, encodedSplitData)` | `JB721TiersHookLib` | Distributes forwarded split funds to tier split recipients. Leftover goes to project balance via `addToBalance`. |
65
65
  | `adjustTiersFor(store, splits, projectId, hookAddress, caller, tiersToAdd, tierIdsToRemove)` | `JB721TiersHookLib` | Called via DELEGATECALL from `adjustTiers`. Removes tiers, adds tiers, emits events, registers splits. |
66
66
  | `normalizePaymentValue(packedPricingContext, prices, projectId, amountValue, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts a payment value to the hook's pricing currency using `JBPrices`. |
@@ -86,7 +86,7 @@ Tiered ERC-721 NFT hook for Juicebox V6 that mints NFTs when a project is paid a
86
86
  | `JB721Tier` | `uint32 id`, `uint104 price`, `uint32 remainingSupply`, `uint32 initialSupply`, `uint104 votingUnits`, `uint16 reserveFrequency`, `address reserveBeneficiary`, `bytes32 encodedIPFSUri`, `uint24 category`, `uint8 discountPercent`, `bool allowOwnerMint`, `bool transfersPausable`, `bool cannotBeRemoved`, `bool cannotIncreaseDiscountPercent`, `uint32 splitPercent`, `string resolvedUri` | Return type from `tierOf`, `tiersOf`, `tierOfTokenId` |
87
87
  | `JBStored721Tier` | `uint104 price`, `uint32 remainingSupply`, `uint32 initialSupply`, `uint32 splitPercent`, `uint24 category`, `uint8 discountPercent`, `uint16 reserveFrequency`, `uint8 packedBools` (allowOwnerMint, transfersPausable, useVotingUnits, cannotBeRemoved, cannotIncreaseDiscountPercent) | Internal storage in `JB721TiersHookStore`. Voting units stored separately in `_tierVotingUnitsOf` when `useVotingUnits` is true. |
88
88
  | `JB721InitTiersConfig` | `JB721TierConfig[] tiers`, `uint32 currency`, `uint8 decimals` | `initialize` -- defines tiers and pricing context. The prices contract is an immutable on the hook, not passed per-config. |
89
- | `JBDeploy721TiersHookConfig` | `string name`, `string symbol`, `string baseUri`, `IJB721TokenUriResolver tokenUriResolver`, `string contractUri`, `JB721InitTiersConfig tiersConfig`, `address reserveBeneficiary`, `JB721TiersHookFlags flags` | `deployHookFor`, `launchProjectFor` |
89
+ | `JBDeploy721TiersHookConfig` | `string name`, `string symbol`, `string baseUri`, `IJB721TokenUriResolver tokenUriResolver`, `string contractUri`, `JB721InitTiersConfig tiersConfig`, `JB721TiersHookFlags flags` | `deployHookFor`, `launchProjectFor` |
90
90
  | `JB721TiersHookFlags` | `bool noNewTiersWithReserves`, `bool noNewTiersWithVotes`, `bool noNewTiersWithOwnerMinting`, `bool preventOverspending`, `bool issueTokensForSplits` | `initialize`, `recordFlags` |
91
91
  | `JB721TiersRulesetMetadata` | `bool pauseTransfers`, `bool pauseMintPendingReserves` | Packed into `JBRulesetMetadata.metadata` per-ruleset (bit 0 = pauseTransfers, bit 1 = pauseMintPendingReserves) |
92
92
  | `JBPayDataHookRulesetConfig` | `uint48 mustStartAtOrAfter`, `uint32 duration`, `uint112 weight`, `uint32 weightCutPercent`, `IJBRulesetApprovalHook approvalHook`, `JBPayDataHookRulesetMetadata metadata`, `JBSplitGroup[] splitGroups`, `JBFundAccessLimitGroup[] fundAccessLimitGroups` | `JB721TiersHookProjectDeployer` -- wraps core ruleset config with `useDataHookForPay: true` hardcoded |
@@ -144,9 +144,11 @@ Each tier has configurable voting power:
144
144
  - Applies the tier's `discountPercent` to derive the effective price.
145
145
  - Computes `mulDiv(effectivePrice, splitPercent, SPLITS_TOTAL_PERCENT)` per tier.
146
146
  - Returns the total to be forwarded to the hook.
147
- - If the payment currency differs from the tier pricing currency, `convertSplitAmounts` converts amounts to the payment token denomination via `JBPrices`.
147
+ - If the payment currency differs from the tier pricing currency, `convertAndCapSplitAmounts` converts amounts to the payment token denomination via `JBPrices` and caps the total at the actual payment value.
148
+ - **Pay credits cap**: `totalSplitAmount` is capped at `context.amount.value` before weight calculation and before being returned in the hook specification. Pay credits fund NFT minting (virtual -- they reduce the price threshold in `recordMint`), but splits require real tokens to distribute. Without this cap, a payer with sufficient pay credits but insufficient actual payment value would cause the terminal to attempt forwarding more tokens than were actually received, reverting the transaction. This means the project receiving the NFT payment must have received the full amount including what would have gone to splits -- split recipients are paid from the project's balance, not directly from the payer's contribution.
148
149
  - Weight is adjusted down proportionally (`weight = mulDiv(weight, amount - totalSplitAmount, amount)`) unless the `issueTokensForSplits` flag is set, in which case the full `context.weight` is returned.
149
150
  - In `afterPayRecordedWith`, `distributeAll` distributes forwarded funds to each tier's split group recipients. Leftover after all splits goes back to the project's balance via `addToBalance`.
151
+ - **Leftover accounting in `_distributeSingleSplit`**: `leftoverAmount` is always decremented **before** the send attempt. If `_sendPayoutToSplit` returns `false` (i.e., the send reverted or had no valid recipient), the failed amount is accumulated in a separate variable (not added back to `leftoverAmount`). After the loop completes, accumulated failed amounts are added to `leftoverAmount` and routed to the project's balance. This "decrement-first, accumulate-failures-separately" pattern uses proportional redistribution: a failed split does not inflate subsequent split recipients' shares because the failed amount is withheld from `leftoverAmount` during the loop, and `leftoverPercentage` still decreases regardless of success.
150
152
  - Split recipients follow the same priority chain as `JBMultiTerminal`: `split.hook` > `split.projectId` > `split.beneficiary`:
151
153
  - **Split hooks**: receive a `JBSplitHookContext` struct (`token`, `amount`, `decimals`, `projectId`, `groupId`, full `split`). Native tokens forwarded via `{value: amount}`; ERC-20s transferred via `SafeERC20.safeTransfer` before calling `processSplitWith`.
152
154
  - **Project splits**: route via `terminal.pay` or `terminal.addToBalance`.
@@ -154,7 +156,7 @@ Each tier has configurable voting power:
154
156
  - **Empty splits** (no hook, no project ID, no beneficiary): skipped -- their share stays in the leftover and routes to the project's balance via `addToBalanceOf`, preventing a misconfigured split from bricking the payout distribution.
155
157
  - All external calls in `_sendPayoutToSplit` are wrapped in try-catch to prevent a single reverting recipient from bricking all payments to the project:
156
158
  - **Native token hooks**: a revert returns `false` (ETH stays in the contract and routes to the project balance).
157
- - **ERC-20 hooks**: tokens are transferred via `safeTransfer` before the callback; the function always returns `true` regardless of callback outcome because the tokens have already left -- returning `false` would cause double-spend accounting in the leftover calculation.
159
+ - **ERC-20 hooks**: tokens are transferred via `safeTransfer` before the callback; the function always returns `true` regardless of callback outcome because the tokens have already left this contract. Since leftoverAmount was already decremented before the send, returning `true` is required to prevent the caller from adding the amount back -- which would create a double-spend (tokens sent to the hook AND counted toward leftover routed to the project).
158
160
  - **ERC-20 terminal calls** (`pay`/`addToBalanceOf`): approval is reset to zero on failure to prevent dangling approvals.
159
161
 
160
162
  ## Gotchas
@@ -197,7 +199,7 @@ Each tier has configurable voting power:
197
199
  | `JB721Hook_InvalidPay()` | `JB721Hook` | `afterPayRecordedWith` caller is not a project terminal. |
198
200
  | `JB721Hook_UnauthorizedToken(tokenId, holder)` | `JB721Hook` | Cash out attempts to burn a token not owned by `context.holder`. |
199
201
  | `JB721Hook_UnexpectedTokenCashedOut()` | `JB721Hook` | `beforeCashOutRecordedWith` called with `cashOutCount > 0` (fungible tokens mixed with NFT cash out). |
200
- | `JB721TiersHook_AlreadyInitialized(projectId)` | `JB721TiersHook` | `initialize` called on a hook that already has a `PROJECT_ID`. |
202
+ | `JB721TiersHook_AlreadyInitialized(projectId)` | `JB721TiersHook` | `initialize` called on a hook where `_initialized` is already true. |
201
203
  | `JB721TiersHook_CurrencyMismatch(paymentCurrency, tierCurrency)` | `JB721TiersHook` | Payment currency differs from tier pricing currency and no `PRICES` contract is configured for conversion. |
202
204
  | `JB721TiersHook_InvalidPricingDecimals(decimals)` | `JB721TiersHook` | `initialize` called with `decimals > 18`. |
203
205
  | `JB721TiersHook_MintReserveNftsPaused()` | `JB721TiersHook` | `mintPendingReservesFor` called while `pauseMintPendingReserves` is set in the current ruleset metadata. |
@@ -292,7 +294,6 @@ tiers[0] = JB721TierConfig({
292
294
  currency: 1, // ETH
293
295
  decimals: 18
294
296
  }),
295
- reserveBeneficiary: address(0),
296
297
  flags: JB721TiersHookFlags({
297
298
  noNewTiersWithReserves: false,
298
299
  noNewTiersWithVotes: false,
package/USER_JOURNEYS.md CHANGED
@@ -47,7 +47,7 @@ A user pays a Juicebox project and receives tiered NFTs based on the amount paid
47
47
  - **Last slot is reserved**: If minting would leave `remainingSupply < pendingReserves`, reverts with `JB721TiersHookStore_InsufficientSupplyRemaining`.
48
48
  - **Cross-currency payment with no price feed**: `normalizePaymentValue` returns `(0, false)`. The hook returns without minting or reverting. The payment is still processed by the terminal.
49
49
  - **Discounted tier**: Effective price is `price - mulDiv(price, discountPercent, 200)`. A `discountPercent` of 200 makes it free.
50
- - **Split distribution**: If a split recipient's `.call{value}` fails, the funds stay in `leftoverAmount` and are added to the project's balance.
50
+ - **Split distribution**: If a split recipient's payout reverts, the failed amount is accumulated separately and added to the project's balance after the loop. Later split recipients receive only their proportional share, not the failed recipient's share.
51
51
 
52
52
  ---
53
53
 
@@ -315,7 +315,6 @@ Deploy a 721 tiers hook for an existing project.
315
315
  - `JB721TierConfig[] tiers` -- initial tiers (sorted by category).
316
316
  - `uint32 currency` -- pricing currency (`uint32(uint160(tokenAddress))` for concrete, or abstract like 1=ETH, 2=USD).
317
317
  - `uint8 decimals` -- pricing decimals (must be <= 18).
318
- - `address reserveBeneficiary` -- (unused in current initialize; set via tier configs).
319
318
  - `JB721TiersHookFlags flags` -- collection-level behavior flags.
320
319
  - `bytes32 salt` -- for deterministic deployment (bytes32(0) for non-deterministic).
321
320
 
@@ -331,11 +330,11 @@ Deploy a 721 tiers hook for an existing project.
331
330
  - `SetDefaultReserveBeneficiary(hook, newBeneficiary, caller)` -- if any initial tier has `useReserveBeneficiaryAsDefault = true`.
332
331
 
333
332
  **Edge cases**:
334
- - **Re-initialization**: Reverts with `JB721TiersHook_AlreadyInitialized` if `PROJECT_ID` is already set.
333
+ - **Re-initialization**: Reverts with `JB721TiersHook_AlreadyInitialized` if `_initialized` is already true.
335
334
  - **`projectId == 0`**: Reverts with `JB721TiersHook_NoProjectId`.
336
335
  - **`decimals > 18`**: Reverts with `JB721TiersHook_InvalidPricingDecimals`.
337
336
  - **Deterministic salt collision**: Reverts at the EVM level (CREATE2 collision).
338
- - **Implementation contract**: The original `HOOK` has `PROJECT_ID == 0`, so anyone could call `initialize` on it. However, since it is the implementation (not a clone), this would just set state on the implementation which has no operational significance -- clones do not read the implementation's storage.
337
+ - **Implementation contract**: The implementation contract's constructor sets `_initialized = true`, so no one can call `initialize` on it.
339
338
 
340
339
  ---
341
340
 
@@ -0,0 +1,83 @@
1
+ # 🔐 Security Review — nana-721-hook-v6
2
+
3
+ ---
4
+
5
+ ## Scope
6
+
7
+ | | |
8
+ | -------------------------------- | ------------------------------------------------------ |
9
+ | **Mode** | ALL / default |
10
+ | **Files reviewed** | `script/Deploy.s.sol` · `script/helpers/Hook721DeploymentLib.sol` · `src/JB721TiersHook.sol`<br>`src/JB721TiersHookDeployer.sol` · `src/JB721TiersHookProjectDeployer.sol` · `src/JB721TiersHookStore.sol`<br>`src/abstract/ERC721.sol` · `src/abstract/JB721Hook.sol` · `src/libraries/JB721Constants.sol`<br>`src/libraries/JB721TiersHookLib.sol` · `src/libraries/JB721TiersRulesetMetadataResolver.sol` · `src/libraries/JBBitmap.sol`<br>`src/libraries/JBIpfsDecoder.sol` · `src/structs/JB721InitTiersConfig.sol` · `src/structs/JB721Tier.sol`<br>`src/structs/JB721TierConfig.sol` · `src/structs/JB721TiersHookFlags.sol` · `src/structs/JB721TiersMintReservesConfig.sol`<br>`src/structs/JB721TiersRulesetMetadata.sol` · `src/structs/JB721TiersSetDiscountPercentConfig.sol` · `src/structs/JBBitmapWord.sol`<br>`src/structs/JBDeploy721TiersHookConfig.sol` · `src/structs/JBLaunchProjectConfig.sol` · `src/structs/JBLaunchRulesetsConfig.sol`<br>`src/structs/JBPayDataHookRulesetConfig.sol` · `src/structs/JBPayDataHookRulesetMetadata.sol` · `src/structs/JBQueueRulesetsConfig.sol`<br>`src/structs/JBStored721Tier.sol` |
11
+ | **Confidence threshold (1-100)** | 75 |
12
+
13
+ ---
14
+
15
+ ## Findings
16
+
17
+ [90] **1. Pay Credits Let Buyers Bypass Tier Split Payments**
18
+
19
+ `JB721TiersHook._mintAndUpdateCredits` · Confidence: 90
20
+
21
+ **Description**
22
+ `payCreditsOf` is merged into NFT purchasing power, but tier split payouts are still capped to `context.forwardedAmount.value`, so a buyer can mint a split-bearing tier mostly with credits while paying only a dust-sized fresh split.
23
+
24
+ **Fix**
25
+
26
+ ```diff
27
+ - leftoverAmount += payCredits;
28
+ - if (context.hookMetadata.length != 0 && context.forwardedAmount.value != 0) {
29
+ - JB721TiersHookLib.distributeAll(... amount: context.forwardedAmount.value, ...);
30
+ - }
31
+ + uint256 creditBackedAmount = context.payer == context.beneficiary ? payCredits : 0;
32
+ + uint256 splitFundingAmount = context.forwardedAmount.value + creditBackedAmountUsedForMint;
33
+ + if (context.hookMetadata.length != 0 && splitFundingAmount != 0) {
34
+ + JB721TiersHookLib.distributeAll(... amount: splitFundingAmount, ...);
35
+ + }
36
+ ```
37
+ ---
38
+
39
+ [90] **2. Failed Early Splits Are Redistributed to Later Recipients**
40
+
41
+ `JB721TiersHookLib._distributeSingleSplit` · Confidence: 90
42
+
43
+ **Description**
44
+ When an early split payout fails, its amount is added back into `leftoverAmount` before later splits are calculated, so later recipients can receive the failed recipient’s share instead of that value falling back to project balance.
45
+
46
+ **Fix**
47
+
48
+ ```diff
49
+ - if (!_sendPayoutToSplit(...)) {
50
+ - leftoverAmount += payoutAmount;
51
+ - }
52
+ + if (!_sendPayoutToSplit(...)) {
53
+ + failedAmount += payoutAmount;
54
+ + }
55
+ ...
56
+ - if (leftoverAmount != 0) {
57
+ - terminal.addToBalanceOf(... leftoverAmount ...);
58
+ + uint256 amountToProject = leftoverAmount + failedAmount;
59
+ + if (amountToProject != 0) {
60
+ + terminal.addToBalanceOf(... amountToProject ...);
61
+ ```
62
+
63
+ ---
64
+
65
+ Findings List
66
+
67
+ | # | Confidence | Title |
68
+ |---|---|---|
69
+ | 1 | [90] | Pay Credits Let Buyers Bypass Tier Split Payments |
70
+ | 2 | [90] | Failed Early Splits Are Redistributed to Later Recipients |
71
+
72
+ ---
73
+
74
+ ## Leads
75
+
76
+ _Vulnerability trails with concrete code smells where the full exploit path could not be completed in one analysis pass. These are not false positives — they are high-signal leads for manual review. Not scored._
77
+
78
+ - **Public Address Registry Can Grief Hook Deployments** — `JB721TiersHookDeployer.deployHookFor` — Code smells: deployment success depends on `JBAddressRegistry.registerAddress`, and the registry is permissionless plus duplicate-registration-reverting — A third party can likely pre-register a predicted hook address and make `deployHookFor` revert at the registry step, but the practical griefing envelope depends on deployment mode and the caller’s ability to retry with a different salt.
79
+ - **Shared `_nonce` Can Desync Registry Provenance After Mixed CREATE/CREATE2 Deployments** — `JB721TiersHookDeployer.deployHookFor` — Code smells: `_nonce` is incremented for both deterministic and non-deterministic deployments, while only the CREATE path consumes the deployer nonce the registry models — Mixed deployment modes may cause later saltless registrations to point at the wrong CREATE address, but I did not complete an end-to-end exploit showing downstream trust assumptions being violated.
80
+
81
+ ---
82
+
83
+ > ⚠️ This review was performed by an AI assistant. AI analysis can never verify the complete absence of vulnerabilities and no guarantee of security is given. Team security reviews, bug bounty programs, and on-chain monitoring are strongly recommended. For a consultation regarding your projects' security, visit [https://www.pashov.com](https://www.pashov.com)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,9 +17,9 @@
17
17
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-721-hook-v6'"
18
18
  },
19
19
  "dependencies": {
20
- "@bananapus/address-registry-v6": "^0.0.15",
21
- "@bananapus/core-v6": "^0.0.27",
22
- "@bananapus/ownable-v6": "^0.0.14",
20
+ "@bananapus/address-registry-v6": "^0.0.16",
21
+ "@bananapus/core-v6": "^0.0.28",
22
+ "@bananapus/ownable-v6": "^0.0.15",
23
23
  "@bananapus/permission-ids-v6": "^0.0.14",
24
24
  "@openzeppelin/contracts": "^5.6.1",
25
25
  "@prb/math": "^4.1.0",
@@ -65,6 +65,15 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
65
65
  /// @notice The contract that stores and manages splits.
66
66
  IJBSplits public immutable override SPLITS;
67
67
 
68
+ //*********************************************************************//
69
+ // --------------------- private stored properties ------------------ //
70
+ //*********************************************************************//
71
+
72
+ /// @notice Whether this contract has been initialized. Used to prevent re-initialization of both the
73
+ /// implementation contract itself and its clones.
74
+ /// @dev Internal (not private) so test harnesses that extend this contract can reset it in their constructors.
75
+ bool internal _initialized;
76
+
68
77
  //*********************************************************************//
69
78
  // ---------------------- public stored properties ------------------- //
70
79
  //*********************************************************************//
@@ -123,6 +132,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
123
132
  RULESETS = rulesets;
124
133
  STORE = store;
125
134
  SPLITS = splits;
135
+
136
+ // Prevent the implementation contract from being initialized.
137
+ _initialized = true;
126
138
  }
127
139
 
128
140
  //*********************************************************************//
@@ -189,16 +201,18 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
189
201
  store: STORE, hook: address(this), metadataIdTarget: METADATA_ID_TARGET, metadata: context.metadata
190
202
  });
191
203
 
192
- // Convert split amounts from tier pricing to payment token denomination if currencies differ.
204
+ // Convert split amounts from tier pricing to payment token denomination (if currencies differ)
205
+ // and cap at the actual payment value so the terminal never forwards more than was paid.
193
206
  if (totalSplitAmount != 0) {
194
- (totalSplitAmount, splitMetadata) = JB721TiersHookLib.convertSplitAmounts({
207
+ (totalSplitAmount, splitMetadata) = JB721TiersHookLib.convertAndCapSplitAmounts({
195
208
  totalSplitAmount: totalSplitAmount,
196
209
  splitMetadata: splitMetadata,
197
210
  packedPricingContext: _packedPricingContext,
198
211
  prices: PRICES,
199
212
  projectId: context.projectId,
200
213
  amountCurrency: context.amount.currency,
201
- amountDecimals: context.amount.decimals
214
+ amountDecimals: context.amount.decimals,
215
+ amountValue: context.amount.value
202
216
  });
203
217
  }
204
218
 
@@ -207,7 +221,8 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
207
221
  contextWeight: context.weight,
208
222
  amountValue: context.amount.value,
209
223
  totalSplitAmount: totalSplitAmount,
210
- issueTokensForSplits: STORE.flagsOf(address(this)).issueTokensForSplits
224
+ store: STORE,
225
+ hook: address(this)
211
226
  });
212
227
 
213
228
  hookSpecifications[0] =
@@ -246,8 +261,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
246
261
  public
247
262
  override
248
263
  {
249
- // Stop re-initialization by ensuring a projectId is provided and doesn't already exist.
250
- if (PROJECT_ID != 0) revert JB721TiersHook_AlreadyInitialized(PROJECT_ID);
264
+ // Stop re-initialization. This protects both the implementation contract (initialized in constructor)
265
+ // and clones (initialized via this function).
266
+ if (_initialized) revert JB721TiersHook_AlreadyInitialized(PROJECT_ID);
267
+ _initialized = true;
251
268
 
252
269
  // Make sure a projectId is provided.
253
270
  if (projectId == 0) revert JB721TiersHook_NoProjectId();
@@ -749,7 +749,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
749
749
 
750
750
  // Make the sorted array.
751
751
  while (currentSortedTierId != 0) {
752
- // If the current tier ID being iterated on isn't an increment of the previous one,
752
+ // Check if the current tier has been removed.
753
753
  if (!_isTierRemovedWithRefresh({hook: hook, tierId: currentSortedTierId, bitmapWord: bitmapWord})) {
754
754
  // Update its `_tierIdAfter` if needed.
755
755
  if (currentSortedTierId != previousSortedTierId + 1) {
@@ -763,8 +763,22 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
763
763
  _tierIdAfter[hook][previousSortedTierId] = 0;
764
764
  }
765
765
 
766
+ // If this tier's category has a stale `_startingTierIdOfCategory` (reset to 0 because the
767
+ // previous starting tier was removed), set this tier as the new starting tier for its category.
768
+ uint256 tierCategory = _storedTierOf[hook][currentSortedTierId].category;
769
+ if (tierCategory != 0 && _startingTierIdOfCategory[hook][tierCategory] == 0) {
770
+ _startingTierIdOfCategory[hook][tierCategory] = currentSortedTierId;
771
+ }
772
+
766
773
  // Iterate by setting the previous tier ID for the next loop to the current tier ID.
767
774
  previousSortedTierId = currentSortedTierId;
775
+ } else {
776
+ // The tier was removed. If it was the `_startingTierIdOfCategory` for its category, reset the
777
+ // pointer so the next non-removed tier in the same category can claim it.
778
+ uint256 removedCategory = _storedTierOf[hook][currentSortedTierId].category;
779
+ if (removedCategory != 0 && _startingTierIdOfCategory[hook][removedCategory] == currentSortedTierId) {
780
+ _startingTierIdOfCategory[hook][removedCategory] = 0;
781
+ }
768
782
  }
769
783
  // Iterate by updating the current sorted tier ID to the next sorted tier ID.
770
784
  currentSortedTierId = _nextSortedTierIdOf({hook: hook, id: currentSortedTierId, max: lastSortedTierId});