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