@bananapus/721-hook-v6 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/ADMINISTRATION.md +38 -11
  2. package/ARCHITECTURE.md +53 -99
  3. package/AUDIT_INSTRUCTIONS.md +84 -383
  4. package/CHANGELOG.md +71 -0
  5. package/README.md +79 -225
  6. package/RISKS.md +28 -11
  7. package/SKILLS.md +29 -296
  8. package/STYLE_GUIDE.md +57 -18
  9. package/USER_JOURNEYS.md +57 -501
  10. package/package.json +1 -1
  11. package/references/operations.md +28 -0
  12. package/references/runtime.md +32 -0
  13. package/script/Deploy.s.sol +5 -4
  14. package/src/JB721TiersHook.sol +1 -1
  15. package/src/JB721TiersHookDeployer.sol +1 -1
  16. package/src/JB721TiersHookProjectDeployer.sol +1 -1
  17. package/src/JB721TiersHookStore.sol +23 -17
  18. package/src/libraries/JB721Constants.sol +1 -1
  19. package/src/libraries/JB721TiersRulesetMetadataResolver.sol +1 -1
  20. package/src/libraries/JBBitmap.sol +1 -1
  21. package/src/libraries/JBIpfsDecoder.sol +1 -1
  22. package/src/structs/JB721Tier.sol +5 -11
  23. package/src/structs/JB721TierConfig.sol +5 -20
  24. package/src/structs/JB721TierConfigFlags.sol +26 -0
  25. package/src/structs/JB721TierFlags.sol +17 -0
  26. package/test/721HookAttacks.t.sol +22 -17
  27. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +19 -14
  28. package/test/Fork.t.sol +69 -54
  29. package/test/TestAuditGaps.sol +73 -56
  30. package/test/TestSafeTransferReentrancy.t.sol +4 -4
  31. package/test/TestVotingUnitsLifecycle.t.sol +11 -11
  32. package/test/audit/CodexPayCreditsBypassTierSplits.t.sol +10 -7
  33. package/test/audit/CodexSplitCreditsMismatch.t.sol +10 -7
  34. package/test/fork/ERC20CashOutFork.t.sol +37 -28
  35. package/test/fork/ERC20TierSplitFork.t.sol +28 -21
  36. package/test/fork/IssueTokensForSplitsFork.t.sol +10 -7
  37. package/test/invariants/handlers/TierLifecycleHandler.sol +10 -7
  38. package/test/invariants/handlers/TierStoreHandler.sol +10 -7
  39. package/test/regression/ProjectDeployerRulesets.t.sol +10 -7
  40. package/test/regression/ReserveBeneficiaryOverwrite.t.sol +6 -6
  41. package/test/unit/AuditFixes_Unit.t.sol +37 -28
  42. package/test/unit/adjustTier_Unit.t.sol +268 -202
  43. package/test/unit/getters_constructor_Unit.t.sol +20 -14
  44. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +2 -2
  45. package/test/unit/pay_Unit.t.sol +1 -1
  46. package/CHANGE_LOG.md +0 -359
@@ -1,413 +1,114 @@
1
- # Audit Instructions -- nana-721-hook-v6
1
+ # Audit Instructions
2
2
 
3
- You are auditing the Juicebox V6 tiered NFT hook system. This hook allows Juicebox projects to sell tiered ERC-721 NFTs via payments and let holders cash out NFTs to reclaim funds. Your goal is to find bugs that lose funds, break invariants, or enable unauthorized access.
3
+ This repo is the tiered ERC-721 hook system for Juicebox payments and NFT cash-outs. Audit it as a shared primitive used by many other repos.
4
4
 
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.
5
+ ## Objective
6
6
 
7
- ## Compiler and Version Info
7
+ Find issues that:
8
+ - let users mint tiers more cheaply than intended
9
+ - over-mint, under-burn, or miscount reserves, credits, or supply
10
+ - route split funds incorrectly or let split paths distort token issuance
11
+ - let NFT cash-outs reclaim more value than intended
12
+ - corrupt shared store state across different hook instances
8
13
 
9
- | Setting | Value |
10
- |---------|-------|
11
- | Solidity version | 0.8.28 |
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) |
14
+ ## Scope
17
15
 
18
- Source: [`foundry.toml`](./foundry.toml)
16
+ In scope:
17
+ - `src/JB721TiersHook.sol`
18
+ - `src/JB721TiersHookStore.sol`
19
+ - `src/JB721TiersHookDeployer.sol`
20
+ - `src/JB721TiersHookProjectDeployer.sol`
21
+ - `src/abstract/`
22
+ - `src/interfaces/`
23
+ - `src/libraries/`
24
+ - `src/structs/`
25
+ - deployment scripts in `script/`
19
26
 
20
- ## Previous Audit Findings
27
+ This repo is depended on by Defifa, Croptop, Banny, Revnets, and omnichain deployers. Bugs here often have ecosystem-wide blast radius.
21
28
 
22
- An automated audit was conducted on 2026-03-17. Summary:
29
+ ## System Model
23
30
 
24
- | Severity | Title | Status |
25
- |----------|-------|--------|
26
- | MEDIUM | Unprotected external calls in tier split distribution cascade to full payment revert | **Remediated** -- hook callbacks, terminal calls, and native-token sends in `_sendPayoutToSplit` are now wrapped in try-catch; ERC-20 beneficiary `safeTransfer` remains unwrapped (reverts only if the token itself reverts) |
27
- | LOW | `_addToBalance` silently drops funds when no primary terminal | **Remediated** -- reverts when `primaryTerminalOf` returns `address(0)` and there are leftover funds |
28
- | LOW | Missing `splitPercent` bounds validation in `recordAddTiers` | **Remediated** -- `SplitPercentExceedsBounds` check added at `JB721TiersHookStore.sol:866` |
29
- | LOW | Implementation contract initializable | **Remediated** -- constructor now sets `_initialized = true` on the implementation |
30
- | LOW | `setMetadata` uses non-standard sentinel for tokenUriResolver | Open (documented behavior) |
31
+ The hook can act as:
32
+ - a data hook for payment and cash-out accounting inputs
33
+ - a pay hook that mints NFTs
34
+ - a cash-out hook that burns NFTs and computes reclaim weight
31
35
 
32
- See [RISKS.md](./RISKS.md) for the project's own risk assessment.
36
+ Key moving parts:
37
+ - `JB721TiersHookStore` holds compact tier state
38
+ - hook instances read and mutate tier data through the store
39
+ - tier prices, discounts, reserves, credits, split percentages, and category order shape mint behavior
40
+ - optional token URI resolvers can override metadata generation
33
41
 
34
- ## Error Reference
42
+ The most important design subtlety is that this repo affects both:
43
+ - NFT state
44
+ - core Juicebox accounting inputs and fulfillment order
35
45
 
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 where `_initialized` is already true |
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) |
46
+ That combination is why small-looking mistakes here often become ecosystem-wide economic bugs.
66
47
 
67
- ## Architecture
48
+ ## Critical Invariants
68
49
 
69
- Four contracts, one library:
50
+ 1. Supply caps hold
51
+ No tier may mint beyond its configured total supply once purchases, owner mints, and pending reserves are all considered.
70
52
 
71
- | Contract | Lines | Role |
72
- |----------|------:|------|
73
- | `JB721TiersHook` | ~790 | The hook itself. ERC-721 + data hook + pay hook + cash out hook. Handles payment processing, NFT minting, cash out burning, tier adjustment, reserve minting, discount setting, metadata, and split distribution. Delegates heavy logic to the library via DELEGATECALL. |
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. |
75
- | `JB721TiersHookDeployer` | ~115 | Deploys hook clones (Solady `LibClone`). Optional deterministic addressing via salt. Atomic deploy + initialize + ownership transfer. Registers with `JBAddressRegistry`. |
76
- | `JB721TiersHookProjectDeployer` | ~420 | Convenience: launches a project + hook in one transaction. Converts `JBPayDataHookRulesetConfig` to `JBRulesetConfig` with `useDataHookForPay: true` hardcoded. Also supports `launchRulesetsFor` and `queueRulesetsOf`. |
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. |
53
+ 2. Reserve accounting is exact
54
+ Pending reserves must neither disappear nor inflate reclaim denominators beyond what the design intends.
78
55
 
79
- Supporting:
80
- - `JB721Hook` (abstract, ~270 lines) -- Base ERC-721 with `beforePayRecordedWith`, `beforeCashOutRecordedWith`, `afterPayRecordedWith`, `afterCashOutRecordedWith`. Terminal authorization checks.
81
- - `ERC721` (abstract) -- Minimal ERC-721 with initializable name/symbol.
56
+ 3. Split routing matches accounting
57
+ If part of a mint price is routed to splits, token issuance and treasury accounting must reflect only the intended project portion.
82
58
 
83
- ## Key Flows
59
+ 4. Cash-out weight is consistent
60
+ The reclaim value for NFTs must match documented tier economics and must not be manipulable through discounts, credits, cross-currency inputs, or reserve timing.
84
61
 
85
- ### Payment -> NFT Mint
62
+ 5. Shared store isolation
63
+ One hook instance must not corrupt or observe mutable state belonging to another project unexpectedly.
86
64
 
87
- ```
88
- Terminal.pay(metadata with tier IDs)
89
- -> beforePayRecordedWith() [JB721TiersHook, view]
90
- -> JB721TiersHookLib.calculateSplitAmounts() -- per-tier split amounts from tier prices
91
- -> JB721TiersHookLib.convertSplitAmounts() -- currency conversion if pricing != payment currency
92
- -> JB721TiersHookLib.calculateWeight() -- reduce weight by split fraction
93
- -> returns (weight, hookSpecifications[0] = {this, totalSplitAmount, splitMetadata})
65
+ 6. Credit semantics remain bounded
66
+ Unused payment value that becomes credits must not let a user later mint tiers, trigger splits, or receive project-token issuance on terms they did not actually fund.
94
67
 
95
- -- Terminal records payment in JBTerminalStore with adjusted weight --
96
- -- Terminal mints project tokens --
68
+ 7. Resolver trust stays read-only unless explicitly intended
69
+ Token URI resolvers must not become an implicit control plane for mint, burn, or accounting behavior.
97
70
 
98
- -> afterPayRecordedWith(context) [JB721TiersHook, payable]
99
- -> Terminal auth check (DIRECTORY.isTerminalOf)
100
- -> _processPayment(context)
101
- -> JB721TiersHookLib.normalizePaymentValue() -- convert to pricing currency
102
- -> Combine pay credits (only if payer == beneficiary)
103
- -> Decode metadata: (allowOverspending, tierIdsToMint)
104
- -> _mintAll(amount, tierIds, beneficiary)
105
- -> STORE.recordMint(amount, tierIds, false)
106
- -- For each tier: check removed, check supply, apply discount, check price, decrement supply, check reserves
107
- -> _mint(to, tokenId) for each [no onERC721Received callback]
108
- -> Update pay credits
109
- -> JB721TiersHookLib.distributeAll(context.hookMetadata) [if forwardedAmount > 0]
110
- -> Pull ERC-20 from terminal (safeTransferFrom)
111
- -> For each tier with splits: read splits from JBSplits, distribute via _sendPayoutToSplit
112
- -> Leftover -> _addToBalance (back to project)
113
- ```
71
+ ## Threat Model
114
72
 
115
- ### Cash Out -> NFT Burn
73
+ Prioritize:
74
+ - overspending and leftover-credit edge cases
75
+ - cross-currency pricing with missing or stale feeds
76
+ - tier additions or adjustments with invalid sort order or percent bounds
77
+ - split hooks or terminal recipients that revert or partially fail
78
+ - data-hook and pay-hook interactions inside the same payment
116
79
 
117
- ```
118
- Terminal.cashOutTokensOf(metadata with token IDs)
119
- -> beforeCashOutRecordedWith() [JB721Hook, view]
120
- -> Decode token IDs from metadata
121
- -> cashOutCount = STORE.cashOutWeightOf(tokenIds) -- sum of tier prices (original, not discounted)
122
- -> totalSupply = STORE.totalCashOutWeight() -- all tiers, includes pending reserves
123
- -> returns (cashOutTaxRate, cashOutCount, totalSupply, hookSpecs)
80
+ Especially high-value attacker profiles:
81
+ - a payer crafting metadata and tier selections to desync credits, split routing, and token issuance
82
+ - a project owner adjusting tiers between preview and execution windows
83
+ - a downstream app assuming tier cash-out weight tracks discounted price when the primitive uses different economics
124
84
 
125
- -- Terminal computes reclaim via bonding curve --
85
+ ## Hotspots
126
86
 
127
- -> afterCashOutRecordedWith(context) [JB721Hook, payable]
128
- -> Terminal auth check
129
- -> For each token ID: verify owner == context.holder, _burn(tokenId)
130
- -> _didBurn(tokenIds) -> STORE.recordBurn(tokenIds) -- increment burn counter
131
- ```
87
+ - `beforePayRecordedWith`, `afterPayRecordedWith`, and cash-out hooks
88
+ - credit handling when `payer != beneficiary`
89
+ - discount logic versus cash-out pricing
90
+ - pending reserve minting and denominator logic
91
+ - `splitPercent` handling and hook distribution fallback behavior
92
+ - deployers that transfer ownership or queue rulesets around the hook
132
93
 
133
- ### Tier Management
94
+ ## Sequences Worth Replaying
134
95
 
135
- ```
136
- Owner -> adjustTiers(tiersToAdd, tierIdsToRemove)
137
- -> Permission check: ADJUST_721_TIERS
138
- -> JB721TiersHookLib.adjustTiersFor() via DELEGATECALL
139
- -> STORE.recordRemoveTierIds(tierIdsToRemove) -- bitmap mark, no data deletion
140
- -> STORE.recordAddTiers(tiersToAdd) -- sorted insert into linked list
141
- -> SPLITS.setSplitGroupsOf() for tiers with splits configured
142
- ```
96
+ 1. Cross-currency payment where prices are missing, stale, or intentionally asymmetric.
97
+ 2. Payment with leftover value that becomes credits, then a second payment from a different payer/beneficiary arrangement.
98
+ 3. Tier purchases with split routing enabled, especially when split hooks or downstream terminals fail.
99
+ 4. Reserve-heavy tiers followed by NFT cash-out before pending reserves are minted.
100
+ 5. Tier adjustment or discount updates around active minting and cash-out windows.
143
101
 
144
- ### Reserve Minting
102
+ ## Build And Verification
145
103
 
146
- ```
147
- Anyone -> mintPendingReservesFor(tierId, count)
148
- -> Check ruleset metadata: mintPendingReservesPaused (bit 1)
149
- -> STORE.recordMintReservesFor(tierId, count)
150
- -- Checks pendingReserves >= count
151
- -- Increments numberOfReservesMintedFor
152
- -- Decrements remainingSupply
153
- -> STORE.reserveBeneficiaryOf(hook, tierId) -- tier-specific or default
154
- -> _mint(to, tokenId) for each
155
- ```
104
+ Standard workflow:
105
+ - `npm install`
106
+ - `forge build`
107
+ - `forge test`
156
108
 
157
- ## Storage Layout
158
-
159
- ### Tier Linked List (sorted by category)
160
-
161
- Tiers are stored individually in `_storedTierOf[hook][tierId]` as `JBStored721Tier` structs. The sorted iteration order is maintained by:
162
-
163
- - `_tierIdAfter[hook][tierId]` -- next tier in sorted order (0 means tierId+1 is next)
164
- - `_tierIdAfter[hook][0]` -- first tier in sorted order
165
- - `_lastTrackedSortedTierIdOf[hook]` -- last tier if explicitly tracked (else `maxTierIdOf`)
166
- - `_startingTierIdOfCategory[hook][category]` -- first tier ID for a given category
167
-
168
- New tiers are always assigned incrementing IDs (`maxTierIdOf + 1, +2, ...`) regardless of category. The linked list is updated to insert them at the correct sorted position.
169
-
170
- ### Tier Removal Bitmap
171
-
172
- Tiers are never deleted from storage. Removal is tracked in `_removedTiersBitmapWordOf[hook]` using the `JBBitmap` library. Each word stores 256 tier removal flags. Removed tiers are skipped during sorted iteration but their data persists for:
173
- - Cash out weight calculation (`totalCashOutWeight` iterates by maxTierIdOf, not by sorted list)
174
- - Existing NFT metadata resolution
175
- - Reserve minting (reserves can still be minted from removed tiers)
176
-
177
- ### Pay Credits
178
-
179
- `payCreditsOf[beneficiary]` in the hook contract (not the store). Tracks overpayment in the pricing currency denomination. Only combined with incoming payment when `payer == beneficiary`.
180
-
181
- ### Token ID Encoding
182
-
183
- `tokenId = tierId * 1_000_000_000 + tokenNumber`
184
-
185
- Where `tokenNumber` is `initialSupply - remainingSupply` at mint time. This means:
186
- - `tierIdOfToken(tokenId) = tokenId / 1_000_000_000`
187
- - Max supply per tier: 999,999,999 (enforced as `_ONE_BILLION - 1`)
188
- - Max tier ID: 65,535 (`type(uint16).max`)
189
-
190
- ### Split Group ID Encoding
191
-
192
- Split groups are stored in `JBSplits` with a composite group ID:
193
- ```
194
- groupId = uint256(uint160(hookAddress)) | (uint256(tierId) << 160)
195
- ```
196
-
197
- ## Key Constants
198
-
199
- | Constant | Value | Where |
200
- |----------|-------|-------|
201
- | `DISCOUNT_DENOMINATOR` | 200 | `JB721Constants.sol` -- 200 = 100% discount, NOT 100 |
202
- | `SPLITS_TOTAL_PERCENT` | 1,000,000,000 | `JBConstants` -- `splitPercent` is out of 1e9 |
203
- | `_ONE_BILLION` | 1,000,000,000 | `JB721TiersHookStore` -- token ID namespace per tier |
204
- | Max tier ID | 65,535 | `type(uint16).max` enforced in `recordAddTiers` |
205
- | Max supply per tier | 999,999,999 | `_ONE_BILLION - 1` enforced in `recordAddTiers` |
206
-
207
- ## Gotchas -- Things That Trip Up Auditors
208
-
209
- 1. **Discount denominator is 200, not 100.** A `discountPercent` of 100 means 50% off. A `discountPercent` of 200 means 100% off (free). The formula: `effectivePrice = price - mulDiv(price, discountPercent, 200)`.
210
-
211
- 2. **Cash out weight uses original price, not discounted price.** `cashOutWeightOf` and `totalCashOutWeight` both use `storedTier.price` directly. If a tier has `discountPercent = 200` (free), NFTs minted for free still carry full cash-out weight. This is by design but creates an arbitrage vector if discount can be increased (see R-2 in RISKS.md).
212
-
213
- 3. **Category sort order is enforced on-chain.** `recordAddTiers` reverts with `InvalidCategorySortOrder` if tiers are not passed in ascending category order. This is a common integration footgun.
214
-
215
- 4. **Tier removal is soft.** `recordRemoveTierIds` only sets a bitmap flag. The stored tier data, cash-out weight, and reserve accounting all persist. `totalCashOutWeight` iterates by `maxTierIdOf`, not by the sorted list, so removed tier NFTs retain their cash-out value.
216
-
217
- 5. **Pay credits accrue to the beneficiary, not the payer.** When `payer != beneficiary`, the payer's existing credits are NOT applied to the mint. The leftover from the payment becomes the beneficiary's credit. This is documented but non-obvious.
218
-
219
- 6. **`splitPercent` is out of 1,000,000,000 (1e9), not 10,000.** A `splitPercent` of 500,000,000 means 50% of the tier's effective (discounted) price is routed to splits.
220
-
221
- 7. **`useReserveBeneficiaryAsDefault` overwrites the global default.** Adding a tier with this flag silently redirects reserve mints for ALL existing tiers that rely on the default beneficiary.
222
-
223
- 8. **No `ReentrancyGuard`.** The hook relies on state-before-interaction ordering and try-catch wrapping. All `STORE.record*` calls and `_mint()` calls happen before any untrusted external calls (split distribution). All external calls in `_sendPayoutToSplit` are wrapped in try-catch so a reverting recipient cannot block payments.
224
-
225
- 9. **`_mint()` is used, not `_safeMint()`.** The `onERC721Received` callback is NOT triggered during minting. This prevents mint-time DoS but means contracts that expect the callback won't detect incoming NFTs.
226
-
227
- 10. **`recordMint` decrements supply BEFORE checking reserves.** The remaining supply check `remainingSupply < _numberOfPendingReservesFor(...)` happens after the decrement. This is intentional -- the post-mint state correctly reflects the new non-reserve mint that may have created a new pending reserve.
228
-
229
- 11. **`totalCashOutWeight` includes pending reserves.** This dilutes cash-out value for existing holders by counting reserves that haven't been minted yet. By design -- prevents early cashers from extracting more than their fair share.
230
-
231
- 12. **`beforePayRecordedWith` computes split amounts in the pricing currency, then converts.** The split amount forwarded to the hook is in the payment token denomination. If the price feed has significant spread, the conversion can over/under-estimate.
232
-
233
- ## Priority Audit Areas
234
-
235
- Audit in this order:
236
-
237
- ### 1. Split Distribution (Highest Risk)
238
-
239
- The split distribution path in `JB721TiersHookLib.distributeAll()` is the largest attack surface:
240
-
241
- - External calls to untrusted split hooks (`processSplitWith{value}`)
242
- - External calls to arbitrary terminals (`terminal.pay()`, `terminal.addToBalanceOf()`)
243
- - External calls to arbitrary beneficiary addresses (`.call{value}`)
244
- - ERC-20 token transfers and approvals before external calls
245
- - No `ReentrancyGuard` -- relies on state ordering and try-catch wrapping
246
-
247
- All external calls in `_sendPayoutToSplit` are wrapped in try-catch so a single reverting recipient cannot brick all payments to the project. Behavior differs by token type:
248
- - **Native token (ETH):** Split hooks, terminal calls, and beneficiary sends are wrapped in try-catch. On revert, ETH stays with the caller and the function returns `false`, routing the amount to the project's balance via `_addToBalance`.
249
- - **ERC-20 split hooks:** Tokens are transferred via `safeTransfer` before the hook callback. The callback is wrapped in try-catch, but the function always returns `true` regardless of callback success — because the tokens have already left the contract, returning `false` would cause double-spend accounting in the leftover calculation.
250
- - **ERC-20 terminal calls:** `forceApprove` is called before the terminal call. On failure, the approval is reset to zero to prevent dangling approvals, and the function returns `false`.
251
-
252
- Verify that:
253
- - State is fully settled before any external call in the distribution loop
254
- - A reentering call through `terminal.pay()` cannot corrupt hook state
255
- - `leftoverAmount` accounting is correct when `_sendPayoutToSplit` returns false
256
- - ERC-20 `forceApprove` followed by external call cannot be exploited (approval not consumed -> leftover approval)
257
- - The ERC-20 split hook path correctly returns `true` after `safeTransfer` regardless of callback outcome (prevents double-spend via leftover miscounting)
258
-
259
- ### 2. Discount / Cash Out Weight Interaction
260
-
261
- The discount system creates a price asymmetry:
262
- - Mint price: `price - mulDiv(price, discountPercent, 200)` (can be zero)
263
- - Cash out weight: `price` (always original, never discounted)
264
-
265
- Verify that:
266
- - `cannotIncreaseDiscountPercent` is correctly enforced in `recordSetDiscountPercentOf`
267
- - Split amounts use the discounted price (they do -- `calculateSplitAmounts` applies discount)
268
- - There is no path to mint at discounted price and cash out at original weight without the owner explicitly enabling it
269
-
270
- ### 3. Reserve Accounting
271
-
272
- Reserve mints interact with supply tracking:
273
- - `_numberOfPendingReservesFor` uses `ceil(nonReserveMints / reserveFrequency) - reservesMinted`
274
- - `recordMint` checks `remainingSupply < pendingReserves` after decrementing
275
- - Reserve mints decrement `remainingSupply` and increment `numberOfReservesMintedFor`
276
-
277
- Verify that:
278
- - A paid mint cannot steal the last slot reserved for a pending reserve
279
- - `_numberOfPendingReservesFor` never returns more than `remainingSupply`
280
- - The rounding-up in pending reserve calculation is correct and consistent
281
- - Changing `defaultReserveBeneficiaryOf` cannot create ghost reserves or destroy legitimate ones
282
-
283
- ### 4. Cross-Currency Price Normalization
284
-
285
- Two conversion points:
286
- - `normalizePaymentValue` -- converts payment amount to pricing currency for tier price comparison
287
- - `convertSplitAmounts` -- converts split amounts from pricing currency to payment token denomination
288
-
289
- Verify that:
290
- - When `address(prices) == address(0)` and currencies differ, `normalizePaymentValue` returns `(0, false)` and the hook skips minting (no silent fund loss)
291
- - A reverting price feed blocks payments but does not lose funds
292
- - Rounding through the conversion chain (normalize -> split calc -> convert back) does not systematically favor the attacker
293
- - The ratio used in `convertSplitAmounts` is the inverse of what `normalizePaymentValue` uses (it should be -- verify)
294
-
295
- ### 5. Initialization and Clone Security
296
-
297
- `JB721TiersHookDeployer` creates minimal proxy clones:
298
- - `initialize()` is guarded by an `_initialized` bool flag
299
- - Ownership is transferred to `_msgSender()` inside `initialize`, then to the deployer caller in `deployHookFor`
300
-
301
- Verify that:
302
- - The implementation contract's constructor sets `_initialized = true`, preventing initialization.
303
- - Deterministic salt derivation (`keccak256(abi.encode(_msgSender(), salt))`) prevents cross-deployer address collision
304
- - Front-running `deployHookFor` cannot hijack ownership
305
-
306
- ### 6. Linked List Integrity
307
-
308
- Tier sorting is maintained by `_tierIdAfter` mappings:
309
- - `recordAddTiers` inserts new tiers into the sorted list
310
- - `cleanTiers` removes gaps from the sorted list
311
- - `_nextSortedTierIdOf` defaults to `id + 1` when no explicit next is stored
312
-
313
- Verify that:
314
- - Adding tiers to an existing set preserves the correct sort order
315
- - Removing and re-adding tiers does not corrupt the linked list
316
- - `cleanTiers` (permissionless) cannot be used to manipulate tier ordering in a way that affects minting or pricing
317
-
318
- ## Invariants
319
-
320
- These must hold. If you can break any, it's a finding:
321
-
322
- 1. **Supply cap**: For every tier, `initialSupply - remainingSupply` (minted count) never exceeds `initialSupply`.
323
- 2. **Reserve protection**: After any `recordMint`, `remainingSupply >= numberOfPendingReserves` for that tier.
324
- 3. **Token ID uniqueness**: No two distinct mints produce the same `tokenId` (guaranteed by `initialSupply - --remainingSupply` pattern).
325
- 4. **Cash out weight conservation**: `totalCashOutWeight` equals the sum of `price * (mintedCount + pendingReserves)` across all tiers.
326
- 5. **Balance tracking**: `sum(tierBalanceOf[hook][owner][tierId])` across all owners equals `initialSupply - remainingSupply - burned` for each tier.
327
- 6. **Credit conservation**: Pay credits increase by leftover after minting, decrease by amount used for minting. Never negative.
328
- 7. **Linked list completeness**: Iterating from `_firstSortedTierIdOf(hook, 0)` via `_nextSortedTierIdOf` visits every non-removed tier exactly once.
329
- 8. **Discount bound**: `discountPercent <= DISCOUNT_DENOMINATOR (200)` for every stored tier.
330
- 9. **Removal idempotency**: Removing an already-removed tier is a no-op (bitmap set is idempotent).
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).
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 an `_initialized` bool flag. The implementation contract's constructor sets `_initialized = true`. Verify clones cannot be re-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
-
346
- ## Testing Setup
347
-
348
- ```bash
349
- cd nana-721-hook-v6
350
- npm install
351
- forge build
352
- forge test
353
-
354
- # Run with high verbosity
355
- forge test -vvvv --match-test testExploitName
356
-
357
- # Write a PoC
358
- forge test --match-path test/audit/ExploitPoC.t.sol -vvv
359
-
360
- # Run invariant tests
361
- forge test --match-contract Invariant
362
-
363
- # Gas analysis
364
- forge test --gas-report
365
- ```
366
-
367
- ### Existing Test Coverage
368
-
369
- | Category | Files | Coverage |
370
- |----------|------:|---------|
371
- | Unit tests | 13 | adjustTier, deployer, getters/constructor, mintFor/mintReservesFor, pay, redeem, tierSplitRouting, splitHookDistribution, JBBitmap, JBIpfsDecoder, pay_CrossCurrency, JB721TiersRulesetMetadataResolver, TierSupplyReserveCheck |
372
- | Invariant tests | 2 + 2 handlers | TierLifecycleInvariant (6), TieredHookStoreInvariant (3) |
373
- | Attack tests | 1 | 10 adversarial scenarios |
374
- | Regression tests | 6 | BrokenTerminalDoesNotDos, CacheTierLookup, ProjectDeployerRulesets, ReserveBeneficiaryOverwrite, SplitDistributionBugs, SplitNoBeneficiary |
375
- | E2E tests | 1 | Full lifecycle |
376
- | Fork tests | 3 | ERC20CashOutFork, ERC20TierSplitFork, IssueTokensForSplitsFork |
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 |
380
-
381
- ### Coverage Gaps
382
-
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.
387
-
388
- ## How to Report Findings
389
-
390
- For each finding:
391
-
392
- 1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
393
- 2. **Affected contract(s)** -- exact file path and line numbers
394
- 3. **Description** -- what's wrong, in plain language
395
- 4. **Trigger sequence** -- step-by-step, minimal steps to reproduce
396
- 5. **Impact** -- what an attacker gains, what a user loses (with numbers if possible)
397
- 6. **Proof** -- code trace showing the exact execution path, or a Foundry test
398
- 7. **Fix** -- minimal code change that resolves the issue
399
-
400
- **Severity guide:**
401
- - **CRITICAL**: Direct fund loss, permanent DoS, or broken core invariant. Exploitable with no preconditions.
402
- - **HIGH**: Conditional fund loss, privilege escalation, or broken invariant. Requires specific but realistic setup.
403
- - **MEDIUM**: Value leakage, griefing with cost to attacker, incorrect accounting, degraded functionality.
404
- - **LOW**: Informational, cosmetic, edge-case-only with no material impact.
405
-
406
- **Before reporting -- verify it's not a false positive:**
407
- - Is the "bug" already documented in [RISKS.md](./RISKS.md)?
408
- - Does cash out weight using original price (not discounted) look intentional? (It is.)
409
- - Does `totalCashOutWeight` including pending reserves look wrong? (It's by design.)
410
- - Is `DISCOUNT_DENOMINATOR = 200` surprising but correct? (It is.)
411
- - Does the store's `msg.sender`-keyed trust model handle the case? (The store trusts the hook.)
412
- - Is the economic attack profitable after the core protocol's 2.5% fee on cash outs?
109
+ Current tests emphasize:
110
+ - audit and regression fixes around split accounting and cross-currency behavior
111
+ - invariants on tier lifecycle and store state
112
+ - fork coverage for ERC-20 cash-out and tier split routes
413
113
 
114
+ High-value findings in this repo tend to become repeatable vulnerabilities in downstream repos, so favor proofs that show the primitive itself returning or recording the wrong value.
package/CHANGELOG.md ADDED
@@ -0,0 +1,71 @@
1
+ # Changelog
2
+
3
+ ## Scope
4
+
5
+ This file describes the verified change from `nana-721-hook-v5` to the current `nana-721-hook-v6` repo.
6
+
7
+ ## Current v6 surface
8
+
9
+ - `JB721TiersHook`
10
+ - `JB721TiersHookStore`
11
+ - `JB721TiersHookDeployer`
12
+ - `JB721TiersHookProjectDeployer`
13
+ - `JB721TiersHookLib`
14
+
15
+ ## Summary
16
+
17
+ - v6 adds tier-level split routing. `JB721TierConfig` and the surrounding minting logic now support `splitPercent` and `splits`.
18
+ - Collection metadata is more flexible than in v5. The hook can update name and symbol through the v6 metadata flow.
19
+ - Pricing context is cleaner. The hook no longer exposes prices through the old return shape, and pricing assumptions should be rebuilt from the current interfaces.
20
+ - The repo now carries a dedicated helper library to keep the hook surface manageable and to support the larger v6 feature set.
21
+ - The repo was upgraded from the v5 Solidity baseline to `0.8.28`.
22
+
23
+ ## Verified deltas
24
+
25
+ - `IJB721TiersHook.pricingContext()` changed from a three-value return to `(currency, decimals)`.
26
+ - `IJB721TiersHook.PRICES()` is now an explicit getter instead of being bundled into `pricingContext()`.
27
+ - `IJB721TiersHook.SPLITS()` is new and matches the new tier-splits feature.
28
+ - `IJB721TiersHook.setMetadata(...)` now takes `name` and `symbol` before the URI fields.
29
+ - The interface gained new event surface around split payout failure handling and collection metadata updates.
30
+
31
+ ## Breaking ABI changes
32
+
33
+ - `pricingContext()` return shape changed.
34
+ - `setMetadata(...)` argument order changed and now includes `name` and `symbol`.
35
+ - `JB721TierConfig` gained `cantBuyWithCredits`, `splitPercent`, and `splits`. Boolean flags (`allowOwnerMint`, `useReserveBeneficiaryAsDefault`, `transfersPausable`, `useVotingUnits`, `cantBeRemoved`, `cantIncreaseDiscountPercent`, `cantBuyWithCredits`) are nested in a `flags` field of type `JB721TierConfigFlags`.
36
+ - `JB721Tier` boolean flags (`allowOwnerMint`, `transfersPausable`, `cantBeRemoved`, `cantIncreaseDiscountPercent`, `cantBuyWithCredits`) are nested in a `flags` field of type `JB721TierFlags`.
37
+ - `JBStored721Tier` replaced packed `votingUnits` storage with `splitPercent` in the stored struct layout.
38
+ - `SPLITS()` and `PRICES()` are explicit interface getters.
39
+
40
+ ## Indexer impact
41
+
42
+ - New events: `AddToBalanceReverted`, `SetName`, `SetSymbol`, `SplitPayoutReverted`.
43
+ - Tier config decoding changed because `JB721TierConfig` is no longer v5-compatible.
44
+ - Collection metadata can now change after deployment, so one-time indexing of `name` and `symbol` is no longer sufficient.
45
+
46
+ ## Migration notes
47
+
48
+ - Rebuild integrations around the current `IJB721TiersHook` and related structs. This is not a selector-stable upgrade.
49
+ - Any indexer or frontend that decoded tier config data must account for tier splits.
50
+ - If you relied on v5 pricing-context return shapes or older metadata argument ordering, update those assumptions before shipping.
51
+
52
+ ## ABI appendix
53
+
54
+ - Added functions
55
+ - `PRICES()`
56
+ - `SPLITS()`
57
+ - Changed functions
58
+ - `pricingContext()`
59
+ - `setMetadata(...)`
60
+ - Added events
61
+ - `AddToBalanceReverted`
62
+ - `SetName`
63
+ - `SetSymbol`
64
+ - `SplitPayoutReverted`
65
+ - Changed structs
66
+ - `JB721TierConfig` (boolean flags moved to nested `JB721TierConfigFlags flags`)
67
+ - `JB721Tier` (boolean flags moved to nested `JB721TierFlags flags`)
68
+ - `JBStored721Tier`
69
+ - Added structs
70
+ - `JB721TierConfigFlags`
71
+ - `JB721TierFlags`