@bananapus/721-hook-v6 0.0.36 → 0.0.38

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/RISKS.md CHANGED
@@ -1,125 +1,118 @@
1
1
  # Juicebox 721 Hook Risk Register
2
2
 
3
- This file focuses on the tiered-NFT accounting, reserve-mint, and cash-out risks in the shared 721 hook used across multiple higher-level products.
3
+ This file covers the tiered-NFT accounting, reserve-mint, and cash-out risks in the shared 721 hook used across multiple higher-level products.
4
4
 
5
- ## How to use this file
5
+ ## How To Use This File
6
6
 
7
- - Read `Priority risks` first; they summarize the shared 721-hook risks with the widest blast radius.
8
- - Use the detailed sections below for reentrancy, gas, tier accounting, and integration reasoning.
9
- - Treat `Invariants to Verify` as required regression coverage for any hook or store change.
7
+ - Read `Priority risks` first. They summarize the shared 721-hook risks with the widest blast radius.
8
+ - Use the later sections for reentrancy, gas, tier accounting, and integration reasoning.
9
+ - Treat `Invariants to verify` as required coverage for any hook or store change.
10
10
 
11
- ## Priority risks
11
+ ## Priority Risks
12
12
 
13
13
  | Priority | Risk | Why it matters | Primary controls |
14
14
  |----------|------|----------------|------------------|
15
- | P0 | Shared store corruption or accounting drift | `JB721TiersHookStore` is reused across products; a tier-accounting bug can affect Defifa, Croptop, Banny, revnets, and standalone hooks simultaneously. | Heavy store testing, invariant coverage, and cautious upgrades or deployments. |
16
- | P1 | Gas and iteration ceilings around tier state | Tier operations can iterate over reserves, pricing state, and cash-out weights; poorly bounded use can become a liveness issue. | Explicit gas tests, tier-count limits, and section 4 DoS analysis. |
17
- | P1 | Cash-out and reserve math mismatch | Fair redemption depends on tier supply, pending reserves, and pricing state staying aligned. | Detailed invariants, fuzzing, and integration tests with downstream consumers. |
18
-
15
+ | P0 | Shared store corruption or accounting drift | `JB721TiersHookStore` is reused across products. A tier-accounting bug can affect many repos at once. | Heavy store testing, invariants, and cautious deployment review. |
16
+ | P1 | Gas and iteration ceilings around tier state | Tier operations can iterate over reserves, pricing state, and cash-out weights. | Gas tests, tier-count limits, and DoS review. |
17
+ | P1 | Cash-out and reserve math mismatch | Fair redemption depends on tier supply, pending reserves, and pricing state staying aligned. | Detailed invariants, fuzzing, and integration tests. |
19
18
 
20
19
  ## 1. Trust Assumptions
21
20
 
22
- - **Store contract (`JB721TiersHookStore`) is fully trusted.** Record functions (`recordMint`, `recordBurn`, `recordAddTiers`, `recordTransferForTier`, `recordFlags`) have no access control -- they key state by `msg.sender`. Any address can call the store to manipulate state for a hook address it controls, but cannot affect other hooks.
23
- - **Tier configuration is partially immutable.** Once created: `price`, `initialSupply`, `reserveFrequency`, `category`, `votingUnits`, `splitPercent` are permanent. Mutable: `discountPercent` (owner-controlled, subject to `flags.cantIncreaseDiscountPercent`), `encodedIPFSUri` (owner-controlled).
24
- - **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.
25
- - **`flags.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.
26
- - **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`.
27
- - **`balanceOf(address(0))` reverts with a hook-specific error.** The hook explicitly reverts with `JB721TiersHook_ZeroAddress` when called with `address(0)`. This matches standard ERC-721 semantics but is still relevant for integrators that key off the custom error surface.
28
- - **`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)`.
29
- - **JBDirectory is trusted for terminal authentication.** `afterPayRecordedWith` and `afterCashOutRecordedWith` check `DIRECTORY.isTerminalOf()`. If the directory is compromised, arbitrary addresses can invoke pay/cashout hooks.
30
- - **JBPrices is trusted for cross-currency conversion.** A reverting price feed blocks all payments in non-matching currencies (DoS, not fund loss). If `address(prices) == address(0)`, cross-currency payments silently skip minting.
21
+ - **The store is trusted.** It keys state by `msg.sender`, so a hook can only affect its own namespace, but that namespace is fully trusted.
22
+ - **Tier configuration is partly immutable.** Price, supply, reserve frequency, category, voting units, and split percent are permanent after creation.
23
+ - **Category ordering matters.** The store's linked-list assumptions depend on correct sorted insertion.
24
+ - **`useReserveBeneficiaryAsDefault` has wide effects.** Setting it on a new tier can change the default reserve beneficiary for older tiers without their own explicit beneficiary.
25
+ - **Clone initialization is one-shot.** Clones are deployed and initialized atomically.
26
+ - **Directory and prices are trusted.** Terminal authentication and cross-currency behavior depend on core.
31
27
 
32
28
  ## 2. Economic Risks
33
29
 
34
- - **Cash out weight uses full undiscounted price.** `cashOutWeightOf` and `totalCashOutWeight` always use `storedTier.price`, not the discounted price. NFTs bought at a discount have cash-out value proportional to the full tier price. A `discountPercent=200` (100% off, denominator is 200) enables free minting with full cash-out weight. Mitigated by `flags.cantIncreaseDiscountPercent` flag.
35
- - **Pending reserves inflate the `totalCashOutWeight` denominator.** The total includes `price * pendingReserves` for unminted reserve NFTs. This dilutes per-NFT reclaim value before reserves are actually minted. Effect is proportional to reserve frequency and number of unminted reserves.
36
- - **Pay credits accumulate without cap.** `payCreditsOf` grows from leftover amounts after minting. Credits are per-beneficiary, not per-payer. When `payer != beneficiary`, overspend accrues to the beneficiary's credits; the payer's existing credits are not applied. No upper bound on accumulation.
37
- - **Zero-price tiers are valid.** A tier with `price=0` allows free minting. Cash-out weight for price-0 tiers is zero, so no value extraction risk. However, they still consume supply and generate pending reserves if `reserveFrequency > 0`.
38
- - **Discount denominator is 200, not 10,000.** `DISCOUNT_DENOMINATOR = 200`. A `discountPercent` of 1 = 0.5% off, 100 = 50% off, 200 = 100% off. `mulDiv` rounding makes small-price discounts lossy (e.g., `price=1, discountPercent=1` -> `mulDiv(1,1,200)=0`, no discount applied).
39
- - **Currency mismatch silently skips minting.** If payment currency differs from tier pricing currency and `PRICES == address(0)`, `_processPayment` returns without minting or reverting. Funds enter the project balance, no NFTs issued, no credits created (the normalized value is 0).
40
- - **`splitPercent` reduces minting weight.** `beforePayRecordedWith` scales down the weight returned to the terminal by `(amountValue - totalSplitAmount) / amountValue`. Payers receive fewer fungible tokens for the split portion. `issueTokensForSplits` flag overrides this to give full weight.
41
- - **Reserved NFT minting is permissionless.** Anyone can call `mintPendingReservesFor` to mint pending reserves to the tier's beneficiary. Only gated by the `mintPendingReservesPaused` ruleset flag. Timing of reserve minting is not owner-controlled.
42
- - **Cross-reference: `PRICES == address(0)` behavior.** When `address(prices) == address(0)`, cross-currency payments skip minting silently (see Trust Assumptions section 1 and Accepted Behaviors section 8.3). This is the same address stored during `initialize()` — clones that omit the prices parameter get `address(0)` permanently.
30
+ - **Cash-out weight uses full undiscounted price.**
31
+ - **Pending reserves inflate the cash-out denominator before reserves are minted.**
32
+ - **Pay credits can accumulate without a cap.**
33
+ - **Zero-price tiers are valid.**
34
+ - **Discount math uses a denominator of 200, not 10,000.**
35
+ - **Currency mismatch can silently skip minting when no prices contract is configured.**
36
+ - **`splitPercent` can reduce fungible-token minting weight.**
37
+ - **Reserve minting is permissionless.**
43
38
 
44
39
  ## 3. Reentrancy Surface
45
40
 
46
- - **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.
47
- - **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 toward the project's balance after the distribution loop via `addToBalanceOf`. Later split recipients receive only their proportional share, not the failed recipient's share. This does not revert the entire payment unless the fallback `addToBalanceOf` path also reverts.
48
- - **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.
49
- - **Split fallback can still strand value if the project terminal rejects leftovers.** `_distributeSingleSplit` tries to route failed split payouts into the source project's primary terminal with `addToBalanceOf`. If that fallback also reverts, the whole hook call reverts with `JB721TiersHookLib_SplitFallbackFailed` after the hook has already received the forwarded funds. For native ETH, that leaves the ETH stranded in the hook. For ERC-20s, approval is reset but the tokens remain in the hook. There is no built-in recovery path.
50
- - **`afterCashOutRecordedWith` execution order.** Burns tokens via `_burn()` -> `_update()` -> `STORE.tierTransferInfoOfTokenId()` 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.
51
- - **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.
52
-
53
- ## 4. Gas/DoS Vectors
54
-
55
- - **`totalCashOutWeight` iterates ALL tier IDs** (1 to `maxTierIdOf`), including removed tiers with minted NFTs. Called during every `beforeCashOutRecordedWith`. At ~2-3k gas per tier, 500+ tiers approaches block gas limits. Could block all NFT cash-outs if an attacker with `ADJUST_721_TIERS` permission adds thousands of tiers.
56
- - **`balanceOf`, `votingUnitsOf`, `totalSupplyOf` iterate all tiers.** Same pattern: loop from `maxTierIdOf` down to 1. These are view functions but called by governance contracts.
57
- - **Theoretical max is not the supported operating envelope.** The store permits up to 65,535 tiers, but the practical
58
- comfort zone is far lower. The test suite demonstrates survivability at 100 to 200 tiers and also demonstrates that
59
- `balanceOf` and `totalCashOutWeight` become materially more expensive at 100 tiers than at 10 tiers. Treat large
60
- catalogs as an explicit gas-budgeting exercise, not as a default deployment shape.
61
- - **`tiersOf` traverses removed tiers.** Removed tiers are skipped via bitmap but still traversed in the linked list. `cleanTiers()` must be called separately to compact. `cleanTiers()` is permissionless and idempotent.
62
- - **Minting from many tiers in one payment.** `recordMint` loops per tier ID: storage read (stored tier + bitmap check) per iteration. 50 tiers in one payment ~5-7M gas (tested, fits in 30M block). 100+ tiers in a single mint is feasible but consumes most of the block.
63
- - **`recordAddTiers` sort-insertion cost.** Adding a low-category tier to a hook with many existing higher-category tiers iterates the entire sorted list to find the insertion point. O(n) per added tier.
64
- - **Reserve minting is unbounded per call.** `mintPendingReservesFor(tierId, count)` mints `count` NFTs in a loop. Large `count` could exceed block gas. Callers should batch.
65
- - **Max tiers capped at `uint16.max` (65,535).** Store enforces this ceiling. Practical gas limits make 1,000+ tiers problematic for on-chain reads.
66
- - **200+ tiers tested.** `TestAuditGaps_GasLimits` adds 200 tiers and verifies store correctness and gas within 30M block limit.
41
+ - **Split hook callbacks execute arbitrary code.**
42
+ - **Split beneficiary ETH sends can fail softly and reroute value.**
43
+ - **Terminal `pay` and `addToBalanceOf` calls during split distribution can reenter external systems.**
44
+ - **Split fallback can still strand value if the project terminal rejects leftovers.**
45
+ - **There is no `ReentrancyGuard`.** Safety depends on state ordering, terminal auth, and wrapped external calls.
46
+
47
+ ## 4. Gas And DoS Vectors
48
+
49
+ - **`totalCashOutWeight` iterates all tier IDs.**
50
+ - **`balanceOf`, `votingUnitsOf`, and `totalSupplyOf` also iterate all tiers.**
51
+ - **Large tier catalogs are technically allowed but not the supported operating shape.**
52
+ - **`tiersOf` still traverses removed tiers until cleanup runs.**
53
+ - **Minting across many tiers in one payment can get expensive fast.**
54
+ - **Reserve minting is loop-based and should be batched when large.**
67
55
 
68
56
  ## 5. Access Control
69
57
 
70
- - **`adjustTiers` (add/remove):** Requires `ADJUST_721_TIERS` permission from `owner()`. Respects `noNewTiersWithReserves`, `noNewTiersWithVotes`, `noNewTiersWithOwnerMinting` flags (append-only restrictions). `flags.cantBeRemoved` flag on individual tiers is enforced by the store.
71
- - **`mintFor` (owner minting):** Requires `MINT_721` permission. Bypasses price checks (`amount: type(uint256).max`). Still requires per-tier `flags.allowOwnerMint` flag. Tiers with `reserveFrequency > 0` cannot have `flags.allowOwnerMint` (enforced at creation).
72
- - **`setDiscountPercentOf`:** Requires `SET_721_DISCOUNT_PERCENT` permission. Cannot increase discount if `flags.cantIncreaseDiscountPercent` is set on the tier. Can always decrease.
73
- - **`setMetadata`:** Requires `SET_721_METADATA` permission. Can change name, symbol, baseURI, contractURI, tokenUriResolver, and per-tier IPFS URIs. Sentinel value `IJB721TokenUriResolver(address(this))` means "no change" for resolver.
74
- - **Transfer pause:** Ruleset-level flag (`transfersPaused` in 721-specific metadata, bit 0). Only applies to tiers with `flags.transfersPausable = true`. Burns (transfer to address(0)) are never paused. Tiers created with `flags.transfersPausable = false` can never be paused.
75
- - **`mintPendingReservesFor`:** Permissionless. Only gated by `mintPendingReservesPaused` ruleset flag (bit 1 of 721 metadata).
76
- - **`cleanTiers`:** Permissionless, idempotent. Compacts the sorted tier list by removing gaps from deleted tiers. No economic impact.
77
- - **Store `recordFlags`:** No access control -- stores against `msg.sender`. Safe because the store keys by caller address, but a compromised hook can freely change its own flags.
58
+ - **`adjustTiers` is permissioned and respects append-only restrictions.**
59
+ - **`mintFor` is permissioned and still depends on per-tier owner-mint flags.**
60
+ - **`setDiscountPercentOf` is permissioned and can be one-way constrained.**
61
+ - **`setMetadata` is permissioned and changes name, symbol, URIs, resolver, and tier URIs.**
62
+ - **Transfer pause is tier-sensitive.**
63
+ - **`mintPendingReservesFor` and `cleanTiers` are permissionless by design.**
78
64
 
79
65
  ## 6. Integration Risks
80
66
 
81
- - **Data hook weight override.** `beforePayRecordedWith` returns modified `weight` accounting for tier split deductions. Terminal uses this for fungible token minting. If splits consume 100% of payment, `weight = 0` and no fungible tokens are minted.
82
- - **Metadata encoding is fragile.** Relies on `JBMetadataResolver.getDataFor` with purpose strings `"pay"` / `"cashOut"` keyed by `METADATA_ID_TARGET` (original hook deploy address for clones). Malformed metadata results in no NFTs minted (pay) or no NFTs burned (cashout) without reverting (unless `preventOverspending` is true).
83
- - **`beforeCashOutRecordedWith` rejects fungible tokens.** Reverts with `JB721Hook_UnexpectedTokenCashedOut` if `context.cashOutCount > 0`. Cannot simultaneously cash out NFTs and fungible tokens in the same terminal call.
84
- - **Split group ID encoding.** Composite: `uint256(uint160(hookAddress)) | (tierId << 160)`. Tier IDs are capped at uint16, so no overflow. Splits are permanently coupled to a specific hook address -- migrating to a new hook requires re-creating all split groups.
85
- - **ERC-20 split distribution pulls from terminal.** `distributeAll` calls `SafeERC20.safeTransferFrom(token, msg.sender, address(this), amount)` to pull ERC-20s from the terminal. Requires the terminal to have granted allowance via its `_beforeTransferTo` pattern. If the terminal's allowance mechanism changes, distribution fails.
86
- - **Forwarded funds depend on non-empty split metadata.** `_processPayment` only calls `distributeAll` when both `context.forwardedAmount.value != 0` and `context.hookMetadata.length != 0`. If an integration ever forwards funds with empty hook metadata, distribution is skipped and the funds remain in the hook contract with no dedicated rescue path.
87
- - **Split-fallback success depends on the source project's active terminal.** Failed split payouts are not simply burned or refunded. They are re-routed into the source project's current primary terminal for the forwarded token. If that terminal is unset or rejects `addToBalanceOf`, the call reverts and the hook can retain the funds.
88
- - **Token URI resolver external calls.** `tokenURI()` and `tiersOf(..., includeResolvedUri=true)` call the resolver if set. A reverting resolver blocks all metadata reads (marketplace/frontend impact, no fund risk).
89
-
90
- ## 7. Invariants to Verify
91
-
92
- - **Per-tier supply conservation:** For every tier, `remainingSupply + outstanding + burned == initialSupply`, where `outstanding = initialSupply - remainingSupply - burned`.
93
- - **Total cash out weight consistency:** `totalCashOutWeight >= sum(tier.price * outstandingNFTs)` for all tiers. Equality holds when no pending reserves exist. Strictly greater when pending reserves are included.
94
- - **Reserve mints bounded by frequency:** For each tier, `reservesMinted <= ceil(nonReserveMints / reserveFrequency)`. Enforced by `_numberOfPendingReservesFor` calculation.
95
- - **Remaining supply never exceeds initial:** `remainingSupply + numberOfBurnedFor <= initialSupply` for every tier.
96
- - **Token ID uniqueness:** Generated as `tierId * 1_000_000_000 + tokenNumber`. Token numbers monotonically assigned from `initialSupply - remainingSupply`. Supply capped at `999,999,999` per tier. No collisions possible.
97
- - **Credit tracking accuracy:** `payCreditsOf[addr]` equals cumulative leftover from payments where `addr` was beneficiary, minus credits consumed by subsequent mints where `payer == beneficiary`.
98
- - **Removed tiers excluded from active listing:** `tiersOf()` never returns tiers marked in the removal bitmap.
99
- - **`maxTierIdOf` monotonically increases:** Tier removal marks a bitmap, does not decrement `maxTierIdOf`.
100
- - **Balance consistency:** `sum(tierBalanceOf[hook][owner][tierId])` across all tiers equals `ERC721._balances[owner]` for each owner.
101
- - **Cash out weight uses full price regardless of discount:** `cashOutWeightOf` for any token returns the tier's stored `price`, not the discounted purchase price.
102
- - **Discount monotonicity when locked:** If `flags.cantIncreaseDiscountPercent` is set, `discountPercent` can only decrease or stay the same.
103
- - **Flags are append-only restrictions:** `noNewTiersWithReserves`, `noNewTiersWithVotes`, `noNewTiersWithOwnerMinting` prevent future tiers from using those features but do not retroactively affect existing tiers.
67
+ - **Hook weight can override fungible-token minting.**
68
+ - **Metadata encoding is fragile.**
69
+ - **`beforeCashOutRecordedWith` rejects mixed fungible-token cash outs.**
70
+ - **Split group IDs are tightly coupled to the hook address.**
71
+ - **ERC-20 split distribution depends on terminal allowance behavior.**
72
+ - **Forwarded funds with empty hook metadata can skip distribution and remain in the hook.**
73
+ - **Token URI resolver calls can block metadata reads if the resolver reverts.**
74
+
75
+ ## 7. Invariants To Verify
76
+
77
+ - per-tier supply conservation holds
78
+ - total cash-out weight stays consistent with outstanding NFTs and pending reserves
79
+ - reserve minting stays bounded by reserve frequency
80
+ - token IDs remain unique
81
+ - credits track leftovers correctly
82
+ - removed tiers stay excluded from active listings
83
+ - store balance views match ERC-721 balances
84
+ - discount monotonicity is enforced when locked
104
85
 
105
86
  ## 8. Accepted Behaviors
106
87
 
107
- ### 8.1 Pending reserves inflate `totalCashOutWeight` denominator (by design)
88
+ ### 8.1 Pending reserves dilute cash-out value before minting
108
89
 
109
- `totalCashOutWeight` includes `price * pendingReserves` for unminted reserve NFTs. This dilutes per-NFT reclaim value before reserves are actually minted. This is intentional: if pending reserves were excluded, a holder could front-run `mintPendingReservesFor` to cash out at an inflated per-NFT value, then the reserve mint would reduce the remaining holders' share. Including pending reserves in the denominator ensures that the reserve allocation is priced in at all times, preventing front-running. The trade-off is that minting reserves (via the permissionless `mintPendingReservesFor`) does not change individual cash-out values — the reserves are already accounted for.
90
+ This is intentional. Including pending reserves in the denominator prevents reserve front-running.
110
91
 
111
- ### 8.2 Cash-out weight uses full price regardless of discount (by design)
92
+ ### 8.2 Cash-out weight uses full price regardless of discount
112
93
 
113
- `cashOutWeightOf` returns the tier's stored `price`, not the discounted purchase price. An NFT bought at 50% discount has the same cash-out weight as one bought at full price. This is intentional: the cash-out weight represents the NFT's share of the project's treasury, not the purchase price paid. Changing cash-out weight based on discount would require per-token storage of purchase price, adding significant gas cost. The discount mechanism is designed for promotional pricing, not for creating tiered cash-out classes.
94
+ This is intentional. The cash-out weight represents treasury share, not purchase price.
114
95
 
115
- ### 8.3 Currency mismatch silently skips minting (accepted degradation)
96
+ ### 8.3 Currency mismatch can skip minting when no prices surface exists
116
97
 
117
- If the payment currency differs from the tier pricing currency and `PRICES == address(0)`, `_processPayment` returns without minting NFTs or creating credits. Funds enter the project balance but no NFTs are issued. This is accepted because: (1) reverting would block all payments in the mismatched currency, (2) the project owner chose not to configure price feeds (by not setting `PRICES`), and (3) the funds are not lost — they increase the project's surplus and are reclaimable via cash-out. Projects that need cross-currency NFT minting must configure `JBPrices`.
98
+ If currencies differ and `PRICES == address(0)`, payments can increase project balance without minting NFTs. That is an accepted degradation rather than a revert path.
118
99
 
119
- ### 8.4 Tiny split allocations can round down to zero recipient amounts
100
+ ### 8.4 Tiny split allocations can round down to zero
120
101
 
121
- Split metadata is expressed in whole token units after conversion and capping. For very small allocations, each rounded per-tier split amount can become zero even though the overall forwarded amount is still reduced by the capped split total. This is an accepted precision tradeoff for dust-sized payments: integrations should not rely on sub-precision split routing and should expect tiny split allocations to be economically lossy.
102
+ Dust-sized split allocations can become economically lossy after rounding.
122
103
 
123
104
  ### 8.5 Failed split payouts only degrade cleanly if the fallback terminal path works
124
105
 
125
- The hook treats a reverting split hook, beneficiary, or target terminal as a soft failure and attempts to re-route that amount into the source project's balance. That graceful degradation depends on the source project's current primary terminal accepting `addToBalanceOf` for the forwarded token. If that terminal is missing or rejects the call, the transaction reverts after funds have already reached the hook, and the hook can retain those assets without a built-in rescue path.
106
+ If both the primary split path and the fallback `addToBalanceOf` path fail, the hook can retain assets with no built-in rescue path.
107
+
108
+ ### 8.6 Credit-funded tier purchases may underfund split obligations
109
+
110
+ Pay credits can be used to buy tiers that carry a `splitPercent`. When credits satisfy part of the tier price, the fresh ETH forwarded to splits may be less than the split obligation implied by the full tier price. Project owners who consider this a problem should enable the `preventBuyingTierWithCredits` flag on affected tiers. This is accepted behavior.
111
+
112
+ ### 8.7 Changing the default reserve beneficiary redirects pending reserves
113
+
114
+ When the default reserve beneficiary is updated, any pending (unminted) reserves across all tiers that rely on the default will be distributed to the new beneficiary once minted. This is by design — the project owner controls reserve distribution targets and may intentionally redirect pending reserves by updating the default.
115
+
116
+ ### 8.8 Discounted credit mints retain full cash-out weight
117
+
118
+ Tokens minted at a discounted price via credits still carry the full undiscounted tier price as their cash-out weight. This means a holder who purchased at a discount receives the same treasury share as a holder who paid full price. Project owners should factor this into discount percentage decisions, as aggressive discounts can create favorable cash-out economics for discounted buyers.
package/SKILLS.md CHANGED
@@ -2,47 +2,42 @@
2
2
 
3
3
  ## Use This File For
4
4
 
5
- - Use this file when the task involves tiered NFT issuance, reserve minting, voting units, tier splits, or token URI resolver integration for Juicebox projects.
6
- - Start here, then decide whether the bug is in hook runtime logic, store accounting, deployer initialization, or downstream token-URI resolution. This repo spans all four and they are easy to conflate.
5
+ - Use this file when the task involves tiered NFT minting, reserve minting, tier adjustments, deployer wiring, or 721-aware cash-out behavior.
6
+ - Start here, then decide whether the issue is in hook execution, store accounting, deployer setup, or resolver behavior.
7
7
 
8
8
  ## Read This Next
9
9
 
10
10
  | If you need... | Open this next |
11
11
  |---|---|
12
- | Repo overview and integration model | [`README.md`](./README.md), [`ARCHITECTURE.md`](./ARCHITECTURE.md) |
13
- | Runtime hook behavior | [`src/JB721TiersHook.sol`](./src/JB721TiersHook.sol), [`src/abstract/JB721Hook.sol`](./src/abstract/JB721Hook.sol) |
14
- | Tier storage and accounting | [`src/JB721TiersHookStore.sol`](./src/JB721TiersHookStore.sol) |
15
- | Deployment or project launch helpers | [`src/JB721TiersHookDeployer.sol`](./src/JB721TiersHookDeployer.sol), [`src/JB721TiersHookProjectDeployer.sol`](./src/JB721TiersHookProjectDeployer.sol), [`script/Deploy.s.sol`](./script/Deploy.s.sol) |
16
- | Shared libraries, interfaces, and resolver surface | [`src/libraries/`](./src/libraries/), [`src/interfaces/`](./src/interfaces/), [`src/structs/`](./src/structs/) |
17
- | Mint, pricing, voting, and checkpoint behavior | [`test/TestVotingUnitsLifecycle.t.sol`](./test/TestVotingUnitsLifecycle.t.sol), [`test/TestCheckpoints.t.sol`](./test/TestCheckpoints.t.sol) |
18
- | Reentrancy, forks, and pinned edge cases | [`test/TestSafeTransferReentrancy.t.sol`](./test/TestSafeTransferReentrancy.t.sol), [`test/721HookAttacks.t.sol`](./test/721HookAttacks.t.sol), [`test/Fork.t.sol`](./test/Fork.t.sol), [`test/TestAuditGaps.sol`](./test/TestAuditGaps.sol) |
12
+ | Repo overview and architecture | [`README.md`](./README.md), [`ARCHITECTURE.md`](./ARCHITECTURE.md) |
13
+ | Runtime hook behavior | [`src/JB721TiersHook.sol`](./src/JB721TiersHook.sol) |
14
+ | Tier accounting | [`src/JB721TiersHookStore.sol`](./src/JB721TiersHookStore.sol) |
15
+ | Shared math and metadata helpers | [`src/libraries/`](./src/libraries/), [`src/interfaces/`](./src/interfaces/), [`src/structs/`](./src/structs/) |
16
+ | Deployer flows | [`src/JB721TiersHookDeployer.sol`](./src/JB721TiersHookDeployer.sol), [`src/JB721TiersHookProjectDeployer.sol`](./src/JB721TiersHookProjectDeployer.sol) |
17
+ | Lifecycle, invariant, and audit coverage | [`test/E2E/Pay_Mint_Redeem_E2E.t.sol`](./test/E2E/Pay_Mint_Redeem_E2E.t.sol), [`test/invariants/TierLifecycleInvariant.t.sol`](./test/invariants/TierLifecycleInvariant.t.sol), [`test/invariants/TieredHookStoreInvariant.t.sol`](./test/invariants/TieredHookStoreInvariant.t.sol), [`test/audit/CodexSplitCreditsMismatch.t.sol`](./test/audit/CodexSplitCreditsMismatch.t.sol) |
19
18
 
20
19
  ## Repo Map
21
20
 
22
21
  | Area | Where to look |
23
22
  |---|---|
24
23
  | Main contracts | [`src/`](./src/) |
25
- | Abstract bases, interfaces, structs, and libraries | [`src/abstract/`](./src/abstract/), [`src/interfaces/`](./src/interfaces/), [`src/structs/`](./src/structs/), [`src/libraries/`](./src/libraries/) |
24
+ | Libraries, interfaces, and structs | [`src/libraries/`](./src/libraries/), [`src/interfaces/`](./src/interfaces/), [`src/structs/`](./src/structs/) |
26
25
  | Scripts | [`script/`](./script/) |
27
26
  | Tests | [`test/`](./test/) |
28
27
 
29
28
  ## Purpose
30
29
 
31
- Tiered ERC-721 NFT issuance and cash-out hook for Juicebox V6. This repo controls tier pricing, reserve issuance, voting units, split forwarding, and deployer flows for projects that mint NFTs on pay.
30
+ Tiered NFT hook for Juicebox V6. This repo handles priced NFT tiers, reserve logic, tier-aware cash outs, and deployer flows that wire those hooks into projects.
32
31
 
33
32
  ## Reference Files
34
33
 
35
- - Open [`references/runtime.md`](./references/runtime.md) when you need the contract roles, payment and cash-out path, reserve math, or the main invariants that should hold while editing.
36
- - Open [`references/operations.md`](./references/operations.md) when you need deployer behavior, metadata and permission surfaces, test breadcrumbs, or the common failure modes to verify before shipping.
34
+ - Open [`references/runtime.md`](./references/runtime.md) when you need hook and store roles, cash-out weight behavior, or main invariants.
35
+ - Open [`references/operations.md`](./references/operations.md) when you need permission paths, tier-adjustment rules, test breadcrumbs, or common stale assumptions.
37
36
 
38
37
  ## Working Rules
39
38
 
40
- - Start in [`src/JB721TiersHook.sol`](./src/JB721TiersHook.sol) for pay and cash-out behavior, but verify storage-side assumptions in [`src/JB721TiersHookStore.sol`](./src/JB721TiersHookStore.sol) before changing mint, burn, reserve, or supply logic.
41
- - The store is the source of truth for supply, reserve, removal, and tier-order invariants. Do not “fix” those concepts only in the hook layer.
42
- - Pending reserves are part of live economics, not deferred bookkeeping. Cash-out denominators and tier availability both depend on them before reserves are minted.
43
- - Pay credits, overspending protection, and tier split forwarding are economically relevant. Treat them like accounting, not just UX.
44
- - Treat tier splits, reserve minting, and discounted pricing as treasury-sensitive. Check both runtime code and regression coverage before assuming a change is local.
45
- - Discounted mint price and cash-out weight are intentionally not the same thing. Free or discounted mints can still carry full tier cash-out weight by design.
46
- - Changing the default reserve beneficiary is not cosmetic. It can change which tiers have pending reserves and therefore change redemption economics for existing mints.
47
- - When a task mentions token metadata or rendering, confirm whether the behavior lives in this repo or in an external resolver. Do not over-edit the hook when the real change belongs downstream.
48
- - When changing deployers or initialization, verify the hook, store, and project-launch path stay aligned. These flows are tightly coupled.
39
+ - Start in [`src/JB721TiersHookStore.sol`](./src/JB721TiersHookStore.sol) for tier accounting questions.
40
+ - Keep hook behavior, store behavior, and resolver behavior separate.
41
+ - Reserve logic, split routing, and cash-out weight calculations are part of the economic surface.
42
+ - Tier adjustments are high-risk because many tier properties are intentionally immutable after creation.
43
+ - If a problem looks like metadata only, verify it is not actually a hook or store issue first.
package/USER_JOURNEYS.md CHANGED
@@ -2,192 +2,120 @@
2
2
 
3
3
  ## Repo Purpose
4
4
 
5
- This repo is the standard tiered NFT hook for V6 projects.
6
- It owns tier issuance, reserve accounting, hook-aware mint and cash-out behavior, and deployer packaging for hook
7
- clones or hook-shaped project launches. It does not own collection-specific rendering or app-layer policy built on top
8
- of the hook.
5
+ This repo adds tiered NFT logic to Juicebox payment and cash-out flows. It owns tier pricing, reserves, and NFT lifecycle state. It does not own project-specific artwork or game logic.
9
6
 
10
7
  ## Primary Actors
11
8
 
12
- - project owners selling or rewarding supporters with tiered NFTs
13
- - operators managing tier supply, pricing, reserves, and ruleset-aware hook behavior
14
- - supporters minting or cashing out tier positions through normal Juicebox flows
15
- - integrators composing custom token URI resolvers or downstream products on top of the hook
9
+ - projects that want priced NFT tiers in their Juicebox flow
10
+ - operators managing tier configuration and hook permissions
11
+ - holders minting, transferring, and cashing out tiered NFTs
12
+ - auditors reviewing tier accounting, reserve behavior, and deployer wiring
16
13
 
17
14
  ## Key Surfaces
18
15
 
19
- - `JB721TiersHook`: project-facing hook behavior for minting, reserves, metadata, and cash out
20
- - `JB721TiersHookStore`: tier definitions and accounting backend
21
- - `JB721TiersHookDeployer`: clone factory for existing projects
22
- - `JB721TiersHookProjectDeployer`: project-launch packaging for new hook-backed projects
16
+ - `JB721TiersHook`: runtime 721 hook behavior
17
+ - `JB721TiersHookStore`: tier accounting and supply state
18
+ - `JB721TiersHookDeployer` and `JB721TiersHookProjectDeployer`: wiring surfaces
19
+ - token URI resolver contracts in downstream repos: presentation layer
23
20
 
24
- ## Journey 1: Add A Tiered 721 Hook To An Existing Project
21
+ ## Journey 1: Launch A Project With A Tiered NFT Hook
25
22
 
26
- **Actor:** project owner or deployer.
23
+ **Actor:** project operator or deployer.
27
24
 
28
- **Intent:** attach tiered NFT behavior to an existing project without relaunching it.
25
+ **Intent:** attach tiered NFT issuance to a project from the start.
29
26
 
30
27
  **Preconditions**
31
- - the project already exists in Juicebox
32
- - the owner knows the tier config, reserve behavior, and resolver assumptions it wants
33
- - the next ruleset metadata can be updated safely
28
+ - the project knows its tier structure and hook expectations
29
+ - the deployer path matches whether the project already exists
34
30
 
35
31
  **Main Flow**
36
- 1. Use `JB721TiersHookDeployer` to clone a hook for the project.
37
- 2. Define tier config, reserve behavior, resolver choice, and per-ruleset flags.
38
- 3. Queue or install ruleset metadata that points at the hook.
39
- 4. Future payments can now mint tiers under the configured constraints.
32
+ 1. Deploy a hook clone or launch a project with the hook already attached.
33
+ 2. Configure tier data, hook metadata, and resolver expectations.
34
+ 3. Transfer hook ownership into the intended project control surface.
40
35
 
41
36
  **Failure Modes**
42
- - the hook is deployed correctly but the ruleset metadata does not actually activate it
43
- - teams treat the resolver as cosmetic when it is part of the trusted surface
37
+ - wrong hook wiring at launch
38
+ - wrong resolver assumptions
39
+ - teams treat deployer convenience as proof that runtime economics are correct
44
40
 
45
41
  **Postconditions**
46
- - the project has an attached hook and future rulesets can mint tiers under the configured constraints
42
+ - the project has a tiered NFT hook wired into its Juicebox flow
47
43
 
48
- ## Journey 2: Launch A New Project With A 721 Hook Already Wired In
44
+ ## Journey 2: Pay And Mint Tiered NFTs
49
45
 
50
- **Actor:** product team or deployer.
46
+ **Actor:** payer or integration acting for a payer.
51
47
 
52
- **Intent:** launch a project whose treasury and tiered NFT logic are aligned from the first ruleset.
48
+ **Intent:** mint NFTs from configured tiers while preserving the project's terminal flow.
53
49
 
54
50
  **Preconditions**
55
- - the team has launch config, terminal config, and initial tier config ready
51
+ - the project has active tiers
52
+ - payment metadata correctly names the intended tiers
56
53
 
57
54
  **Main Flow**
58
- 1. Use `JB721TiersHookProjectDeployer` with launch and hook config.
59
- 2. Launch the project and deploy the hook in the same packaged flow.
60
- 3. Ensure the first ruleset already points at the created hook.
61
- 4. Start life as a hook-backed project instead of converting later.
55
+ 1. A payment reaches the hook through the terminal.
56
+ 2. The hook decodes tier selection and records mint state in the store.
57
+ 3. NFTs mint, reserve implications update, and any split routing is applied.
62
58
 
63
59
  **Failure Modes**
64
- - deployers assume the package is purely convenience and miss the initial ruleset implications
65
- - launch-time metadata drifts from the actual hook config
60
+ - malformed metadata
61
+ - currency mismatch or missing pricing support
62
+ - splits or discounts behave differently than the integration expected
66
63
 
67
64
  **Postconditions**
68
- - the project launches with tiered NFT logic active from the first ruleset
65
+ - the payer or beneficiary receives the intended NFT tiers and tier state updates
69
66
 
70
- ## Journey 3: Mint Specific Tiers Through A Payment
67
+ ## Journey 3: Mint Or Release Reserve NFTs
71
68
 
72
- **Actor:** payer.
69
+ **Actor:** reserve beneficiary, operator, or any caller using the reserve path.
73
70
 
74
- **Intent:** mint one or more tiers through a normal payment flow.
71
+ **Intent:** realize pending reserves under the configured reserve rules.
75
72
 
76
73
  **Preconditions**
77
- - the project has live tiers
78
- - the payer submits metadata encoding the intended tier selections
74
+ - the relevant tiers have reserve logic enabled
75
+ - the ruleset does not pause pending reserve minting
79
76
 
80
77
  **Main Flow**
81
- 1. Submit a payment with tier-selection metadata.
82
- 2. `JB721TiersHook` validates availability, quantity rules, discounts, and ruleset flags.
83
- 3. `JB721TiersHookStore` updates supply and reserve accounting.
84
- 4. The hook mints the intended NFTs and the terminal completes treasury accounting.
78
+ 1. Eligible reserve amounts accumulate as mint activity happens.
79
+ 2. A caller triggers reserve minting for pending tiers.
80
+ 3. The store moves reserve state forward and NFTs mint to the configured reserve beneficiary.
85
81
 
86
82
  **Failure Modes**
87
- - sold-out tiers, malformed metadata, or cross-currency pricing mistakes
88
- - pay-hook participation flags do not match the user's assumptions
89
- - split-routing or hook behavior changes what part of the payment actually mints
83
+ - teams misunderstand that reserve minting timing is not owner-exclusive
84
+ - reserve assumptions drift from actual tier settings
90
85
 
91
86
  **Postconditions**
92
- - the intended tiers are minted and store accounting reflects the updated supply and reserve state
87
+ - pending reserves mint according to tier configuration
93
88
 
94
- ## Journey 4: Mint Reserves And Operate Tier Inventory Over Time
89
+ ## Journey 4: Cash Out Tiered NFTs
95
90
 
96
- **Actor:** owner or authorized operator.
91
+ **Actor:** NFT holder.
97
92
 
98
- **Intent:** manage tier inventory and reserve behavior after launch.
93
+ **Intent:** redeem tiered NFT exposure through the terminal cash-out path.
99
94
 
100
95
  **Preconditions**
101
- - the collection is live
102
- - the operator has the required permission surfaces to mutate tiers or mint reserves
96
+ - the holder owns valid NFTs
97
+ - the hook is active for the cash-out path
103
98
 
104
99
  **Main Flow**
105
- 1. Use tier-management surfaces to add, remove, or adjust tiers.
106
- 2. Mint reserves through the configured reserve logic.
107
- 3. Let the store preserve historical tier state so old token IDs still resolve correctly.
108
- 4. Keep downstream products assuming stable tier semantics whenever possible.
100
+ 1. The holder requests a cash out with NFT-specific metadata.
101
+ 2. The hook burns the selected NFTs and records the burn in the store.
102
+ 3. The terminal completes reclaim logic using the hook-aware cash-out surface.
109
103
 
110
104
  **Failure Modes**
111
- - tier mutations surprise downstream products or resolvers
112
- - reserve accounting is misread as ordinary minting
105
+ - integrations mix fungible-token and NFT cash-out assumptions
106
+ - pending reserves or discounts are misunderstood in value calculations
107
+ - token IDs are invalid or already burned
113
108
 
114
109
  **Postconditions**
115
- - live tier inventory and reserve state match the operator's configured collection policy
116
-
117
- ## Journey 5: Let Holders Cash Out Tier Positions
118
-
119
- **Actor:** holder.
120
-
121
- **Intent:** exit a tier position through the project's cash-out path.
122
-
123
- **Preconditions**
124
- - the holder owns one or more NFTs from the hook-enabled project
125
- - the active ruleset allows a surplus-backed exit
126
-
127
- **Main Flow**
128
- 1. Call the project's cash-out path on the terminal.
129
- 2. Let the hook participate in cash-out calculation so tier state is reflected.
130
- 3. Burn or consume the tier exposure as required by the exit path.
131
- 4. Receive the reclaim value through the terminal that holds the asset.
132
-
133
- **Failure Modes**
134
- - the project uses the hook for metadata only and the holder assumes an economic cash-out path exists
135
- - terminal behavior or reserve drift changes reclaim expectations
136
-
137
- **Postconditions**
138
- - the holder exits the tier position through the hook-aware terminal path or learns that no such economic path is active
139
-
140
- ## Journey 6: Compose A Custom Product On Top Of The Standard Hook
141
-
142
- **Actor:** integrator or downstream product team.
143
-
144
- **Intent:** build collection-specific behavior without reimplementing hook economics.
145
-
146
- **Preconditions**
147
- - the team wants custom presentation or app-layer logic
148
- - the team does not want to fork pricing, reserve, and treasury behavior
149
-
150
- **Main Flow**
151
- 1. Plug a custom resolver or wrapper into the hook.
152
- 2. Keep collection-specific behavior outside this repo.
153
- 3. Audit hook-store interactions here first, then audit the downstream wrapper.
154
-
155
- **Failure Modes**
156
- - downstream products reimplement hook behavior and drift from canonical accounting
157
- - teams blame the hook for bugs that actually live in the resolver or wrapper
158
-
159
- **Postconditions**
160
- - the custom product reuses canonical hook economics while isolating collection-specific behavior downstream
161
-
162
- ## Journey 7: Mint NFTs To The Correct Beneficiary During Cross-Chain Payments
163
-
164
- **Actor:** cross-chain payer or integrator.
165
-
166
- **Intent:** preserve the real remote beneficiary when a sucker relays a payment.
167
-
168
- **Preconditions**
169
- - a sucker or relay path pays on behalf of a remote user
170
- - relay-beneficiary metadata is encoded correctly
171
-
172
- **Main Flow**
173
- 1. The sucker calls `terminal.pay()` with relay-beneficiary metadata.
174
- 2. `_mintAndUpdateCredits` resolves the relay beneficiary when `payer == beneficiary`.
175
- 3. NFT minting and credit accounting use the resolved remote user.
176
-
177
- **Failure Modes**
178
- - relay metadata is missing or malformed
179
- - downstream systems attribute NFTs or credits to the sucker instead of the user
180
-
181
- **Postconditions**
182
- - NFT minting and credit accounting attribute the remote payment to the correct beneficiary
110
+ - NFTs burn and reclaim value follows the intended tier model
183
111
 
184
112
  ## Trust Boundaries
185
113
 
186
- - `JB721TiersHookStore` is the accounting backend and should be treated as part of the same economic surface as the hook
187
- - custom token URI resolvers are part of the trusted collection surface
188
- - core terminals remain the source of treasury accounting truth around the hook
114
+ - this repo trusts core terminals, directory checks, and pricing surfaces from `nana-core-v6`
115
+ - metadata resolvers are outside this repo but still affect user-visible trust
116
+ - the store is the main source of truth for tier lifecycle state
189
117
 
190
118
  ## Hand-Offs
191
119
 
192
- - Use [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md) for the treasury, ruleset, and permission surfaces the hook plugs into.
193
- - Use [banny-retail-v6](../banny-retail-v6/USER_JOURNEYS.md), [croptop-core-v6](../croptop-core-v6/USER_JOURNEYS.md), and [revnet-core-v6](../revnet-core-v6/USER_JOURNEYS.md) for product layers built on this hook.
120
+ - Use [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md) for the underlying terminal and accounting behavior.
121
+ - Use the downstream resolver repo when the question is about project-specific metadata or rendering.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,8 @@ contract JB721CheckpointsDeployer is IJB721CheckpointsDeployer {
37
37
  /// @param store The store that holds tier data for the hook's NFTs.
38
38
  /// @return module The newly deployed and initialized checkpoint module.
39
39
  function deploy(address hook, IJB721TiersHookStore store) external override returns (IJB721Checkpoints module) {
40
+ if (msg.sender != hook) revert JB721CheckpointsDeployer_Unauthorized();
41
+
40
42
  module = IJB721Checkpoints(
41
43
  LibClone.cloneDeterministic({implementation: IMPLEMENTATION, salt: bytes32(uint256(uint160(hook)))})
42
44
  );