@bananapus/721-hook-v6 0.0.10 → 0.0.12

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.
@@ -0,0 +1,151 @@
1
+ # Administration
2
+
3
+ Admin privileges and their scope in nana-721-hook-v6.
4
+
5
+ ## Roles
6
+
7
+ ### Hook Owner (JBOwnable)
8
+
9
+ - **Assigned by**: `initialize()` transfers ownership to the caller (line 285 of `JB721TiersHook.sol`). When deployed via `JB721TiersHookProjectDeployer.launchProjectFor()`, ownership is transferred to the project NFT (line 101 of `JB721TiersHookProjectDeployer.sol`), meaning the project owner controls the hook.
10
+ - **Scope**: Per-hook instance. Each cloned hook has its own independent owner.
11
+ - **Inheritance**: `JBOwnable` supports both EOA ownership and project-based ownership (owner = holder of the project's ERC-721 NFT). When ownership is transferred to a project via `transferOwnershipToProject()`, whoever owns that project NFT becomes the hook's owner.
12
+
13
+ ### Permission Operators
14
+
15
+ - **Assigned by**: The hook owner grants permissions via the `JBPermissions` contract.
16
+ - **Scope**: Per-project. Operators can be granted specific permission IDs scoped to the hook's `PROJECT_ID`.
17
+ - **How it works**: Each privileged function calls `_requirePermissionFrom(account: owner(), projectId: PROJECT_ID, permissionId: ...)`. This passes if the caller IS the owner, OR if the caller has been granted the specified permission ID by the owner for the project.
18
+
19
+ ### Terminal (Protocol-Level Caller)
20
+
21
+ - **Assigned by**: The project's `JBDirectory` configuration.
22
+ - **Scope**: Only a contract registered as a terminal for the hook's project in `JBDirectory` can call `afterPayRecordedWith()` and `afterCashOutRecordedWith()`.
23
+ - **Verification**: `DIRECTORY.isTerminalOf(projectId, IJBTerminal(msg.sender))` is checked at lines 195-197 and 236-237 of `JB721Hook.sol`.
24
+
25
+ ### Store Callers (msg.sender Trust Model)
26
+
27
+ - **Assigned by**: Implicit. `JB721TiersHookStore` trusts `msg.sender` as the hook contract.
28
+ - **Scope**: All `record*` functions in the store use `msg.sender` as the hook address key. Any contract can call the store, but state changes are scoped to `msg.sender`'s own data namespace.
29
+ - **Why this is safe**: Each hook clone has its own address, and the store keys all data by `[msg.sender][tierId]`. A malicious contract calling the store can only modify its own namespace.
30
+
31
+ ## Privileged Functions
32
+
33
+ ### JB721TiersHook
34
+
35
+ | Function | Permission ID | Checked Against | What It Does |
36
+ |----------|--------------|-----------------|--------------|
37
+ | `adjustTiers()` (line 322) | `ADJUST_721_TIERS` | `owner()` | Adds new tiers and/or soft-removes existing tiers. Sets tier split groups in JBSplits. |
38
+ | `mintFor()` (line 338) | `MINT_721` | `owner()` | Manually mints NFTs from tiers that have `allowOwnerMint` enabled. Bypasses price checks (passes `type(uint256).max` as amount). |
39
+ | `setDiscountPercentOf()` (line 389) | `SET_721_DISCOUNT_PERCENT` | `owner()` | Sets the discount percentage for a single tier. |
40
+ | `setDiscountPercentsOf()` (line 399) | `SET_721_DISCOUNT_PERCENT` | `owner()` | Batch-sets discount percentages for multiple tiers. |
41
+ | `setMetadata()` (line 420) | `SET_721_METADATA` | `owner()` | Updates baseURI, contractURI, tokenUriResolver, and/or per-tier encoded IPFS URIs. |
42
+ | `initialize()` (line 223) | None (one-time) | `PROJECT_ID == 0` check | Initializes a cloned hook. Can only be called once. Transfers ownership to caller on completion. |
43
+
44
+ ### JB721TiersHookProjectDeployer
45
+
46
+ | Function | Permission ID | Checked Against | What It Does |
47
+ |----------|--------------|-----------------|--------------|
48
+ | `launchProjectFor()` (line 74) | None | Anyone can call | Creates a new project with a 721 hook. Ownership goes to the specified `owner` address. |
49
+ | `launchRulesetsFor()` (line 115) | `QUEUE_RULESETS` + `SET_TERMINALS` | Project NFT owner | Deploys a hook and launches rulesets for an existing project. |
50
+ | `queueRulesetsOf()` (line 164) | `QUEUE_RULESETS` | Project NFT owner | Deploys a hook and queues rulesets for an existing project. |
51
+
52
+ ### JB721TiersHookDeployer
53
+
54
+ | Function | Permission ID | Checked Against | What It Does |
55
+ |----------|--------------|-----------------|--------------|
56
+ | `deployHookFor()` (line 68) | None | Anyone can call | Clones and initializes a new hook instance. Ownership starts with the deployer contract, then is transferred to `msg.sender`. |
57
+
58
+ ### JB721Hook (Abstract Base)
59
+
60
+ | Function | Required Caller | What It Does |
61
+ |----------|----------------|--------------|
62
+ | `afterPayRecordedWith()` (line 231) | Project terminal | Processes payment, mints NFTs. Verifies caller via `DIRECTORY.isTerminalOf()`. |
63
+ | `afterCashOutRecordedWith()` (line 183) | Project terminal | Burns NFTs on cash out. Verifies caller via `DIRECTORY.isTerminalOf()` and that `msg.value == 0`. |
64
+
65
+ ### JB721TiersHookStore (No Access Control -- msg.sender Keyed)
66
+
67
+ | Function | Caller | What It Does |
68
+ |----------|--------|--------------|
69
+ | `recordAddTiers()` (line 772) | Hook contract | Adds tiers to the caller's namespace. Category sort order enforced. |
70
+ | `recordRemoveTierIds()` (line 1139) | Hook contract | Marks tiers as removed in bitmap. Respects `cannotBeRemoved` flag. |
71
+ | `recordMint()` (line 1020) | Hook contract | Records mints, decrements supply, enforces price and reserve checks. |
72
+ | `recordMintReservesFor()` (line 1103) | Hook contract | Mints reserved NFTs from a tier. |
73
+ | `recordBurn()` (line 995) | Hook contract | Increments burn counter for token IDs. |
74
+ | `recordFlags()` (line 1010) | Hook contract | Sets behavioral flags for the caller's hook. |
75
+ | `recordSetTokenUriResolver()` (line 1193) | Hook contract | Sets the token URI resolver. |
76
+ | `recordSetEncodedIPFSUriOf()` (line 1187) | Hook contract | Sets the encoded IPFS URI for a tier. |
77
+ | `recordSetDiscountPercentOf()` (line 1161) | Hook contract | Updates a tier's discount percent. Enforces bounds and `cannotIncreaseDiscountPercent`. |
78
+ | `recordTransferForTier()` (line 1201) | Hook contract | Updates per-tier balance tracking on transfer. |
79
+ | `cleanTiers()` (line 726) | Anyone | Reorganizes the tier sorting linked list to skip removed tiers. Pure bookkeeping, no value at risk. |
80
+
81
+ ## Permission System
82
+
83
+ Permissions flow through two mechanisms:
84
+
85
+ 1. **JBOwnable** (`JB721TiersHook` inherits from it): The hook has a single `owner()` that can be an EOA or a Juicebox project. When owned by a project, the holder of that project's ERC-721 NFT is the effective owner.
86
+
87
+ 2. **JBPermissions** (protocol-wide permission registry): The owner can grant specific permission IDs to operator addresses. Each permission is scoped to a `(operator, account, projectId, permissionId)` tuple. The `ROOT` permission (ID 255) grants all permissions.
88
+
89
+ The `_requirePermissionFrom()` check (inherited from `JBOwnable` via `JBPermissioned`) passes if:
90
+ - `msg.sender == account` (the owner themselves), OR
91
+ - `JBPermissions.hasPermission(msg.sender, account, projectId, permissionId)` returns true.
92
+
93
+ ### Permission IDs Used
94
+
95
+ | Permission ID | Constant Name | Used By |
96
+ |--------------|---------------|---------|
97
+ | `JBPermissionIds.ADJUST_721_TIERS` | `ADJUST_721_TIERS` | `adjustTiers()` |
98
+ | `JBPermissionIds.MINT_721` | `MINT_721` | `mintFor()` |
99
+ | `JBPermissionIds.SET_721_DISCOUNT_PERCENT` | `SET_721_DISCOUNT_PERCENT` | `setDiscountPercentOf()`, `setDiscountPercentsOf()` |
100
+ | `JBPermissionIds.SET_721_METADATA` | `SET_721_METADATA` | `setMetadata()` |
101
+ | `JBPermissionIds.QUEUE_RULESETS` | `QUEUE_RULESETS` | `launchRulesetsFor()`, `queueRulesetsOf()` |
102
+ | `JBPermissionIds.SET_TERMINALS` | `SET_TERMINALS` | `launchRulesetsFor()` |
103
+
104
+ ## Immutable Configuration
105
+
106
+ The following are set at deploy/initialization time and **cannot be changed afterward**:
107
+
108
+ | Property | Set In | Scope |
109
+ |----------|--------|-------|
110
+ | `DIRECTORY` | Constructor | Which terminal/controller directory is trusted |
111
+ | `RULESETS` | Constructor | Which rulesets contract is consulted |
112
+ | `STORE` | Constructor | Which store manages tier data |
113
+ | `SPLITS` | Constructor | Which splits contract manages tier split groups |
114
+ | `METADATA_ID_TARGET` | Constructor | The address used for metadata ID derivation (original implementation address for clones) |
115
+ | `PROJECT_ID` | `initialize()` | Which project this hook belongs to |
116
+ | Pricing context (currency, decimals, prices contract) | `initialize()` | Packed into `_packedPricingContext` -- the token denomination for tier prices |
117
+ | `JB721TiersHookFlags` | `initialize()` | `noNewTiersWithReserves`, `noNewTiersWithVotes`, `noNewTiersWithOwnerMinting`, `preventOverspending`, `issueTokensForSplits` |
118
+ | Per-tier `cannotBeRemoved` | `recordAddTiers()` | Whether a tier can be soft-removed |
119
+ | Per-tier `cannotIncreaseDiscountPercent` | `recordAddTiers()` | Whether a tier's discount can be increased |
120
+ | Per-tier `reserveFrequency` | `recordAddTiers()` | How often reserve NFTs accrue |
121
+ | Per-tier `initialSupply` | `recordAddTiers()` | Maximum number of NFTs mintable from the tier |
122
+ | Per-tier `price` | `recordAddTiers()` | The base price (and cash-out weight) of NFTs in the tier |
123
+ | Per-tier `category` | `recordAddTiers()` | The category grouping for sort order |
124
+
125
+ ## Ruleset-Level Pauses
126
+
127
+ Two behaviors are controlled by the project's current ruleset metadata (packed into the 14-bit `metadata` field of `JBRulesetMetadata`), parsed by `JB721TiersRulesetMetadataResolver`:
128
+
129
+ | Bit | Flag | Effect |
130
+ |-----|------|--------|
131
+ | 0 | `transfersPaused` | When set, NFT transfers are blocked for tiers that have `transfersPausable` enabled |
132
+ | 1 | `mintPendingReservesPaused` | When set, `mintPendingReservesFor()` reverts |
133
+
134
+ These can change each ruleset cycle, giving the project owner temporary control over these behaviors without modifying the hook itself.
135
+
136
+ ## Admin Boundaries
137
+
138
+ What the hook owner **cannot** do:
139
+
140
+ - **Cannot steal or redirect existing NFTs.** The ERC-721 transfer logic is standard; the owner has no backdoor to move tokens between arbitrary addresses.
141
+ - **Cannot change tier prices after creation.** The `price` field in `JBStored721Tier` is set once in `recordAddTiers()` and never modified. Cash-out weight is always based on the original price.
142
+ - **Cannot change reserve frequency after creation.** The `reserveFrequency` is immutable per tier.
143
+ - **Cannot reduce a tier's initial supply.** Supply can only decrease through minting and burning.
144
+ - **Cannot remove a tier marked `cannotBeRemoved`.** The store enforces this in `recordRemoveTierIds()` (line 1151).
145
+ - **Cannot increase a tier's discount if `cannotIncreaseDiscountPercent` is set.** The store enforces this in `recordSetDiscountPercentOf()` (line 1176).
146
+ - **Cannot mint from tiers without `allowOwnerMint`.** The `mintFor()` function passes `isOwnerMint: true` to the store, which checks the flag (line 1060).
147
+ - **Cannot re-initialize a hook.** The `initialize()` function reverts if `PROJECT_ID != 0` (line 237).
148
+ - **Cannot change the pricing currency or decimals.** The `_packedPricingContext` is set once during initialization.
149
+ - **Cannot bypass the flag restrictions.** Once `noNewTiersWithReserves`, `noNewTiersWithVotes`, or `noNewTiersWithOwnerMinting` are set, all future tiers added via `adjustTiers()` must comply.
150
+ - **Cannot mint more reserves than the formula allows.** Reserve mints are bounded by `ceil(nonReserveMints / reserveFrequency)`.
151
+ - **Cannot modify the split groups outside of `adjustTiers()`.** Tier split groups are set during tier addition via the library; there is no separate admin function to change them directly on the hook (though the project owner could call `JBSplits.setSplitGroupsOf()` directly if they have the appropriate permission).
@@ -0,0 +1,70 @@
1
+ # nana-721-hook-v6 — Architecture
2
+
3
+ ## Purpose
4
+
5
+ NFT tier system for Juicebox V6. Allows projects to attach tiered NFT minting to payments and use NFTs as cash-out hooks. Supports on-chain and off-chain metadata, category-based sorting, and configurable pricing with discounts.
6
+
7
+ ## Contract Map
8
+
9
+ ```
10
+ src/
11
+ ├── JB721TiersHook.sol — Pay + cash-out hook that mints/burns tiered NFTs
12
+ ├── JB721TiersHookStore.sol — Tier configuration storage and pricing logic
13
+ ├── JB721TiersHookDeployer.sol — Deploys hook+store pairs (clone-based)
14
+ ├── JB721TiersHookProjectDeployer.sol — Launches project + hook in one transaction
15
+ ├── abstract/
16
+ │ ├── JB721Hook.sol — Base ERC-721 + pay/cashout hook integration
17
+ │ └── ERC721.sol — Minimal ERC-721 implementation
18
+ ├── libraries/
19
+ │ └── JB721TiersHookLib.sol — Tier packing/unpacking helpers
20
+ ├── interfaces/ — All interfaces (IJB721TiersHook, etc.)
21
+ └── structs/ — Tier config, mint context, cash-out structs
22
+ ```
23
+
24
+ ## Key Data Flows
25
+
26
+ ### NFT Minting (via Payment)
27
+ ```
28
+ User → JBMultiTerminal.pay(metadata)
29
+ → JBTerminalStore records payment
30
+ → JB721TiersHook.afterPayRecordedWith()
31
+ → Decode tier IDs from metadata
32
+ → For each tier:
33
+ → Validate: not removed, not paused, supply available
34
+ → Check price (with optional discount)
35
+ → Mint NFT to beneficiary
36
+ → Leftover amount optionally mints best-available tiers
37
+ ```
38
+
39
+ ### NFT Cash Out
40
+ ```
41
+ Holder → JBMultiTerminal.cashOutTokensOf()
42
+ → JB721TiersHook.afterCashOutRecordedWith()
43
+ → Burn specified NFT token IDs
44
+ → Each NFT's cash-out weight contributes to reclaim amount
45
+ → Weight = tier.initialQuantity * tier.price (or custom)
46
+ ```
47
+
48
+ ### Tier Management
49
+ ```
50
+ Owner → JB721TiersHook.adjustTiers()
51
+ → Add new tiers (must be sorted by category)
52
+ → Remove existing tiers (flags, doesn't delete)
53
+ ```
54
+
55
+ ## Extension Points
56
+
57
+ | Point | Interface | Purpose |
58
+ |-------|-----------|---------|
59
+ | Token URI resolver | `IJB721TokenUriResolver` | Custom metadata rendering |
60
+ | Pay hook | `IJBPayHook` | Called after payment recorded |
61
+ | Cash out hook | `IJBCashOutHook` | Called during cash out |
62
+
63
+ ## Dependencies
64
+ - `@bananapus/core-v6` — Core protocol interfaces
65
+ - `@bananapus/ownable-v6` — JB-aware ownership
66
+ - `@bananapus/address-registry-v6` — Deterministic deploy addresses
67
+ - `@bananapus/permission-ids-v6` — Permission constants
68
+ - `@openzeppelin/contracts` — ERC-721 utils, Ownable
69
+ - `@prb/math` — mulDiv
70
+ - `solady` — LibString, Base64
package/README.md CHANGED
@@ -222,7 +222,7 @@ Each pay/cash out hook can then execute custom behavior based on the custom data
222
222
  A project using a 721 tiers hook can specify any number of NFT tiers (up to 65,535 total).
223
223
 
224
224
  - NFT tiers can be removed by the project owner as long as they are not locked (`cannotBeRemoved`). After removing tiers, call `cleanTiers()` on the store to optimize tier iteration.
225
- - NFT tiers can be added by the project owner as long as they respect the hook's `flags`. The flags specify if newly added tiers can have votes (voting units), if new tiers can have non-zero reserve frequencies, if new tiers can allow on-demand minting by the project's owner, and if overspending is allowed.
225
+ - NFT tiers can be added by the project owner as long as they respect the hook's `flags`. Tiers must be sorted by category in ascending order — the store reverts with `JB721TiersHookStore_InvalidCategorySortOrder` if not. The flags specify if newly added tiers can have votes (voting units), if new tiers can have non-zero reserve frequencies, if new tiers can allow on-demand minting by the project's owner, and if overspending is allowed.
226
226
 
227
227
  Each tier has the following properties:
228
228
 
@@ -235,8 +235,8 @@ Each tier has the following properties:
235
235
  - Voting units (optional). By default, each NFT's voting power equals its tier price. If `useVotingUnits` is true, a custom `votingUnits` value is used instead.
236
236
  - A flag to specify whether the NFTs in the tier can always be transferred, or if transfers can be paused depending on the project's ruleset.
237
237
  - A flag to specify whether the contract's owner can mint NFTs from the tier on-demand.
238
- - A split percent and a set of splits (optional). Each tier can route a percentage of its mint price to configured split recipients (other projects, addresses, etc.) every time an NFT from the tier is purchased. The remaining funds stay in the project's balance. The `splitPercent` is out of `JBConstants.SPLITS_TOTAL_PERCENT` (1,000,000,000).
239
- - A set of flags which restrict tiers added in the future (the votes/reserved frequency/on-demand minting/overspending flags noted above).
238
+ - A split percent and a set of splits (optional). Each tier can route a percentage of its mint price to configured split recipients (other projects, addresses, etc.) every time an NFT from the tier is purchased. The remaining funds stay in the project's balance. The `splitPercent` is out of `JBConstants.SPLITS_TOTAL_PERCENT` (1,000,000,000). When splits are active, the hook adjusts the returned weight so the terminal only mints tokens proportional to the amount that actually enters the project treasury (e.g., a 50% split on a 1 ETH payment results in half the normal token issuance). This weight adjustment can be disabled with the `issueTokensForSplits` flag, which gives payers full token credit regardless of where the funds go.
239
+ - A set of flags which restrict tiers added in the future (the votes/reserved frequency/on-demand minting/overspending/issueTokensForSplits flags noted above).
240
240
 
241
241
  Additional notes:
242
242
 
package/RISKS.md ADDED
@@ -0,0 +1,311 @@
1
+ # nana-721-hook-v6 -- Risks
2
+
3
+ Deep implementation-level risk analysis covering all contracts in the 721 tiered hook system.
4
+
5
+ ---
6
+
7
+ ## Trust Assumptions
8
+
9
+ 1. **Project Owner / Hook Owner** -- Can adjust tiers (add/remove), set metadata, set discount percent, manually mint from `allowOwnerMint` tiers, and configure hook flags. Full control over NFT economics within the boundaries enforced by immutable per-tier flags.
10
+ 2. **Core Protocol (JBMultiTerminal)** -- The hook trusts that `afterPayRecordedWith()` and `afterCashOutRecordedWith()` are only called by a registered terminal. Verification at `JB721Hook.sol` lines 194-197 and 236-237 via `DIRECTORY.isTerminalOf()`.
11
+ 3. **JBDirectory** -- Trusted to correctly report terminal registrations. If compromised, arbitrary addresses could call pay/cashout hooks.
12
+ 4. **JBSplits** -- Trusted to store and return correct split configurations for tier split groups. The hook delegates split group management to this contract.
13
+ 5. **Token URI Resolver** -- If set, controls all NFT metadata rendering. Cannot affect funds but can misrepresent NFT properties. Set via `SET_721_METADATA` permission.
14
+ 6. **Store Contract** -- `JB721TiersHookStore` manages all tier state using a `msg.sender`-keyed trust model. The hook delegates pricing, supply, and reserve logic to the store.
15
+ 7. **JBPrices** -- If a prices contract is configured (for cross-currency payments), the hook trusts it for price conversion. A reverting price feed will block all payments in non-native currencies.
16
+
17
+ ---
18
+
19
+ ## Risk Analysis
20
+
21
+ ### R-1: Default Reserve Beneficiary Global Overwrite
22
+
23
+ - **Severity**: MEDIUM
24
+ - **Location**: `JB721TiersHookStore.sol` lines 890-897 (`recordAddTiers`)
25
+ - **Description**: When adding a tier with `useReserveBeneficiaryAsDefault = true`, the `defaultReserveBeneficiaryOf[msg.sender]` is overwritten. This silently redirects reserve mints for ALL existing tiers that rely on the default (i.e., tiers without a tier-specific `_reserveBeneficiaryOf` entry).
26
+ - **Attack scenario**: A project owner adds a new tier with `useReserveBeneficiaryAsDefault = true` and a different beneficiary address. Existing tiers whose reserves were flowing to the old default now silently redirect to the new address.
27
+ - **Tested**: YES -- `test/regression/L34_ReserveBeneficiaryOverwrite.t.sol` explicitly tests this behavior with 3 scenarios.
28
+ - **Mitigation**: Documented via `@dev WARNING` in the store. Callers should use per-tier beneficiaries (`useReserveBeneficiaryAsDefault = false`) when adding tiers to hooks with existing tiers.
29
+
30
+ ### R-2: 100% Discount Enables Free Minting With Full Cash-Out Weight
31
+
32
+ - **Severity**: HIGH
33
+ - **Location**: `JB721TiersHookStore.sol` lines 1069-1071 (`recordMint`), `JB721Constants.sol` line 6
34
+ - **Description**: Setting `discountPercent = 200` (the `DISCOUNT_DENOMINATOR`) makes the effective mint price zero: `price - mulDiv(price, 200, 200) = 0`. However, the cash-out weight is always based on the original `storedTier.price` (lines 415-417 of `cashOutWeightOf`), not the discounted price. An attacker granted discount-setting permission could set 100% discount, mint for free, then cash out at full weight.
35
+ - **Attack scenario**: Compromised operator with `SET_721_DISCOUNT_PERCENT` permission sets discount to 200 on a high-value tier. Mints for free. Burns NFTs via cash-out to extract funds proportional to the original price.
36
+ - **Tested**: YES -- `test/721HookAttacks.t.sol` tests 2 and 3 cover discount behavior and `cannotIncreaseDiscountPercent` enforcement.
37
+ - **Mitigation**: Use `cannotIncreaseDiscountPercent = true` on tiers where this is a concern. Only grant `SET_721_DISCOUNT_PERCENT` to trusted addresses. The `discountPercent > storedTier.discountPercent && cannotIncreaseDiscountPercent` check at store line 1176 enforces the immutable cap.
38
+
39
+ ### R-3: Tier Split Fund Distribution -- Reentrancy Surface
40
+
41
+ - **Severity**: MEDIUM
42
+ - **Location**: `JB721TiersHookLib.sol` lines 265-285 (`_distributeSingleSplit`) and lines 312-315 (`_sendPayoutToSplit`)
43
+ - **Description**: During `afterPayRecordedWith()`, if tiers have `splitPercent > 0`, the hook distributes forwarded funds to split beneficiaries. For native token splits, this involves a low-level `.call{value: amount}("")` to the beneficiary address (line 314). This is an external call to an untrusted address during payment processing.
44
+ - **Reentrancy path**: `afterPayRecordedWith` -> `_processPayment` -> `distributeAll` -> `_distributeSingleSplit` -> `_sendPayoutToSplit` -> `beneficiary.call{value}` -- the beneficiary could reenter the hook.
45
+ - **Why it is mitigated**: The NFT mint (`_mintAll`) happens BEFORE split distribution (line 646 vs. line 678 in `JB721TiersHook.sol`). The store's `recordMint` has already decremented supply. Pay credits are already updated. A reentrant call to `afterPayRecordedWith` would require terminal authorization and would process as a separate independent payment.
46
+ - **Tested**: PARTIALLY -- Split distribution is tested in `test/unit/tierSplitRouting_Unit.t.sol` and `test/regression/L36_SplitNoBeneficiary.t.sol`, but no explicit reentrancy test exists for the `.call{value}` path.
47
+ - **Mitigation**: State is settled before external calls. The terminal authorization check prevents casual reentrancy. No explicit `ReentrancyGuard` is used.
48
+
49
+ ### R-4: Split Beneficiary With No Recipient -- Fund Routing
50
+
51
+ - **Severity**: LOW (fixed)
52
+ - **Location**: `JB721TiersHookLib.sol` lines 300-323 (`_sendPayoutToSplit`)
53
+ - **Description**: A split with `projectId == 0` and `beneficiary == address(0)` previously had undefined behavior. The current implementation returns `false`, causing the calling function to keep those funds in `leftoverAmount`, which is then routed to the project's balance via `_addToBalance` (line 282-284).
54
+ - **Tested**: YES -- `test/regression/L36_SplitNoBeneficiary.t.sol` verifies funds are routed to the project's balance.
55
+ - **Mitigation**: Fixed by design. Funds are never silently lost.
56
+
57
+ ### R-5: Category Sort Order Enforcement -- Off-Chain Burden
58
+
59
+ - **Severity**: LOW
60
+ - **Location**: `JB721TiersHookStore.sol` lines 820-822 (`recordAddTiers`)
61
+ - **Description**: Tiers must be sorted by category when added. The store reverts `InvalidCategorySortOrder` if violated. This is an on-chain enforcement that protects invariants, but the error is difficult to debug for integrators.
62
+ - **Tested**: YES -- Implicit in all tier creation tests.
63
+ - **Mitigation**: Validate tier ordering off-chain before submitting transactions.
64
+
65
+ ### R-6: Soft Removal Preserves Cash-Out Weight
66
+
67
+ - **Severity**: LOW (by design)
68
+ - **Location**: `JB721TiersHookStore.sol` lines 1139-1156 (`recordRemoveTierIds`), lines 460-478 (`totalCashOutWeight`)
69
+ - **Description**: Removing a tier only marks it in a bitmap (`_removedTiersBitmapWordOf`). The tier data (`_storedTierOf`) is not deleted. `totalCashOutWeight()` iterates by `maxTierIdOf` (line 462-466), not by the sorted tier list, so removed tiers' minted NFTs continue to contribute to cash-out weight. Existing NFTs from removed tiers retain their full cash-out value.
70
+ - **Attack scenario**: None -- this is intentional. Prevents retroactive value destruction of already-minted NFTs.
71
+ - **Tested**: YES -- `test/721HookAttacks.t.sol` test 5 explicitly verifies cash-out weight is preserved after tier removal.
72
+ - **Mitigation**: By design. Tier removal prevents new mints, not cash-outs.
73
+
74
+ ### R-7: Pay Credit Accumulation -- Payer vs. Beneficiary Separation
75
+
76
+ - **Severity**: LOW
77
+ - **Location**: `JB721TiersHook.sol` lines 604-616 (`_processPayment`)
78
+ - **Description**: Pay credits are tracked per beneficiary, not per payer. When `payer != beneficiary`, the payer's existing credits are NOT applied to the mint. Credits from the payment's leftover are stored for the beneficiary. This means a payer who directs payment to another beneficiary loses access to any overspend -- it becomes the beneficiary's credit.
79
+ - **Tested**: PARTIALLY -- Pay credit tests exist in `test/unit/pay_Unit.t.sol` but the payer-beneficiary divergence case may not be exhaustively covered.
80
+ - **Mitigation**: This is documented behavior. Payers should be aware that credits accrue to the beneficiary.
81
+
82
+ ### R-8: Reserve Supply Protection -- Post-Mint Check
83
+
84
+ - **Severity**: MEDIUM (fixed)
85
+ - **Location**: `JB721TiersHookStore.sol` lines 1079-1095 (`recordMint`)
86
+ - **Description**: The store decrements `remainingSupply` BEFORE checking whether enough supply remains for pending reserves (lines 1081-1086). After decrementing, it checks `remainingSupply < _numberOfPendingReservesFor(...)`. This is the correct order because `_numberOfPendingReservesFor` needs to see the post-mint state (the new non-reserve mint increases pending reserves). Without this ordering, the last available slot could be consumed by a paid mint, making pending reserves unmintable.
87
+ - **Tested**: YES -- `test/unit/M6_TierSupplyCheck.t.sol` provides 4 targeted tests for this edge case with varying supply and reserve frequency combinations.
88
+ - **Mitigation**: The decrement-then-check pattern is intentional and correct. The test proves a 7th paid mint correctly reverts when it would steal a reserve slot.
89
+
90
+ ### R-9: Price Feed Dependency -- DoS Vector
91
+
92
+ - **Severity**: MEDIUM
93
+ - **Location**: `JB721TiersHookLib.sol` lines 121-138 (`normalizePaymentValue`)
94
+ - **Description**: When the hook's pricing currency differs from the payment currency and a `JBPrices` contract is configured, the hook calls `prices.pricePerUnitOf()` to normalize the payment value. If the price feed reverts (e.g., stale Chainlink data, sequencer down on L2), all payments in non-native currencies will revert. This is a DoS vector but not a fund-loss vector.
95
+ - **Tested**: NOT directly tested for the revert-on-stale-feed scenario.
96
+ - **Mitigation**: If `address(prices) == address(0)`, payments in non-matching currencies silently return `(0, false)` and the hook skips minting (line 600). Projects using cross-currency pricing should monitor feed health.
97
+
98
+ ### R-10: Large Tier Array Gas Exhaustion
99
+
100
+ - **Severity**: LOW
101
+ - **Location**: `JB721TiersHookStore.sol` lines 253-333 (`tiersOf`), lines 358-380 (`votingUnitsOf`), lines 391-400 (`balanceOf`), lines 338-349 (`totalSupplyOf`), lines 460-478 (`totalCashOutWeight`)
102
+ - **Description**: Several view functions iterate from `maxTierIdOf` down to 1. With many tiers (up to 65,535 theoretically), these functions could exceed block gas limits. `totalCashOutWeight()` iterates ALL tier IDs (not just active ones), making it the most gas-intensive.
103
+ - **Attack scenario**: An attacker with `ADJUST_721_TIERS` permission adds thousands of tiers, causing `totalCashOutWeight()` to become uncallable. Since `beforeCashOutRecordedWith()` calls `totalCashOutWeight()` (line 115 of `JB721Hook.sol`), this could block all NFT cash-outs.
104
+ - **Tested**: `test/721HookAttacks.t.sol` test 10 tests `maxSupplyTier_noOverflow` but does not test gas limits with many tiers.
105
+ - **Mitigation**: The `maxTierIdOf` is capped at `type(uint16).max` (65,535) by the store (line 781-783). Keep tier count manageable in practice.
106
+
107
+ ### R-11: Metadata Decode Failure -- Silent Skip
108
+
109
+ - **Severity**: LOW
110
+ - **Location**: `JB721TiersHook.sol` lines 622-648 (`_processPayment`)
111
+ - **Description**: If `JBMetadataResolver.getDataFor()` returns `found = false` (line 627), the hook skips NFT minting entirely. If `preventOverspending` is also false (the default), the payment goes through and the entire amount becomes pay credits for the beneficiary. No NFTs are minted, but the payment is not reverted.
112
+ - **Tested**: PARTIALLY -- `test/721HookAttacks.t.sol` test 6 tests invalid tier IDs with `preventOverspending = true`, but the silent-skip path (malformed metadata + `preventOverspending = false`) lacks a dedicated test.
113
+ - **Mitigation**: Use `JBMetadataResolver` for encoding. Set `preventOverspending = true` if unintended credit accumulation is a concern.
114
+
115
+ ### R-12: ERC-721 Receiver Callback -- Potential DoS on Mint
116
+
117
+ - **Severity**: LOW
118
+ - **Location**: `ERC721.sol` lines 466-483 (`_checkOnERC721Received`), `JB721TiersHook.sol` line 579 (`_mint`)
119
+ - **Description**: The hook uses `_mint()` (not `_safeMint()`), so the `onERC721Received` callback is NOT triggered during minting. However, `safeTransferFrom` and `transferFrom` do trigger the `_update` override which calls `STORE.recordTransferForTier()` -- this is a cross-contract call during every transfer.
120
+ - **Tested**: Not specifically for DoS via receiver callbacks.
121
+ - **Mitigation**: `_mint()` avoids the receiver callback, preventing mint-time DoS. Transfers use the standard ERC-721 flow.
122
+
123
+ ### R-13: Token URI Resolver -- Arbitrary External Call
124
+
125
+ - **Severity**: LOW
126
+ - **Location**: `JB721TiersHookLib.sol` lines 396-407 (`resolveTokenURI`), store line 551-556 (`_getTierFrom`)
127
+ - **Description**: If a `tokenUriResolver` is set, `tokenURI()` and `tiersOf(..., includeResolvedUri=true)` make external calls to the resolver contract. A malicious resolver could revert (blocking metadata reads) or return misleading data. Since these are view functions, there is no fund risk, but integrators (marketplaces, frontends) could be affected.
128
+ - **Tested**: NOT directly tested for malicious resolver behavior.
129
+ - **Mitigation**: Only the hook owner (via `SET_721_METADATA`) can set the resolver. The resolver cannot affect fund flows.
130
+
131
+ ### R-14: Split Distribution -- Terminal Pay/AddToBalance External Calls
132
+
133
+ - **Severity**: MEDIUM
134
+ - **Location**: `JB721TiersHookLib.sol` lines 340-377 (`_terminalAddToBalance`, `_terminalPay`)
135
+ - **Description**: When distributing split funds to projects, the library calls `terminal.pay()` or `terminal.addToBalanceOf()` on the target project's primary terminal. These are external calls to potentially untrusted terminal contracts. For ERC-20 tokens, `SafeERC20.forceApprove()` is used before the call (lines 353, 373).
136
+ - **Reentrancy path**: The target terminal could call back into the hook during `pay()` processing. However, since the hook's own state (supply, credits) is already settled before distribution begins, reentrancy through this path cannot double-mint or corrupt state.
137
+ - **Tested**: `test/unit/tierSplitRouting_Unit.t.sol` tests split distribution with mocked terminals.
138
+ - **Mitigation**: State is fully settled before distribution. `SafeERC20` handles token approval safely.
139
+
140
+ ### R-15: Initialize Front-Running on Deterministic Clones
141
+
142
+ - **Severity**: LOW
143
+ - **Location**: `JB721TiersHookDeployer.sol` lines 78-84 (`deployHookFor`)
144
+ - **Description**: When deploying with a salt (deterministic address via `LibClone.cloneDeterministic`), the salt is derived from `keccak256(abi.encode(_msgSender(), salt))`. An attacker who knows the deployer's address and salt could front-run the deployment. However, since `initialize()` is called in the same transaction as the clone creation (line 88-97), and ownership is transferred to `_msgSender()` (line 100), the front-runner would end up with a hook they do not own.
145
+ - **Tested**: NOT directly tested for front-running scenarios.
146
+ - **Mitigation**: The sender-specific salt derivation prevents third-party address prediction. The atomic deploy+initialize+transfer pattern prevents initialization hijacking.
147
+
148
+ ### R-16: cleanTiers() -- Permissionless Tier List Reorganization
149
+
150
+ - **Severity**: LOW
151
+ - **Location**: `JB721TiersHookStore.sol` lines 726-763 (`cleanTiers`)
152
+ - **Description**: `cleanTiers()` is callable by anyone. It reorganizes the sorted tier linked list to skip removed tiers. While this is pure bookkeeping (no value at risk), a griefing attacker could call it repeatedly to waste gas or to ensure the tier list is in a particular order.
153
+ - **Tested**: NOT specifically tested for griefing.
154
+ - **Mitigation**: The function is idempotent and only modifies the `_tierIdAfter` mapping. No economic impact.
155
+
156
+ ### R-17: Voting Units Manipulation via Tier Addition
157
+
158
+ - **Severity**: LOW
159
+ - **Location**: `JB721TiersHookStore.sol` lines 829-835 (`recordAddTiers`)
160
+ - **Description**: If `noNewTiersWithVotes` is NOT set, an owner can add new tiers with custom `votingUnits`. This could allow governance manipulation by creating tiers with high voting power relative to their price. If `useVotingUnits = false`, voting power defaults to the tier's price, which could also be set to artificially high values.
161
+ - **Tested**: PARTIALLY -- The flag enforcement is tested in the store invariant tests, but governance manipulation scenarios are not explicitly tested.
162
+ - **Mitigation**: Set `noNewTiersWithVotes = true` at initialization for projects where governance voting power is economically significant.
163
+
164
+ ### R-18: mulDiv Rounding in Discount Application
165
+
166
+ - **Severity**: LOW
167
+ - **Location**: `JB721TiersHookStore.sol` line 1070 (`recordMint`)
168
+ - **Description**: The discounted price is calculated as `price - mulDiv(price, discountPercent, DISCOUNT_DENOMINATOR)`. The `mulDiv` from PRBMath rounds down, meaning the discount amount is slightly less than the mathematical result, and the effective price is slightly higher than expected. For small prices, this could mean the discount has no effect (e.g., `price=1, discountPercent=1` -> `mulDiv(1, 1, 200) = 0`).
169
+ - **Tested**: PARTIALLY -- Discount tests exist in `test/721HookAttacks.t.sol` tests 2-3, but edge cases with very small prices are not explicitly tested.
170
+ - **Mitigation**: Rounding always favors the protocol (charges slightly more). Economically insignificant for typical tier prices.
171
+
172
+ ### R-19: Transfer Pause Bypass via Tier Configuration
173
+
174
+ - **Severity**: LOW
175
+ - **Location**: `JB721TiersHook.sol` lines 715-727 (`_update`)
176
+ - **Description**: Transfer pausing only applies to tiers with `transfersPausable = true` AND requires the current ruleset's metadata to have `transfersPaused` set (bit 0). If a tier is created with `transfersPausable = false`, its NFTs can never be paused regardless of ruleset settings. This is by design but could surprise project owners who expect blanket pause capability.
177
+ - **Tested**: PARTIALLY -- The transfer pause logic is tested but not for the interaction between tier-level and ruleset-level flags.
178
+ - **Mitigation**: Set `transfersPausable = true` on all tiers where pause capability is desired.
179
+
180
+ ---
181
+
182
+ ## MEV / Frontrunning Vectors
183
+
184
+ ### F-1: Tier Addition Frontrunning
185
+
186
+ An attacker who sees a pending `adjustTiers()` transaction could front-run it with payments to mint NFTs from existing tiers before the new (possibly cheaper or more favorable) tiers are added. This is standard mempool visibility risk and not specific to this hook.
187
+
188
+ ### F-2: Discount Change Frontrunning
189
+
190
+ When the owner calls `setDiscountPercentOf()` to increase a discount, a frontrunner could observe the pending transaction and mint before the discount takes effect (paying the higher price). Conversely, when decreasing a discount, a frontrunner could mint at the current lower price before the increase takes effect.
191
+
192
+ ### F-3: Cash-Out Sandwich
193
+
194
+ An attacker could observe a large cash-out and front-run it with their own cash-out to claim a larger share of the surplus (since `totalCashOutWeight` decreases after each burn). This is a standard bonding curve risk inherited from the core protocol.
195
+
196
+ ---
197
+
198
+ ## Reentrancy Analysis
199
+
200
+ ### External Call Map
201
+
202
+ 1. **`afterPayRecordedWith()`** -> `_processPayment()`:
203
+ - `STORE.recordMint()` (cross-contract, trusted)
204
+ - `_mint()` (internal, no receiver callback)
205
+ - `JB721TiersHookLib.distributeAll()` via DELEGATECALL:
206
+ - `SPLITS.splitsOf()` (cross-contract, trusted)
207
+ - `split.beneficiary.call{value}()` (untrusted external call)
208
+ - `terminal.pay()` (cross-contract, semi-trusted)
209
+ - `terminal.addToBalanceOf()` (cross-contract, semi-trusted)
210
+ - `SafeERC20.safeTransfer()` (token transfer)
211
+ - `SafeERC20.forceApprove()` (token approval)
212
+
213
+ 2. **`afterCashOutRecordedWith()`**:
214
+ - `_ownerOf()` (internal read)
215
+ - `_burn()` -> `_update()` (internal) -> `STORE.recordTransferForTier()` (cross-contract, trusted)
216
+ - `STORE.recordBurn()` (cross-contract, trusted)
217
+
218
+ 3. **`adjustTiers()`**:
219
+ - `JB721TiersHookLib.adjustTiersFor()` via DELEGATECALL:
220
+ - `STORE.recordRemoveTierIds()` (cross-contract, trusted)
221
+ - `STORE.recordAddTiers()` (cross-contract, trusted)
222
+ - `SPLITS.setSplitGroupsOf()` (cross-contract, trusted)
223
+
224
+ 4. **`mintFor()`**:
225
+ - `STORE.recordMint()` (cross-contract, trusted)
226
+ - `_mint()` (internal, no receiver callback)
227
+
228
+ 5. **`mintPendingReservesFor()`**:
229
+ - `RULESETS.currentOf()` (cross-contract, trusted)
230
+ - `STORE.recordMintReservesFor()` (cross-contract, trusted)
231
+ - `STORE.reserveBeneficiaryOf()` (cross-contract, trusted)
232
+ - `_mint()` (internal, no receiver callback)
233
+
234
+ ### Reentrancy Assessment
235
+
236
+ **No explicit reentrancy guard** (`ReentrancyGuard`) is used. Protection relies on state ordering:
237
+
238
+ - All `STORE.record*` calls (state mutations) happen BEFORE any untrusted external calls.
239
+ - `_mint()` uses the non-safe variant, avoiding `onERC721Received` callbacks.
240
+ - The only untrusted external calls (split beneficiary `.call{value}`, terminal `.pay()`, terminal `.addToBalanceOf()`) happen after all state is settled.
241
+ - A reentering call would need terminal authorization (`DIRECTORY.isTerminalOf`) and would be processed as an independent operation with its own state changes.
242
+
243
+ **Risk level**: LOW. The state-before-interaction pattern is consistently applied.
244
+
245
+ ---
246
+
247
+ ## Test Coverage Summary
248
+
249
+ ### By Risk
250
+
251
+ | Risk | Has Direct Test | Invariant Covered | Fuzz Tested |
252
+ |------|:-:|:-:|:-:|
253
+ | R-1 Reserve beneficiary overwrite | YES (L34) | NO | NO |
254
+ | R-2 100% discount free mint | YES (attacks t2-t3) | NO | NO |
255
+ | R-3 Split distribution reentrancy | PARTIAL | NO | NO |
256
+ | R-4 Split no beneficiary | YES (L36) | NO | NO |
257
+ | R-5 Category sort order | YES (implicit) | NO | YES (handler) |
258
+ | R-6 Soft removal cash-out weight | YES (attacks t5) | YES (INV-721-2) | YES |
259
+ | R-7 Pay credit payer/beneficiary | PARTIAL | YES (INV-721-3) | YES |
260
+ | R-8 Reserve supply protection | YES (M6) | YES (INV-721-1,4) | YES |
261
+ | R-9 Price feed DoS | NO | NO | NO |
262
+ | R-10 Large tier array gas | PARTIAL | NO | NO |
263
+ | R-11 Metadata decode silent skip | PARTIAL | NO | NO |
264
+ | R-12 ERC-721 receiver DoS | NO | NO | NO |
265
+ | R-13 Token URI resolver abuse | NO | NO | NO |
266
+ | R-14 Terminal call reentrancy | PARTIAL (mocked) | NO | NO |
267
+ | R-15 Initialize front-running | NO | NO | NO |
268
+ | R-16 cleanTiers griefing | NO | NO | NO |
269
+ | R-17 Voting units manipulation | PARTIAL | NO | NO |
270
+ | R-18 mulDiv rounding | PARTIAL | NO | NO |
271
+ | R-19 Transfer pause bypass | PARTIAL | NO | NO |
272
+
273
+ ### Test Suite Overview
274
+
275
+ | Category | File Count | What It Covers |
276
+ |----------|:----------:|----------------|
277
+ | Unit tests | 9 | `adjustTier`, `deployer`, `getters/constructor`, `mintFor/mintReservesFor`, `pay`, `redeem`, `tierSplitRouting`, `JBBitmap`, `JBIpfsDecoder` |
278
+ | Invariant tests | 2 + 2 handlers | `TierLifecycleInvariant` (6 invariants), `TieredHookStoreInvariant` (3 invariants) |
279
+ | Attack tests | 1 | 10 adversarial scenarios (zero price, max discount, reserves, supply, permissions, overflow) |
280
+ | Regression tests | 3 | L34 (reserve beneficiary overwrite), L35 (cached tier lookup), L36 (split no beneficiary) |
281
+ | E2E tests | 1 | Full lifecycle with deployer, payments, cash-outs |
282
+ | Fork tests | 1 | Deployment on live chain state |
283
+ | Metadata unit | 2 | `JB721TiersRulesetMetadataResolver`, `M6_TierSupplyCheck` |
284
+
285
+ ### Notable Coverage Gaps
286
+
287
+ 1. **No reentrancy test** for the split distribution `.call{value}` path.
288
+ 2. **No price feed failure test** for cross-currency payment scenarios.
289
+ 3. **No gas limit test** for operations with many tiers (hundreds+).
290
+ 4. **No test** for token URI resolver returning malicious/reverting data.
291
+ 5. **No test** for `initialize()` front-running on deterministic clones.
292
+ 6. **No explicit fuzz test** for discount percent edge cases with very small prices.
293
+ 7. **No cross-terminal reentry test** where a split's `terminal.pay()` triggers a callback into the hook.
294
+
295
+ ---
296
+
297
+ ## External Dependencies
298
+
299
+ | Dependency | What It Provides | Risk If Compromised |
300
+ |------------|-----------------|---------------------|
301
+ | `JBMultiTerminal` | Calls hook during pay/cashout | Arbitrary pay/cashout hook invocations |
302
+ | `JBDirectory` | Terminal registration lookups | Could allow unauthorized callers |
303
+ | `JBController` | Project lifecycle management | Hook deployment flow relies on it |
304
+ | `JBPermissions` | Permission checks for privileged functions | Could grant unauthorized access |
305
+ | `JBRulesets` | Current ruleset for pause checks | Could disable pause protections |
306
+ | `JBSplits` | Tier split group storage and retrieval | Could return incorrect splits |
307
+ | `JBPrices` | Cross-currency price conversion | Could return wrong prices or revert (DoS) |
308
+ | `JBOwnable` | Ownership model (EOA or project) | Ownership transfer mechanics |
309
+ | OpenZeppelin `ERC2771Context` | Meta-transaction support | Trusted forwarder could spoof `msg.sender` |
310
+ | PRBMath `mulDiv` | Fixed-point arithmetic | Rounding errors (bounded) |
311
+ | Solady `LibClone` | Minimal proxy cloning | Clone implementation bugs |