@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/SKILLS.md CHANGED
@@ -21,50 +21,50 @@ Tiered ERC-721 NFT hook for Juicebox V6 that mints NFTs when a project is paid a
21
21
 
22
22
  | Function | Contract | What it does |
23
23
  |----------|----------|--------------|
24
- | `initialize(projectId, name, symbol, baseUri, tokenUriResolver, contractUri, tiersConfig, flags)` | `JB721TiersHook` | One-time setup for a cloned hook instance. Stores pricing context (currency, decimals, and the immutable `PRICES` contract packed into uint256), records tiers and flags in the store. Registers any configured tier splits in `JBSplits` via `SPLITS.setSplitGroupsOf`. Validates `decimals <= 18`. |
24
+ | `initialize(projectId, name, symbol, baseUri, tokenUriResolver, contractUri, tiersConfig, flags)` | `JB721TiersHook` | One-time setup for a cloned hook. Stores packed pricing context, records tiers and flags in the store, registers tier splits. |
25
25
  | `afterPayRecordedWith(context)` | `JB721Hook` | Called by terminal after payment. Validates caller is a project terminal, delegates to virtual `_processPayment`. |
26
- | `_processPayment(context)` | `JB721TiersHook` | Normalizes payment value via pricing context, decodes payer metadata for tier IDs to mint, calls `_mintAll`, manages pay credits for overspending. Distributes tier split funds via `JB721TiersHookLib.distributeAll` if split amounts were forwarded. |
27
- | `afterCashOutRecordedWith(context)` | `JB721Hook` | Called by terminal during cash out. Decodes token IDs from metadata, validates ownership, burns NFTs, delegates to virtual `_didBurn`. Reverts if `msg.value != 0`. |
28
- | `beforePayRecordedWith(context)` | `JB721TiersHook` | Data hook: calculates per-tier split amounts via `JB721TiersHookLib.calculateSplitAmounts`, adjusts the weight proportionally so the terminal only mints tokens for the amount that actually enters the project (i.e., `weight = mulDiv(context.weight, amount - totalSplitAmount, amount)`), and sets this contract as the pay hook with the total split amount forwarded. If no splits, returns original weight unchanged. If splits consume the entire payment, returns weight 0. If the `issueTokensForSplits` flag is set, returns the full `context.weight` regardless of splits. |
29
- | `beforeCashOutRecordedWith(context)` | `JB721Hook` | Data hook: calculates `cashOutCount` (via virtual `cashOutWeightOf`) and `totalSupply` (via virtual `totalCashOutWeight`). Rejects if fungible tokens are also being cashed out. |
30
- | `adjustTiers(tiersToAdd, tierIdsToRemove)` | `JB721TiersHook` | Owner-only. Adds/removes tiers via `JB721TiersHookLib.adjustTiersFor` (DELEGATECALL). Requires `ADJUST_721_TIERS` permission. Registers tier splits in `JBSplits` if configured. |
31
- | `mintFor(tierIds, beneficiary)` | `JB721TiersHook` | Owner-only manual mint. Requires `MINT_721` permission. Passes `amount: type(uint256).max` and `isOwnerMint: true` to force the mint. |
26
+ | `_processPayment(context)` | `JB721TiersHook` | Normalizes payment value, decodes tier IDs from metadata, mints NFTs, manages pay credits. Distributes tier split funds if forwarded. |
27
+ | `afterCashOutRecordedWith(context)` | `JB721Hook` | Called by terminal during cash out. Decodes token IDs from metadata, validates ownership, burns NFTs, delegates to virtual `_didBurn`. |
28
+ | `beforePayRecordedWith(context)` | `JB721TiersHook` | Data hook: calculates per-tier split amounts, adjusts weight proportionally for the amount entering the project, sets this contract as pay hook. |
29
+ | `beforeCashOutRecordedWith(context)` | `JB721Hook` | Data hook: calculates `cashOutCount` and `totalSupply` via virtual overrides. Rejects if fungible tokens are also being cashed out. |
30
+ | `adjustTiers(tiersToAdd, tierIdsToRemove)` | `JB721TiersHook` | Owner-only. Adds/removes tiers via DELEGATECALL to `JB721TiersHookLib`. Requires `ADJUST_721_TIERS` permission. Registers tier splits if configured. |
31
+ | `mintFor(tierIds, beneficiary)` | `JB721TiersHook` | Owner-only manual mint. Requires `MINT_721` permission. |
32
32
  | `mintPendingReservesFor(tierId, count)` | `JB721TiersHook` | Public. Mints pending reserve NFTs for a tier to the tier's `reserveBeneficiary`. Checks ruleset metadata for `mintPendingReservesPaused`. |
33
33
  | `mintPendingReservesFor(configs[])` | `JB721TiersHook` | Batch variant. Calls `mintPendingReservesFor(tierId, count)` for each config. |
34
- | `setMetadata(name, symbol, baseUri, contractUri, tokenUriResolver, encodedIPFSUriTierId, encodedIPFSUri)` | `JB721TiersHook` | Owner-only. Updates collection name, symbol, base URI, contract URI, token URI resolver, or per-tier encoded IPFS URI. Empty strings leave values unchanged. Requires `SET_721_METADATA` permission. |
34
+ | `setMetadata(name, symbol, baseUri, contractUri, tokenUriResolver, encodedIPFSUriTierId, encodedIPFSUri)` | `JB721TiersHook` | Owner-only. Updates collection metadata fields. Empty strings leave values unchanged. Requires `SET_721_METADATA` permission. |
35
35
  | `setDiscountPercentOf(tierId, discountPercent)` | `JB721TiersHook` | Owner-only. Sets discount percent for a tier. Requires `SET_721_DISCOUNT_PERCENT` permission. |
36
36
  | `setDiscountPercentsOf(configs[])` | `JB721TiersHook` | Batch variant. Sets discount percent for multiple tiers. Requires `SET_721_DISCOUNT_PERCENT` permission. |
37
- | `tokenURI(tokenId)` | `JB721TiersHook` | Resolves token metadata URI. Delegates to `JB721TiersHookLib.resolveTokenURI`, which checks for a custom `tokenUriResolver` first, then falls back to IPFS decoding via `JBIpfsDecoder`. |
38
- | `firstOwnerOf(tokenId)` | `JB721TiersHook` | Returns the first owner of an NFT (the address that originally received it). Stored on first transfer out; returns current owner if never transferred. |
39
- | `pricingContext()` | `JB721TiersHook` | Unpacks and returns the currency and decimals from the packed `_packedPricingContext`. The prices contract is available via the `PRICES()` immutable getter. |
37
+ | `tokenURI(tokenId)` | `JB721TiersHook` | Resolves token metadata URI via `JB721TiersHookLib.resolveTokenURI`. Checks custom resolver first, falls back to IPFS decoding. |
38
+ | `firstOwnerOf(tokenId)` | `JB721TiersHook` | Returns the first owner of an NFT. Stored on first transfer out; returns current owner if never transferred. |
39
+ | `pricingContext()` | `JB721TiersHook` | Unpacks and returns currency and decimals from the packed `_packedPricingContext`. |
40
40
  | `balanceOf(owner)` | `JB721TiersHook` | Overrides ERC-721 `balanceOf` to delegate to `STORE.balanceOf`, which sums across all tiers. |
41
- | `hasMintPermissionFor(...)` | `JB721Hook` | Always returns `false`. Required by `IJBRulesetDataHook`; prevents the hook from granting mint permissions to anyone. |
42
- | `supportsInterface(interfaceId)` | `JB721TiersHook` | Returns `true` for `IJB721TiersHook`, `IJBRulesetDataHook`, `IJBPayHook`, `IJBCashOutHook`, `IERC2981`, `IERC721`, `IERC721Metadata`, `IERC165`. |
41
+ | `hasMintPermissionFor(...)` | `JB721Hook` | Always returns `false`. Required by `IJBRulesetDataHook`. |
42
+ | `supportsInterface(interfaceId)` | `JB721TiersHook` | Returns `true` for `IJB721TiersHook`, `IJBRulesetDataHook`, `IJBPayHook`, `IJBCashOutHook`, `IERC721`, `IERC721Metadata`, `IERC165`. ERC-2981 support was removed. |
43
43
  | `deployHookFor(projectId, config, salt)` | `JB721TiersHookDeployer` | Clones the hook implementation, initializes it, transfers ownership to caller, registers in address registry. |
44
- | `launchProjectFor(owner, deployConfig, launchConfig, controller, salt)` | `JB721TiersHookProjectDeployer` | Creates project via controller, deploys hook, wires hook as data hook with `useDataHookForPay: true`, transfers hook ownership to project. |
45
- | `launchRulesetsFor(projectId, deployConfig, launchRulesetsConfig, controller, salt)` | `JB721TiersHookProjectDeployer` | Deploys a hook for an existing project and launches rulesets. Requires `QUEUE_RULESETS` and `SET_TERMINALS` permissions. Transfers hook ownership to project. |
46
- | `queueRulesetsOf(projectId, deployConfig, queueRulesetsConfig, controller, salt)` | `JB721TiersHookProjectDeployer` | Deploys a hook and queues rulesets for an existing project. Requires `QUEUE_RULESETS` permission. Transfers hook ownership to project. |
47
- | `recordMint(amount, tierIds, isOwnerMint)` | `JB721TiersHookStore` | Records minting: validates supply, checks tier prices against amount (unless owner mint), applies discount if set, generates token IDs (`tierId * 1_000_000_000 + mintCount`), ensures remaining supply covers pending reserves. |
48
- | `recordAddTiers(tiers)` | `JB721TiersHookStore` | Adds new tiers sorted by category. Validates against hook flags (no new reserves/votes/owner-minting if flagged). Enforces max tier count (`type(uint16).max`), max supply per tier (`999_999_999`), discount percent bounds, non-zero supply, category sort order, and reserve+owner-mint mutual exclusion. |
49
- | `recordRemoveTierIds(tierIds)` | `JB721TiersHookStore` | Marks tiers as removed in the bitmap. Validates tier is not locked (`cannotBeRemoved`). Does NOT update the sorted linked list -- call `cleanTiers()` afterward. |
44
+ | `launchProjectFor(owner, deployConfig, launchConfig, controller, salt)` | `JB721TiersHookProjectDeployer` | Creates project via controller, deploys hook, wires as data hook, transfers hook ownership to project. |
45
+ | `launchRulesetsFor(projectId, deployConfig, launchRulesetsConfig, controller, salt)` | `JB721TiersHookProjectDeployer` | Deploys a hook for an existing project and launches rulesets. Requires `QUEUE_RULESETS` and `SET_TERMINALS`. |
46
+ | `queueRulesetsOf(projectId, deployConfig, queueRulesetsConfig, controller, salt)` | `JB721TiersHookProjectDeployer` | Deploys a hook and queues rulesets for an existing project. Requires `QUEUE_RULESETS`. |
47
+ | `recordMint(amount, tierIds, isOwnerMint)` | `JB721TiersHookStore` | Records minting: validates supply, checks prices, applies discount, generates token IDs, ensures supply covers pending reserves. |
48
+ | `recordAddTiers(tiers)` | `JB721TiersHookStore` | Adds new tiers sorted by category. Validates flags, limits, supply, sort order, and reserve+owner-mint mutual exclusion. |
49
+ | `recordRemoveTierIds(tierIds)` | `JB721TiersHookStore` | Marks tiers as removed in the bitmap. Does NOT update the sorted linked list -- call `cleanTiers()` afterward. |
50
50
  | `recordMintReservesFor(tierId, count)` | `JB721TiersHookStore` | Mints reserve NFTs from remaining supply. Validates count does not exceed pending reserves. |
51
- | `recordSetDiscountPercentOf(tierId, discountPercent)` | `JB721TiersHookStore` | Sets discount percent for a tier. Validates bounds (`<= DISCOUNT_DENOMINATOR`). If `cannotIncreaseDiscountPercent` is set, rejects increases. |
52
- | `recordBurn(tokenIds)` | `JB721TiersHookStore` | Increments burn counter per tier. Trusts `msg.sender` (the hook) to have already verified ownership and burned the tokens. |
53
- | `cleanTiers(hook)` | `JB721TiersHookStore` | Public. Removes stale entries from the sorted tier linked list after tiers have been removed via `recordRemoveTierIds`. Optimizes tier iteration. |
51
+ | `recordSetDiscountPercentOf(tierId, discountPercent)` | `JB721TiersHookStore` | Sets discount percent for a tier. Validates bounds and `cannotIncreaseDiscountPercent` constraint. |
52
+ | `recordBurn(tokenIds)` | `JB721TiersHookStore` | Increments burn counter per tier. Trusts the hook to have already verified ownership and burned the tokens. |
53
+ | `cleanTiers(hook)` | `JB721TiersHookStore` | Public. Removes stale entries from the sorted tier linked list after `recordRemoveTierIds`. |
54
54
  | `tiersOf(hook, categories, includeResolvedUri, startingId, size)` | `JB721TiersHookStore` | Returns an array of active tiers, optionally filtered by categories. Skips removed tiers. |
55
55
  | `tierOf(hook, id, includeResolvedUri)` | `JB721TiersHookStore` | Returns a single tier by ID. |
56
56
  | `tierOfTokenId(hook, tokenId, includeResolvedUri)` | `JB721TiersHookStore` | Returns the tier for a given token ID. |
57
57
  | `totalSupplyOf(hook)` | `JB721TiersHookStore` | Returns total NFTs minted across all tiers (excluding burns). |
58
- | `totalCashOutWeight(hook)` | `JB721TiersHookStore` | Returns total cash out weight (sum of `price * (minted + pendingReserves)` for all tiers). Uses original price, not discounted price. |
59
- | `cashOutWeightOf(hook, tokenIds)` | `JB721TiersHookStore` | Returns combined cash out weight for specific token IDs. Uses original tier price, not discounted. |
60
- | `votingUnitsOf(hook, account)` | `JB721TiersHookStore` | Returns total voting units for an address across all tiers. Uses custom `votingUnits` if `useVotingUnits` is set, otherwise uses tier price. |
58
+ | `totalCashOutWeight(hook)` | `JB721TiersHookStore` | Returns total cash out weight: `sum(price * (minted + pendingReserves))`. Uses original price, not discounted. |
59
+ | `cashOutWeightOf(hook, tokenIds)` | `JB721TiersHookStore` | Returns combined cash out weight for specific token IDs. Uses original tier price. |
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
- | `calculateSplitAmounts(store, hook, metadataIdTarget, metadata)` | `JB721TiersHookLib` | Called in `beforePayRecordedWith`. Decodes tier IDs from payer metadata, looks up each tier's `splitPercent`, applies `discountPercent` to derive the effective price, then calculates `mulDiv(effectivePrice, splitPercent, SPLITS_TOTAL_PERCENT)` per tier. Returns `totalSplitAmount` and encoded `hookMetadata` (tier IDs + amounts). Amounts are in the tier pricing denomination — call `convertSplitAmounts` afterward when the payment currency differs. |
63
- | `convertSplitAmounts(totalSplitAmount, splitMetadata, packedPricingContext, projectId, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts per-tier split amounts from tier pricing denomination to payment token denomination using `JBPrices.pricePerUnitOf`. Called automatically by `beforePayRecordedWith` when `totalSplitAmount != 0`. Returns early (no-op) when currencies match or no prices contract is configured. |
64
- | `distributeAll(directory, splits, projectId, hookAddress, token, amount, decimals, encodedSplitData)` | `JB721TiersHookLib` | Called in `afterPayRecordedWith`. Decodes per-tier amounts, looks up each tier's splits from `JBSplits` by group ID (`hookAddress | (tierId << 160)`), distributes to split recipients following priority: `split.hook` (via `IJBSplitHook.processSplitWith` with full `JBSplitHookContext`) > `split.projectId` (via terminal) > `split.beneficiary` (direct transfer). Threads `decimals` through for correct `JBSplitHookContext` construction. Leftover goes to project balance via `addToBalance`. |
65
- | `adjustTiersFor(store, splits, projectId, hookAddress, caller, tiersToAdd, tierIdsToRemove)` | `JB721TiersHookLib` | Called via DELEGATECALL from `adjustTiers`. Removes tiers, adds tiers, emits events, and registers any configured splits directly in `JBSplits`. |
66
- | `normalizePaymentValue(packedPricingContext, projectId, amountValue, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts a payment value to the hook's pricing currency using `JBPrices`. Returns `(0, false)` if currencies differ and no prices contract is set. |
67
- | `resolveTokenURI(store, hook, baseUri, tokenId)` | `JB721TiersHookLib` | Resolves token URI: checks for custom `tokenUriResolver` first, otherwise decodes IPFS URI via `JBIpfsDecoder`. |
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`. |
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
+ | `adjustTiersFor(store, splits, projectId, hookAddress, caller, tiersToAdd, tierIdsToRemove)` | `JB721TiersHookLib` | Called via DELEGATECALL from `adjustTiers`. Removes tiers, adds tiers, emits events, registers splits. |
66
+ | `normalizePaymentValue(packedPricingContext, prices, projectId, amountValue, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts a payment value to the hook's pricing currency using `JBPrices`. |
67
+ | `resolveTokenURI(store, hook, baseUri, tokenId)` | `JB721TiersHookLib` | Resolves token URI: checks for custom resolver first, otherwise decodes IPFS URI via `JBIpfsDecoder`. |
68
68
 
69
69
  ## Integration Points
70
70
 
@@ -74,7 +74,7 @@ Tiered ERC-721 NFT hook for Juicebox V6 that mints NFTs when a project is paid a
74
74
  | `@bananapus/ownable-v6` | `JBOwnable` | Project-based ownership for the hook (ownership can be transferred to a project NFT) |
75
75
  | `@bananapus/permission-ids-v6` | `JBPermissionIds` | Permission IDs: `ADJUST_721_TIERS`, `MINT_721`, `SET_721_METADATA`, `SET_721_DISCOUNT_PERCENT`, `QUEUE_RULESETS`, `SET_TERMINALS` |
76
76
  | `@bananapus/address-registry-v6` | `IJBAddressRegistry` | Registering deployed hook clones |
77
- | `@openzeppelin/contracts` | `ERC2771Context`, `IERC165`, `IERC2981`, `IERC721`, `SafeERC20` | Meta-transactions (trusted forwarder), interface detection, royalty standard declaration, safe ERC-20 transfers for split distribution |
77
+ | `@openzeppelin/contracts` | `ERC2771Context`, `IERC165`, `IERC721`, `SafeERC20` | Meta-transactions (trusted forwarder), interface detection, safe ERC-20 transfers for split distribution |
78
78
  | `@prb/math` | `mulDiv` | Safe fixed-point multiplication/division for price normalization and discount/split calculation |
79
79
  | `solady` | `LibClone` | Minimal proxy (clone) deployment for hooks |
80
80
 
@@ -139,10 +139,23 @@ Each tier has configurable voting power:
139
139
  - Each tier can route a percentage of its mint price to configured split recipients. The `splitPercent` field (out of `JBConstants.SPLITS_TOTAL_PERCENT` = 1,000,000,000) determines how much of the price is forwarded.
140
140
  - Split recipients are stored in `JBSplits` using group IDs computed as `uint256(uint160(hookAddress)) | (uint256(tierId) << 160)`.
141
141
  - Splits are registered in `JBSplits` both during `initialize()` (for tiers included at launch) and during `adjustTiers()` (for tiers added later), using the hook's `SPLITS` immutable directly.
142
- - In `beforePayRecordedWith`, `calculateSplitAmounts` decodes tier IDs from payer metadata, applies the tier's `discountPercent` to derive the effective price, computes `mulDiv(effectivePrice, splitPercent, SPLITS_TOTAL_PERCENT)` per tier, and returns the total to be forwarded to the hook. If the payment currency differs from the tier pricing currency, `convertSplitAmounts` converts the amounts to the payment token denomination using the configured `JBPrices` contract. The weight is adjusted down proportionally unless the `issueTokensForSplits` flag is set, in which case the full `context.weight` is returned.
142
+ - In `beforePayRecordedWith`, `calculateSplitAmounts` processes tier splits:
143
+ - Decodes tier IDs from payer metadata.
144
+ - Applies the tier's `discountPercent` to derive the effective price.
145
+ - Computes `mulDiv(effectivePrice, splitPercent, SPLITS_TOTAL_PERCENT)` per tier.
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`.
148
+ - 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.
143
149
  - 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`.
144
- - Split recipients follow the same priority chain as `JBMultiTerminal`: `split.hook` > `split.projectId` > `split.beneficiary`. Split hooks receive a `JBSplitHookContext` struct containing `token`, `amount`, `decimals`, `projectId`, `groupId`, and the full `split` struct. For native tokens, ETH is forwarded via `{value: amount}`; for ERC-20s, tokens are transferred first via `SafeERC20.safeTransfer` then `processSplitWith` is called. Project splits route via `terminal.pay` or `terminal.addToBalance`. Beneficiary splits use direct ETH transfer or `SafeERC20.safeTransfer`. Splits with no hook, no project ID, and no beneficiary are skipped -- their share stays in the leftover and is routed to the project's own balance via `addToBalanceOf`, preventing a misconfigured split from bricking the payout distribution.
145
- - All external calls in `_sendPayoutToSplit` 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 the project balance). For 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. For ERC-20 terminal calls (`pay`/`addToBalanceOf`), approval is reset to zero on failure to prevent dangling approvals.
150
+ - Split recipients follow the same priority chain as `JBMultiTerminal`: `split.hook` > `split.projectId` > `split.beneficiary`:
151
+ - **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
+ - **Project splits**: route via `terminal.pay` or `terminal.addToBalance`.
153
+ - **Beneficiary splits**: direct ETH transfer or `SafeERC20.safeTransfer`.
154
+ - **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
+ - All external calls in `_sendPayoutToSplit` are wrapped in try-catch to prevent a single reverting recipient from bricking all payments to the project:
156
+ - **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.
158
+ - **ERC-20 terminal calls** (`pay`/`addToBalanceOf`): approval is reset to zero on failure to prevent dangling approvals.
146
159
 
147
160
  ## Gotchas
148
161
 
@@ -161,19 +174,76 @@ Each tier has configurable voting power:
161
174
  - `setMetadata` uses `address(this)` as the sentinel for "no change" on `tokenUriResolver` (not `address(0)`). Passing `address(0)` will clear the resolver.
162
175
  - `JBPayDataHookRulesetConfig` hardcodes `useDataHookForPay: true` when wiring rulesets through the project deployer. All other metadata fields are passed through.
163
176
  - The `_update` override in `JB721TiersHook` checks `tier.transfersPausable` and consults the current ruleset's metadata for `transfersPaused`. Transfers to `address(0)` (burns) are never blocked.
164
- - **IERC2981 declared but not implemented**: `supportsInterface` returns `true` for `IERC2981`, but no `royaltyInfo` function is implemented. Callers querying `royaltyInfo` will get a revert. This appears intentional -- the interface is declared for future extension or to signal capability to marketplaces that may override behavior.
177
+ - **ERC-2981 not supported**: ERC-2981 royalty support was removed. `supportsInterface` returns `false` for `IERC2981`, and no `royaltyInfo` function exists.
165
178
  - **Tier splits**: Each tier can route a percentage of its mint price to configured split recipients. `splitPercent` is out of `JBConstants.SPLITS_TOTAL_PERCENT` (1,000,000,000). Split group IDs are `uint256(uint160(hookAddress)) | (uint256(tierId) << 160)`.
166
179
  - **`useReserveBeneficiaryAsDefault` overwrites globally**: Adding a tier with `useReserveBeneficiaryAsDefault: true` silently overwrites `defaultReserveBeneficiaryOf` for ALL existing tiers that lack a tier-specific beneficiary. A `SetDefaultReserveBeneficiary` event is emitted when the default changes.
167
180
  - **Removing tiers does not update the sorted list**: `recordRemoveTierIds` only marks tiers in the bitmap. Call `cleanTiers()` afterward to remove them from the iteration sequence.
168
181
  - `JB721TiersHookStore` is a **shared singleton** -- all hook instances on the same chain use the same store, keyed by `address(hook)`.
169
182
  - The `ERC721` abstract uses `_initialize(name, symbol)` instead of a constructor, making it clone-compatible. It also exposes `_setName()` and `_setSymbol()` for post-initialization updates. The standard `_owners` mapping is `internal` (not `private`).
183
+ - **Noop hook specifications**: `JBPayHookSpecification` and `JBCashOutHookSpecification` each have a `bool noop` field. When the 721 hook is composed with another data hook (e.g., via `REVDeployer` or `JBOmnichainDeployer`), the outer data hook may return noop specs alongside the 721 hook's active specs. The 721 hook itself always returns active specs (`noop = false`) — it needs the callback to mint NFTs. Noop specs with `amount != 0` revert at the terminal store level.
170
184
  - **`hasMintPermissionFor` always returns `false`**: The hook never grants mint permission to any address. This is part of the `IJBRulesetDataHook` interface.
171
185
  - **Max tier count is 65,535** (`type(uint16).max`). Adding tiers beyond this limit reverts.
172
186
  - **Max initial supply per tier is 999,999,999** (`_ONE_BILLION - 1`). Exceeding this would cause token ID overflow into the next tier's ID space.
173
187
  - **`noNewTiersWithVotes` blocks all voting power**: It rejects tiers where voting units would be non-zero, whether from custom `votingUnits` or from a non-zero `price` (when `useVotingUnits` is false).
174
188
  - **`firstOwnerOf` is lazy**: The first owner is only stored when the token is first transferred away from its original holder. Before any transfer, `firstOwnerOf` returns the current owner.
175
189
  - **Tiers must be sorted by category, NOT price.** `recordAddTiers` reverts with `JB721TiersHookStore_InvalidCategorySortOrder` if tiers aren't in ascending category order. The `JB721InitTiersConfig` struct comment previously said "sorted by price" but the code enforces category ordering. Within the same category, tiers can be in any order.
176
- - **Always use `JB721TiersHookProjectDeployer.launchProjectFor` even without NFTs.** Pass an empty tiers array to enable future NFT additions without migration. If a project is launched via `JBController.launchProjectFor` instead, adding NFT tiers later requires wiring a new data hook into a new ruleset using the 721 deployer from the start avoids this.
190
+ - **Always use `JB721TiersHookProjectDeployer.launchProjectFor` even without NFTs.** Pass an empty tiers array to enable future NFT additions without migration. If a project is launched via `JBController.launchProjectFor` instead, adding NFT tiers later requires wiring a new data hook into a new ruleset -- using the 721 deployer from the start avoids this.
191
+
192
+ ## Custom Errors
193
+
194
+ | Error | Contract | When |
195
+ |-------|----------|------|
196
+ | `JB721Hook_InvalidCashOut()` | `JB721Hook` | `afterCashOutRecordedWith` caller is not a project terminal. |
197
+ | `JB721Hook_InvalidPay()` | `JB721Hook` | `afterPayRecordedWith` caller is not a project terminal. |
198
+ | `JB721Hook_UnauthorizedToken(tokenId, holder)` | `JB721Hook` | Cash out attempts to burn a token not owned by `context.holder`. |
199
+ | `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`. |
201
+ | `JB721TiersHook_CurrencyMismatch(paymentCurrency, tierCurrency)` | `JB721TiersHook` | Payment currency differs from tier pricing currency and no `PRICES` contract is configured for conversion. |
202
+ | `JB721TiersHook_InvalidPricingDecimals(decimals)` | `JB721TiersHook` | `initialize` called with `decimals > 18`. |
203
+ | `JB721TiersHook_MintReserveNftsPaused()` | `JB721TiersHook` | `mintPendingReservesFor` called while `pauseMintPendingReserves` is set in the current ruleset metadata. |
204
+ | `JB721TiersHook_NoProjectId()` | `JB721TiersHook` | `initialize` called with `projectId == 0`. |
205
+ | `JB721TiersHook_Overspending(leftoverAmount)` | `JB721TiersHook` | Payment has leftover funds after minting and `preventOverspending` flag is set. |
206
+ | `JB721TiersHook_TierTransfersPaused()` | `JB721TiersHook` | NFT transfer attempted on a tier with `transfersPausable` while `transfersPaused` is set in ruleset metadata. |
207
+ | `JB721TiersHookStore_CantMintManually(tierId)` | `JB721TiersHookStore` | Owner mint attempted on a tier with `allowOwnerMint: false`. |
208
+ | `JB721TiersHookStore_CantRemoveTier(tierId)` | `JB721TiersHookStore` | Removing a tier that has `cannotBeRemoved: true`. |
209
+ | `JB721TiersHookStore_DiscountPercentExceedsBounds(percent, limit)` | `JB721TiersHookStore` | Discount percent exceeds `DISCOUNT_DENOMINATOR` (200). |
210
+ | `JB721TiersHookStore_DiscountPercentIncreaseNotAllowed(percent, storedPercent)` | `JB721TiersHookStore` | Increasing discount on a tier with `cannotIncreaseDiscountPercent: true`. |
211
+ | `JB721TiersHookStore_InsufficientPendingReserves(count, numberOfPendingReserves)` | `JB721TiersHookStore` | `recordMintReservesFor` called with `count` exceeding available pending reserves. |
212
+ | `JB721TiersHookStore_InsufficientSupplyRemaining(tierId)` | `JB721TiersHookStore` | Tier has no remaining supply, or remaining supply does not cover pending reserves after mint. |
213
+ | `JB721TiersHookStore_InvalidCategorySortOrder(tierCategory, previousTierCategory)` | `JB721TiersHookStore` | Tiers not sorted in ascending `category` order during `recordAddTiers`. |
214
+ | `JB721TiersHookStore_InvalidQuantity(quantity, limit)` | `JB721TiersHookStore` | Tier `initialSupply` exceeds max (999,999,999). |
215
+ | `JB721TiersHookStore_ManualMintingNotAllowed(tierId)` | `JB721TiersHookStore` | Adding a tier with `allowOwnerMint: true` when `noNewTiersWithOwnerMinting` flag is set. |
216
+ | `JB721TiersHookStore_MaxTiersExceeded(numberOfTiers, limit)` | `JB721TiersHookStore` | Total tier count exceeds `type(uint16).max` (65,535). |
217
+ | `JB721TiersHookStore_PriceExceedsAmount(price, leftoverAmount)` | `JB721TiersHookStore` | Tier price exceeds remaining payment amount during `recordMint`. |
218
+ | `JB721TiersHookStore_ReserveFrequencyNotAllowed(tierId)` | `JB721TiersHookStore` | Adding a tier with `reserveFrequency` when `noNewTiersWithReserves` flag is set. |
219
+ | `JB721TiersHookStore_SplitPercentExceedsBounds(percent, limit)` | `JB721TiersHookStore` | Tier `splitPercent` exceeds `SPLITS_TOTAL_PERCENT`. |
220
+ | `JB721TiersHookStore_TierRemoved(tierId)` | `JB721TiersHookStore` | Attempting to mint from a tier that has been removed. |
221
+ | `JB721TiersHookStore_UnrecognizedTier(tierId)` | `JB721TiersHookStore` | Tier ID does not exist (`initialSupply == 0`). |
222
+ | `JB721TiersHookStore_VotingUnitsNotAllowed(tierId)` | `JB721TiersHookStore` | Adding a tier with voting power when `noNewTiersWithVotes` flag is set. |
223
+ | `JB721TiersHookStore_ZeroInitialSupply(tierId)` | `JB721TiersHookStore` | Adding a tier with `initialSupply == 0`. |
224
+
225
+ ## Events
226
+
227
+ | Event | Contract | Key Params |
228
+ |-------|----------|------------|
229
+ | `AddToBalanceReverted(projectId, token, amount, reason)` | `IJB721TiersHook` | Emitted when leftover `addToBalanceOf` call reverts during split distribution. Funds remain stranded in hook. |
230
+ | `AddPayCredits(amount, newTotalCredits, account, caller)` | `IJB721TiersHook` | Pay credits added for an account (overspending stored for future mints). |
231
+ | `AddTier(tierId, tier, caller)` | `IJB721TiersHook` | New tier added via `adjustTiers`. `tier` is the full `JB721TierConfig`. |
232
+ | `Mint(tokenId, tierId, beneficiary, totalAmountPaid, caller)` | `IJB721TiersHook` | NFT minted from a payment. |
233
+ | `MintReservedNft(tokenId, tierId, beneficiary, caller)` | `IJB721TiersHook` | Reserve NFT minted via `mintPendingReservesFor`. |
234
+ | `RemoveTier(tierId, caller)` | `IJB721TiersHook` | Tier removed via `adjustTiers`. |
235
+ | `SetName(name, caller)` | `IJB721TiersHook` | Collection name updated via `setMetadata`. |
236
+ | `SetSymbol(symbol, caller)` | `IJB721TiersHook` | Collection symbol updated via `setMetadata`. |
237
+ | `SetBaseUri(baseUri, caller)` | `IJB721TiersHook` | Base URI updated via `setMetadata`. |
238
+ | `SetContractUri(uri, caller)` | `IJB721TiersHook` | Contract URI updated via `setMetadata`. |
239
+ | `SetDiscountPercent(tierId, discountPercent, caller)` | `IJB721TiersHook` | Tier discount percent changed via `setDiscountPercentOf`. |
240
+ | `SetEncodedIPFSUri(tierId, encodedUri, caller)` | `IJB721TiersHook` | Tier IPFS URI updated via `setMetadata`. |
241
+ | `SetTokenUriResolver(resolver, caller)` | `IJB721TiersHook` | Token URI resolver updated via `setMetadata`. |
242
+ | `SplitPayoutReverted(projectId, split, amount, reason, caller)` | `IJB721TiersHook` | A split payout reverted during distribution. Failed split's funds route to project balance. |
243
+ | `UsePayCredits(amount, newTotalCredits, account, caller)` | `IJB721TiersHook` | Pay credits consumed during a payment. |
244
+ | `CleanTiers(hook, caller)` | `JB721TiersHookStore` | Removed tiers cleaned from the sorting linked list. |
245
+ | `SetDefaultReserveBeneficiary(hook, newBeneficiary, caller)` | `JB721TiersHookStore` | Default reserve beneficiary changed (affects all tiers without a tier-specific beneficiary). |
246
+ | `HookDeployed(projectId, hook, caller)` | `JB721TiersHookDeployer` | New hook clone deployed for a project. |
177
247
 
178
248
  ## Example Integration
179
249
 
package/STYLE_GUIDE.md CHANGED
@@ -21,7 +21,7 @@ One contract/interface/struct/enum per file. Name the file after the type it con
21
21
 
22
22
  ```solidity
23
23
  // Contracts — pin to exact version
24
- pragma solidity 0.8.26;
24
+ pragma solidity 0.8.28;
25
25
 
26
26
  // Interfaces, structs, enums — caret for forward compatibility
27
27
  pragma solidity ^0.8.0;
@@ -326,7 +326,7 @@ Standard config across all repos:
326
326
 
327
327
  ```toml
328
328
  [profile.default]
329
- solc = '0.8.26'
329
+ solc = '0.8.28'
330
330
  evm_version = 'cancun'
331
331
  optimizer_runs = 200
332
332
  libs = ["node_modules", "lib"]
package/USER_JOURNEYS.md CHANGED
@@ -14,6 +14,8 @@ A user pays a Juicebox project and receives tiered NFTs based on the amount paid
14
14
 
15
15
  **Entry point**: `JBMultiTerminal.pay()` (external). The hook is invoked as both a data hook (`beforePayRecordedWith`) and a pay hook (`afterPayRecordedWith`).
16
16
 
17
+ **Who can call**: Anyone. The terminal's `pay()` is permissionless.
18
+
17
19
  **Parameters** (encoded in payment metadata via `JBMetadataResolver`):
18
20
  - `bool allowOverspending` -- whether leftover funds after minting should be stored as credits (true) or revert (false). The hook-level `preventOverspending` flag can override this.
19
21
  - `uint16[] tierIdsToMint` -- which tier IDs to mint, in order. The same tier can appear multiple times.
@@ -33,6 +35,8 @@ A user pays a Juicebox project and receives tiered NFTs based on the amount paid
33
35
  - `Mint(tokenId, tierId, beneficiary, totalAmountPaid, caller)` -- one per NFT minted.
34
36
  - `AddPayCredits(amount, newTotalCredits, account, caller)` -- if credits increased.
35
37
  - `UsePayCredits(amount, newTotalCredits, account, caller)` -- if credits decreased.
38
+ - `SplitPayoutReverted(projectId, split, amount, reason, caller)` -- if a split recipient's payout reverts during distribution (funds route to project balance instead).
39
+ - `AddToBalanceReverted(projectId, token, amount, reason)` -- if the `addToBalanceOf` call for leftover funds reverts after split distribution.
36
40
 
37
41
  **Edge cases**:
38
42
  - **No metadata or metadata not found**: No NFTs minted. If `preventOverspending` is false, the entire payment becomes credits for the beneficiary. If true, reverts.
@@ -53,6 +57,8 @@ An NFT holder burns their NFTs to reclaim funds from the project's surplus, prop
53
57
 
54
58
  **Entry point**: `JBMultiTerminal.cashOutTokensOf()` (external). The hook is invoked as both a data hook (`beforeCashOutRecordedWith`) and a cash out hook (`afterCashOutRecordedWith`).
55
59
 
60
+ **Who can call**: The NFT holder, or an operator with `CASH_OUT_TOKENS` permission from the holder.
61
+
56
62
  **Parameters** (encoded in cash-out metadata via `JBMetadataResolver`):
57
63
  - `uint256[] tokenIds` -- the token IDs of the NFTs to burn.
58
64
 
@@ -72,7 +78,7 @@ An NFT holder burns their NFTs to reclaim funds from the project's surplus, prop
72
78
 
73
79
  **Cash-out weight calculation**:
74
80
  - Per-NFT weight: `storedTier.price` (original price, NOT discounted).
75
- - Total weight: sum of `price * (mintedCount + pendingReserves)` across ALL tiers (including removed tiers).
81
+ - Total weight: sum of `price * (outstandingCount + pendingReserves)` across ALL tiers (including removed tiers), where `outstandingCount = initialSupply - remainingSupply - numberOfBurned`. Burned tokens are excluded from the total weight, so burning NFTs reduces the denominator and increases the per-NFT reclaim for remaining holders.
76
82
  - Reclaim amount is computed by the terminal's bonding curve using `cashOutCount / totalSupply` as the ratio.
77
83
 
78
84
  **Events**:
@@ -92,7 +98,7 @@ The project owner adds new NFT tiers to the hook.
92
98
 
93
99
  **Entry point**: `JB721TiersHook.adjustTiers(JB721TierConfig[] calldata tiersToAdd, uint256[] calldata tierIdsToRemove)` (external).
94
100
 
95
- **Permission**: `ADJUST_721_TIERS` from the hook owner.
101
+ **Who can call**: Hook owner, or an operator with `ADJUST_721_TIERS` permission from the hook owner.
96
102
 
97
103
  **Parameters** (per `JB721TierConfig`):
98
104
  - `uint104 price` -- tier price in the hook's pricing currency.
@@ -124,16 +130,20 @@ The project owner adds new NFT tiers to the hook.
124
130
 
125
131
  **Events**:
126
132
  - `AddTier(tierId, tierConfig, caller)` -- one per tier added.
133
+ - `SetDefaultReserveBeneficiary(hook, newBeneficiary, caller)` -- if any tier has `useReserveBeneficiaryAsDefault = true` and changes the current default.
127
134
 
128
135
  **Edge cases**:
129
136
  - **Categories not sorted ascending**: Reverts with `JB721TiersHookStore_InvalidCategorySortOrder`.
130
137
  - **Exceeds 65,535 total tiers**: Reverts with `JB721TiersHookStore_MaxTiersExceeded`.
131
138
  - **`initialSupply == 0`**: Reverts with `JB721TiersHookStore_ZeroInitialSupply`.
132
- - **`noNewTiersWithVotes` flag set**: Reverts if tier has non-zero voting power.
133
- - **`noNewTiersWithReserves` flag set**: Reverts if tier has `reserveFrequency > 0`.
134
- - **`noNewTiersWithOwnerMinting` flag set**: Reverts if tier has `allowOwnerMint = true`.
139
+ - **`initialSupply > 999,999,999`**: Reverts with `JB721TiersHookStore_InvalidQuantity`. Each tier's supply must fit within the token ID encoding scheme (`tierId * 1e9 + tokenNumber`).
140
+ - **`discountPercent > 200`**: Reverts with `JB721TiersHookStore_DiscountPercentExceedsBounds`.
141
+ - **`splitPercent > SPLITS_TOTAL_PERCENT`**: Reverts with `JB721TiersHookStore_SplitPercentExceedsBounds`.
142
+ - **`noNewTiersWithVotes` flag set**: Reverts with `JB721TiersHookStore_VotingUnitsNotAllowed` if the new tier would have any voting power. This means tiers with `useVotingUnits = true` and `votingUnits > 0` are rejected, AND tiers with `useVotingUnits = false` and `price > 0` are also rejected (since price is used as voting power by default when `useVotingUnits` is false).
143
+ - **`noNewTiersWithReserves` flag set**: Reverts with `JB721TiersHookStore_ReserveFrequencyNotAllowed` if tier has `reserveFrequency > 0`.
144
+ - **`noNewTiersWithOwnerMinting` flag set**: Reverts with `JB721TiersHookStore_ManualMintingNotAllowed` if tier has `allowOwnerMint = true`.
135
145
  - **`allowOwnerMint` + `reserveFrequency > 0`**: Reverts with `JB721TiersHookStore_ReserveFrequencyNotAllowed`. Owner-mintable tiers cannot have reserves.
136
- - **`useReserveBeneficiaryAsDefault = true`**: Silently overwrites `defaultReserveBeneficiaryOf[hook]`, affecting all existing tiers that use the default.
146
+ - **`useReserveBeneficiaryAsDefault = true`**: Only takes effect when both `reserveBeneficiary != address(0)` AND `reserveFrequency != 0`. When both conditions are met, silently overwrites `defaultReserveBeneficiaryOf[hook]`, affecting all existing tiers that use the default. If either condition is missing, the flag is silently ignored.
137
147
 
138
148
  ---
139
149
 
@@ -143,7 +153,7 @@ The project owner removes tiers, preventing new mints but preserving existing NF
143
153
 
144
154
  **Entry point**: `JB721TiersHook.adjustTiers(JB721TierConfig[] calldata tiersToAdd, uint256[] calldata tierIdsToRemove)` (external). Pass an empty `tiersToAdd` array to only remove.
145
155
 
146
- **Permission**: `ADJUST_721_TIERS` from the hook owner.
156
+ **Who can call**: Hook owner, or an operator with `ADJUST_721_TIERS` permission from the hook owner.
147
157
 
148
158
  **Parameters**:
149
159
  - `uint256[] tierIdsToRemove` -- IDs of tiers to remove.
@@ -172,6 +182,8 @@ Anyone can mint pending reserved NFTs for a tier. Reserves accumulate based on t
172
182
 
173
183
  **Batch entry point**: `JB721TiersHook.mintPendingReservesFor(JB721TiersMintReservesConfig[] calldata reserveMintConfigs)` (external, permissionless).
174
184
 
185
+ **Who can call**: Anyone. Both entry points are permissionless.
186
+
175
187
  **Parameters**:
176
188
  - `uint256 tierId` -- the tier to mint reserves from.
177
189
  - `uint256 count` -- how many reserve NFTs to mint.
@@ -213,7 +225,7 @@ The project owner adjusts the discount on a tier's mint price. Does not affect c
213
225
 
214
226
  **Batch entry point**: `JB721TiersHook.setDiscountPercentsOf(JB721TiersSetDiscountPercentConfig[] calldata configs)` (external).
215
227
 
216
- **Permission**: `SET_721_DISCOUNT_PERCENT` from the hook owner.
228
+ **Who can call**: Hook owner, or an operator with `SET_721_DISCOUNT_PERCENT` permission from the hook owner.
217
229
 
218
230
  **Parameters**:
219
231
  - `uint256 tierId` -- the tier to update.
@@ -240,7 +252,7 @@ The project owner directly mints NFTs from tiers that have `allowOwnerMint = tru
240
252
 
241
253
  **Entry point**: `JB721TiersHook.mintFor(uint16[] calldata tierIds, address beneficiary)` (external).
242
254
 
243
- **Permission**: `MINT_721` from the hook owner.
255
+ **Who can call**: Hook owner, or an operator with `MINT_721` permission from the hook owner.
244
256
 
245
257
  **Parameters**:
246
258
  - `uint16[] tierIds` -- the tiers to mint from. One NFT per entry. Can repeat tiers.
@@ -270,7 +282,7 @@ A single call that both adds new tiers and removes existing tiers.
270
282
 
271
283
  **Entry point**: `JB721TiersHook.adjustTiers(JB721TierConfig[] calldata tiersToAdd, uint256[] calldata tierIdsToRemove)` (external).
272
284
 
273
- **Permission**: `ADJUST_721_TIERS` from the hook owner.
285
+ **Who can call**: Hook owner, or an operator with `ADJUST_721_TIERS` permission from the hook owner.
274
286
 
275
287
  **Execution order**:
276
288
  1. Removals are processed first (bitmap marked).
@@ -289,6 +301,8 @@ Deploy a 721 tiers hook for an existing project.
289
301
 
290
302
  **Entry point**: `JB721TiersHookDeployer.deployHookFor(uint256 projectId, JBDeploy721TiersHookConfig calldata deployTiersHookConfig, bytes32 salt)` (external).
291
303
 
304
+ **Who can call**: Anyone. The deployer is permissionless. The caller becomes the initial hook owner.
305
+
292
306
  **Parameters**:
293
307
  - `uint256 projectId` -- the project to associate the hook with.
294
308
  - `JBDeploy721TiersHookConfig deployTiersHookConfig`:
@@ -314,6 +328,7 @@ Deploy a 721 tiers hook for an existing project.
314
328
  **Events**:
315
329
  - `HookDeployed(projectId, newHook, caller)`.
316
330
  - `AddTier(tierId, tierConfig, caller)` -- one per initial tier.
331
+ - `SetDefaultReserveBeneficiary(hook, newBeneficiary, caller)` -- if any initial tier has `useReserveBeneficiaryAsDefault = true`.
317
332
 
318
333
  **Edge cases**:
319
334
  - **Re-initialization**: Reverts with `JB721TiersHook_AlreadyInitialized` if `PROJECT_ID` is already set.
@@ -330,6 +345,8 @@ Launch a new Juicebox project with a 721 tiers hook attached, all in one transac
330
345
 
331
346
  **Entry point**: `JB721TiersHookProjectDeployer.launchProjectFor(address owner, JBDeploy721TiersHookConfig calldata deployTiersHookConfig, JBLaunchProjectConfig calldata launchProjectConfig, IJBController controller, bytes32 salt)` (external).
332
347
 
348
+ **Who can call**: Anyone. The deployer is permissionless. The `owner` parameter receives the project ERC-721.
349
+
333
350
  **Parameters**:
334
351
  - `address owner` -- receives the project ERC-721.
335
352
  - `JBDeploy721TiersHookConfig deployTiersHookConfig` -- hook config (see Journey 9).
@@ -362,9 +379,9 @@ Launch a new Juicebox project with a 721 tiers hook attached, all in one transac
362
379
 
363
380
  Set a custom contract that resolves token URIs for all NFTs in the collection.
364
381
 
365
- **Entry point**: `JB721TiersHook.setMetadata(...)` (external). Pass `address(0)` as `tokenUriResolver` sentinel value `address(this)` to skip, or a real address to set.
382
+ **Entry point**: `JB721TiersHook.setMetadata(...)` (external). The `tokenUriResolver` parameter is an optional contract that can override the default IPFS-based token URI generation. Pass a contract address to set a custom resolver, `address(0)` to clear it and revert to the default, or the sentinel value `address(this)` to leave it unchanged.
366
383
 
367
- **Permission**: `SET_721_METADATA` from the hook owner.
384
+ **Who can call**: Hook owner, or an operator with `SET_721_METADATA` permission from the hook owner.
368
385
 
369
386
  **Parameters** (relevant subset):
370
387
  - `IJB721TokenUriResolver tokenUriResolver` -- the new resolver. Pass `IJB721TokenUriResolver(address(this))` to skip (no change). Pass `IJB721TokenUriResolver(address(0))` to clear.
@@ -378,7 +395,7 @@ Set a custom contract that resolves token URIs for all NFTs in the collection.
378
395
  **Behavior**:
379
396
  - When set, `tokenURI(tokenId)` calls `resolver.tokenUriOf(nft, tokenId)` instead of using IPFS URIs.
380
397
  - When cleared (set to `address(0)`), falls back to IPFS-based URIs via `JBIpfsDecoder`.
381
- - The sentinel value for "skip" is `address(this)` (the hook's own address), checked at line 483.
398
+ - The sentinel value for "skip" is `address(this)` (the hook's own address), checked in `tokenURI()`.
382
399
 
383
400
  **Edge cases**:
384
401
  - **Malicious resolver**: Could revert (blocking metadata reads for marketplaces) or return misleading URIs. Cannot affect funds.
@@ -392,6 +409,8 @@ Reorganize the sorted tier linked list to skip removed tiers. Improves iteration
392
409
 
393
410
  **Entry point**: `JB721TiersHookStore.cleanTiers(address hook)` (external, permissionless).
394
411
 
412
+ **Who can call**: Anyone. This function is permissionless.
413
+
395
414
  **Parameters**:
396
415
  - `address hook` -- the hook contract whose tier list to clean.
397
416
 
@@ -414,7 +433,7 @@ Update the collection's name, symbol, base URI, contract URI, or per-tier IPFS U
414
433
 
415
434
  **Entry point**: `JB721TiersHook.setMetadata(string calldata name, string calldata symbol, string calldata baseUri, string calldata contractUri, IJB721TokenUriResolver tokenUriResolver, uint256 encodedIPFSUriTierId, bytes32 encodedIPFSUri)` (external).
416
435
 
417
- **Permission**: `SET_721_METADATA` from the hook owner.
436
+ **Who can call**: Hook owner, or an operator with `SET_721_METADATA` permission from the hook owner.
418
437
 
419
438
  **Parameters**:
420
439
  - `string name` -- new collection name. Empty string = no change.
@@ -448,7 +467,7 @@ Transfer an NFT between addresses. Subject to per-tier and per-ruleset pause con
448
467
 
449
468
  **Entry point**: Standard ERC-721 `transferFrom(address from, address to, uint256 tokenId)` or `safeTransferFrom(...)`.
450
469
 
451
- **Permission**: Standard ERC-721 (owner, approved, or operator).
470
+ **Who can call**: Standard ERC-721 (token owner, approved address, or approved-for-all operator).
452
471
 
453
472
  **State changes**:
454
473
  1. ERC-721 ownership updated.
@@ -480,6 +499,8 @@ Configure how a percentage of a tier's effective price is distributed to split r
480
499
 
481
500
  **Entry point**: Splits are set during tier creation via `adjustTiers` (see Journey 3). The `splits` field in `JB721TierConfig` defines the split recipients.
482
501
 
502
+ **Who can call**: Splits are configured as part of `adjustTiers`, so the same permission applies: hook owner or an operator with `ADJUST_721_TIERS` permission. Split distribution during payment is automatic and permissionless.
503
+
483
504
  **Split group ID**: `uint256(uint160(hookAddress)) | (uint256(tierId) << 160)`. This is stored in `JBSplits` with `rulesetId = 0` (always active).
484
505
 
485
506
  **How it works during payment**:
@@ -494,14 +515,18 @@ Configure how a percentage of a tier's effective price is distributed to split r
494
515
 
495
516
  **Parameters** (per `JBSplit`):
496
517
  - `bool preferAddToBalance` -- for project splits, use `addToBalanceOf` instead of `pay`.
497
- - `uint256 percent` -- percentage of the remaining amount (sequential, not parallel).
498
- - `uint256 projectId` -- target project (0 = no project split).
518
+ - `uint32 percent` -- percentage of the remaining amount (sequential, not parallel).
519
+ - `uint64 projectId` -- target project (0 = no project split).
499
520
  - `address beneficiary` -- direct recipient (if no hook and no projectId).
500
521
  - `IJBSplitHook hook` -- split hook contract (highest priority).
501
- - `bool lockedUntil` -- (from JBSplits, prevents modification until timestamp).
522
+ - `uint48 lockedUntil` -- timestamp until which this split is locked and cannot be modified.
523
+
524
+ **Events** (during split distribution on payment):
525
+ - `SplitPayoutReverted(projectId, split, amount, reason, caller)` -- if an individual split recipient's payout reverts (funds become leftover, routed to project balance).
526
+ - `AddToBalanceReverted(projectId, token, amount, reason)` -- if the `addToBalanceOf` call for leftover funds reverts after distribution.
502
527
 
503
528
  **Edge cases**:
504
- - **`splitPercent` > SPLITS_TOTAL_PERCENT**: Not validated in the hook. A `splitPercent` exceeding 1e9 would forward more than the tier's price, potentially exceeding the payment amount. However, `beforePayRecordedWith` computes the split amount from the tier price, not the payment amount, so the terminal would need to have received enough.
529
+ - **`splitPercent` > SPLITS_TOTAL_PERCENT**: Validated at tier creation in `recordAddTiers`. Reverts with `JB721TiersHookStore_SplitPercentExceedsBounds` if `splitPercent` exceeds `JBConstants.SPLITS_TOTAL_PERCENT` (1e9).
505
530
  - **Weight adjustment**: When splits route funds away, `calculateWeight` reduces the mint weight so payers receive fewer project tokens proportional to the split fraction. This can be disabled with `issueTokensForSplits = true`.
506
531
  - **Cross-currency**: Split amounts are calculated in the pricing currency, then converted to the payment token denomination. Rounding occurs at each step.
507
532
  - **ERC-20 tokens**: The library pulls tokens from the terminal via `safeTransferFrom`, distributes them, and approves terminals for project splits via `forceApprove`.
package/foundry.toml CHANGED
@@ -1,5 +1,5 @@
1
1
  [profile.default]
2
- solc = '0.8.26'
2
+ solc = '0.8.28'
3
3
  evm_version = 'cancun'
4
4
  optimizer_runs = 200
5
5
  libs = ["node_modules", "lib"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.19",
3
+ "version": "0.0.21",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,10 +17,10 @@
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.10",
21
- "@bananapus/core-v6": "^0.0.23",
22
- "@bananapus/ownable-v6": "^0.0.10",
23
- "@bananapus/permission-ids-v6": "^0.0.10",
20
+ "@bananapus/address-registry-v6": "^0.0.15",
21
+ "@bananapus/core-v6": "^0.0.27",
22
+ "@bananapus/ownable-v6": "^0.0.14",
23
+ "@bananapus/permission-ids-v6": "^0.0.14",
24
24
  "@openzeppelin/contracts": "^5.6.1",
25
25
  "@prb/math": "^4.1.0",
26
26
  "solady": "^0.1.8"
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  // forge-lint: disable-next-line(unaliased-plain-import)
5
5
  import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {stdJson} from "forge-std/Script.sol";
5
5
  import {Vm} from "forge-std/Vm.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBAddressRegistry} from "@bananapus/address-registry-v6/src/interfaces/IJBAddressRegistry.sol";
5
5
  import {JBOwnable} from "@bananapus/ownable-v6/src/JBOwnable.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
5
5
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {mulDiv} from "@prb/math/src/Common.sol";
5
5
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
@@ -26,6 +26,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
26
26
 
27
27
  error JB721TiersHookStore_CantMintManually(uint256 tierId);
28
28
  error JB721TiersHookStore_CantRemoveTier(uint256 tierId);
29
+ error JB721TiersHookStore_DeadlockedReserve();
29
30
  error JB721TiersHookStore_DiscountPercentExceedsBounds(uint256 percent, uint256 limit);
30
31
  error JB721TiersHookStore_DiscountPercentIncreaseNotAllowed(uint256 percent, uint256 storedPercent);
31
32
  error JB721TiersHookStore_InsufficientPendingReserves(uint256 count, uint256 numberOfPendingReserves);
@@ -872,6 +873,12 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
872
873
  // Make sure the tier has a non-zero supply.
873
874
  if (tierToAdd.initialSupply == 0) revert JB721TiersHookStore_ZeroInitialSupply(tierId);
874
875
 
876
+ // A tier with initialSupply == 1 and reserveFrequency > 0 deadlocks: the single mint is reserved,
877
+ // leaving zero available for paid mints, but reserves only mint after a paid mint triggers them.
878
+ if (tierToAdd.initialSupply == 1 && tierToAdd.reserveFrequency > 0) {
879
+ revert JB721TiersHookStore_DeadlockedReserve();
880
+ }
881
+
875
882
  // Store the tier with that ID.
876
883
  _storedTierOf[msg.sender][tierId] = JBStored721Tier({
877
884
  price: uint104(tierToAdd.price),
@@ -1181,6 +1188,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
1181
1188
  /// @param tierId The ID of the tier to record a discount for.
1182
1189
  /// @param discountPercent The new discount percent being applied.
1183
1190
  function recordSetDiscountPercentOf(uint256 tierId, uint256 discountPercent) external override {
1191
+ // Make sure the tier hasn't been removed.
1192
+ JBBitmapWord memory bitmapWord = _removedTiersBitmapWordOf[msg.sender].readId(tierId);
1193
+ if (bitmapWord.isTierIdRemoved(tierId)) revert JB721TiersHookStore_TierRemoved(tierId);
1194
+
1184
1195
  // Make sure the discount percent is within the bound.
1185
1196
  if (discountPercent > JB721Constants.DISCOUNT_DENOMINATOR) {
1186
1197
  revert JB721TiersHookStore_DiscountPercentExceedsBounds(
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // OpenZeppelin Contracts (last updated v5.0.0) (token/ERC721/ERC721.sol)
3
3
 
4
- pragma solidity 0.8.26;
4
+ pragma solidity 0.8.28;
5
5
 
6
6
  import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
7
7
  import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";