@bananapus/721-hook-v6 0.0.16 → 0.0.17
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 +1 -1
- package/ARCHITECTURE.md +2 -2
- package/AUDIT_INSTRUCTIONS.md +340 -0
- package/CHANGE_LOG.md +309 -0
- package/README.md +1 -1
- package/RISKS.md +77 -312
- package/SKILLS.md +5 -4
- package/USER_JOURNEYS.md +503 -0
- package/package.json +5 -5
- package/src/JB721TiersHook.sol +7 -3
- package/src/JB721TiersHookStore.sol +52 -35
- package/src/abstract/ERC721.sol +3 -0
- package/src/interfaces/IJB721TiersHook.sol +23 -3
- package/src/libraries/JB721TiersHookLib.sol +182 -118
- package/test/TestAuditGaps.sol +903 -0
- package/test/TestSafeTransferReentrancy.t.sol +305 -0
- package/test/TestVotingUnitsLifecycle.t.sol +313 -0
- package/test/regression/BrokenTerminalDoesNotDos.t.sol +301 -0
- package/test/regression/ProjectDeployerRulesets.t.sol +355 -0
- package/test/regression/SplitDistributionBugs.t.sol +751 -0
- package/test/unit/redeem_Unit.t.sol +4 -0
- package/test/unit/splitHookDistribution_Unit.t.sol +604 -0
package/ADMINISTRATION.md
CHANGED
|
@@ -84,7 +84,7 @@ Permissions flow through two mechanisms:
|
|
|
84
84
|
|
|
85
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
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
|
|
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 1) grants all permissions.
|
|
88
88
|
|
|
89
89
|
The `_requirePermissionFrom()` check (inherited from `JBOwnable` via `JBPermissioned`) passes if:
|
|
90
90
|
- `msg.sender == account` (the owner themselves), OR
|
package/ARCHITECTURE.md
CHANGED
|
@@ -37,7 +37,7 @@ User → JBMultiTerminal.pay(metadata)
|
|
|
37
37
|
→ Validate: not removed, not paused, supply available
|
|
38
38
|
→ Check price (with optional discount, normalized to tier pricing currency)
|
|
39
39
|
→ Mint NFT to beneficiary
|
|
40
|
-
→ Distribute split funds
|
|
40
|
+
→ Distribute split funds (priority: split.hook > split.projectId > split.beneficiary)
|
|
41
41
|
→ Leftover amount optionally mints best-available tiers
|
|
42
42
|
```
|
|
43
43
|
|
|
@@ -72,4 +72,4 @@ Owner → JB721TiersHook.adjustTiers()
|
|
|
72
72
|
- `@bananapus/permission-ids-v6` — Permission constants
|
|
73
73
|
- `@openzeppelin/contracts` — ERC-721 utils, Ownable
|
|
74
74
|
- `@prb/math` — mulDiv
|
|
75
|
-
- `solady` —
|
|
75
|
+
- `solady` — LibClone
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# Audit Instructions -- nana-721-hook-v6
|
|
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.
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
Four contracts, one library:
|
|
10
|
+
|
|
11
|
+
| Contract | Lines | Role |
|
|
12
|
+
|----------|------:|------|
|
|
13
|
+
| `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. |
|
|
14
|
+
| `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
|
+
| `JB721TiersHookDeployer` | ~115 | Deploys hook clones (Solady `LibClone`). Optional deterministic addressing via salt. Atomic deploy + initialize + ownership transfer. Registers with `JBAddressRegistry`. |
|
|
16
|
+
| `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) | ~607 | 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
|
+
|
|
19
|
+
Supporting:
|
|
20
|
+
- `JB721Hook` (abstract, ~270 lines) -- Base ERC-721 with `beforePayRecordedWith`, `beforeCashOutRecordedWith`, `afterPayRecordedWith`, `afterCashOutRecordedWith`. Terminal authorization checks.
|
|
21
|
+
- `ERC721` (abstract) -- Minimal ERC-721 with initializable name/symbol.
|
|
22
|
+
|
|
23
|
+
## Key Flows
|
|
24
|
+
|
|
25
|
+
### Payment -> NFT Mint
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
Terminal.pay(metadata with tier IDs)
|
|
29
|
+
-> beforePayRecordedWith() [JB721TiersHook, view]
|
|
30
|
+
-> JB721TiersHookLib.calculateSplitAmounts() -- per-tier split amounts from tier prices
|
|
31
|
+
-> JB721TiersHookLib.convertSplitAmounts() -- currency conversion if pricing != payment currency
|
|
32
|
+
-> JB721TiersHookLib.calculateWeight() -- reduce weight by split fraction
|
|
33
|
+
-> returns (weight, hookSpecifications[0] = {this, totalSplitAmount, splitMetadata})
|
|
34
|
+
|
|
35
|
+
-- Terminal records payment in JBTerminalStore with adjusted weight --
|
|
36
|
+
-- Terminal mints project tokens --
|
|
37
|
+
|
|
38
|
+
-> afterPayRecordedWith(context) [JB721TiersHook, payable]
|
|
39
|
+
-> Terminal auth check (DIRECTORY.isTerminalOf)
|
|
40
|
+
-> _processPayment(context)
|
|
41
|
+
-> JB721TiersHookLib.normalizePaymentValue() -- convert to pricing currency
|
|
42
|
+
-> Combine pay credits (only if payer == beneficiary)
|
|
43
|
+
-> Decode metadata: (allowOverspending, tierIdsToMint)
|
|
44
|
+
-> _mintAll(amount, tierIds, beneficiary)
|
|
45
|
+
-> STORE.recordMint(amount, tierIds, false)
|
|
46
|
+
-- For each tier: check removed, check supply, apply discount, check price, decrement supply, check reserves
|
|
47
|
+
-> _mint(to, tokenId) for each [no onERC721Received callback]
|
|
48
|
+
-> Update pay credits
|
|
49
|
+
-> JB721TiersHookLib.distributeAll(context.hookMetadata) [if forwardedAmount > 0]
|
|
50
|
+
-> Pull ERC-20 from terminal (safeTransferFrom)
|
|
51
|
+
-> For each tier with splits: read splits from JBSplits, distribute via _sendPayoutToSplit
|
|
52
|
+
-> Leftover -> _addToBalance (back to project)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Cash Out -> NFT Burn
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
Terminal.cashOutTokensOf(metadata with token IDs)
|
|
59
|
+
-> beforeCashOutRecordedWith() [JB721Hook, view]
|
|
60
|
+
-> Decode token IDs from metadata
|
|
61
|
+
-> cashOutCount = STORE.cashOutWeightOf(tokenIds) -- sum of tier prices (original, not discounted)
|
|
62
|
+
-> totalSupply = STORE.totalCashOutWeight() -- all tiers, includes pending reserves
|
|
63
|
+
-> returns (cashOutTaxRate, cashOutCount, totalSupply, hookSpecs)
|
|
64
|
+
|
|
65
|
+
-- Terminal computes reclaim via bonding curve --
|
|
66
|
+
|
|
67
|
+
-> afterCashOutRecordedWith(context) [JB721Hook, payable]
|
|
68
|
+
-> Terminal auth check
|
|
69
|
+
-> For each token ID: verify owner == context.holder, _burn(tokenId)
|
|
70
|
+
-> _didBurn(tokenIds) -> STORE.recordBurn(tokenIds) -- increment burn counter
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Tier Management
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
Owner -> adjustTiers(tiersToAdd, tierIdsToRemove)
|
|
77
|
+
-> Permission check: ADJUST_721_TIERS
|
|
78
|
+
-> JB721TiersHookLib.adjustTiersFor() via DELEGATECALL
|
|
79
|
+
-> STORE.recordRemoveTierIds(tierIdsToRemove) -- bitmap mark, no data deletion
|
|
80
|
+
-> STORE.recordAddTiers(tiersToAdd) -- sorted insert into linked list
|
|
81
|
+
-> SPLITS.setSplitGroupsOf() for tiers with splits configured
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Reserve Minting
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
Anyone -> mintPendingReservesFor(tierId, count)
|
|
88
|
+
-> Check ruleset metadata: mintPendingReservesPaused (bit 1)
|
|
89
|
+
-> STORE.recordMintReservesFor(tierId, count)
|
|
90
|
+
-- Checks pendingReserves >= count
|
|
91
|
+
-- Increments numberOfReservesMintedFor
|
|
92
|
+
-- Decrements remainingSupply
|
|
93
|
+
-> STORE.reserveBeneficiaryOf(hook, tierId) -- tier-specific or default
|
|
94
|
+
-> _mint(to, tokenId) for each
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Storage Layout
|
|
98
|
+
|
|
99
|
+
### Tier Linked List (sorted by category)
|
|
100
|
+
|
|
101
|
+
Tiers are stored individually in `_storedTierOf[hook][tierId]` as `JBStored721Tier` structs. The sorted iteration order is maintained by:
|
|
102
|
+
|
|
103
|
+
- `_tierIdAfter[hook][tierId]` -- next tier in sorted order (0 means tierId+1 is next)
|
|
104
|
+
- `_tierIdAfter[hook][0]` -- first tier in sorted order
|
|
105
|
+
- `_lastTrackedSortedTierIdOf[hook]` -- last tier if explicitly tracked (else `maxTierIdOf`)
|
|
106
|
+
- `_startingTierIdOfCategory[hook][category]` -- first tier ID for a given category
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
110
|
+
### Tier Removal Bitmap
|
|
111
|
+
|
|
112
|
+
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:
|
|
113
|
+
- Cash out weight calculation (`totalCashOutWeight` iterates by maxTierIdOf, not by sorted list)
|
|
114
|
+
- Existing NFT metadata resolution
|
|
115
|
+
- Reserve minting (reserves can still be minted from removed tiers)
|
|
116
|
+
|
|
117
|
+
### Pay Credits
|
|
118
|
+
|
|
119
|
+
`payCreditsOf[beneficiary]` in the hook contract (not the store). Tracks overpayment in the pricing currency denomination. Only combined with incoming payment when `payer == beneficiary`.
|
|
120
|
+
|
|
121
|
+
### Token ID Encoding
|
|
122
|
+
|
|
123
|
+
`tokenId = tierId * 1_000_000_000 + tokenNumber`
|
|
124
|
+
|
|
125
|
+
Where `tokenNumber` is `initialSupply - remainingSupply` at mint time. This means:
|
|
126
|
+
- `tierIdOfToken(tokenId) = tokenId / 1_000_000_000`
|
|
127
|
+
- Max supply per tier: 999,999,999 (enforced as `_ONE_BILLION - 1`)
|
|
128
|
+
- Max tier ID: 65,535 (`type(uint16).max`)
|
|
129
|
+
|
|
130
|
+
### Split Group ID Encoding
|
|
131
|
+
|
|
132
|
+
Split groups are stored in `JBSplits` with a composite group ID:
|
|
133
|
+
```
|
|
134
|
+
groupId = uint256(uint160(hookAddress)) | (uint256(tierId) << 160)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Key Constants
|
|
138
|
+
|
|
139
|
+
| Constant | Value | Where |
|
|
140
|
+
|----------|-------|-------|
|
|
141
|
+
| `DISCOUNT_DENOMINATOR` | 200 | `JB721Constants.sol` -- 200 = 100% discount, NOT 100 |
|
|
142
|
+
| `SPLITS_TOTAL_PERCENT` | 1,000,000,000 | `JBConstants` -- `splitPercent` is out of 1e9 |
|
|
143
|
+
| `_ONE_BILLION` | 1,000,000,000 | `JB721TiersHookStore` -- token ID namespace per tier |
|
|
144
|
+
| Max tier ID | 65,535 | `type(uint16).max` enforced in `recordAddTiers` |
|
|
145
|
+
| Max supply per tier | 999,999,999 | `_ONE_BILLION - 1` enforced in `recordAddTiers` |
|
|
146
|
+
|
|
147
|
+
## Gotchas -- Things That Trip Up Auditors
|
|
148
|
+
|
|
149
|
+
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)`.
|
|
150
|
+
|
|
151
|
+
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).
|
|
152
|
+
|
|
153
|
+
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.
|
|
154
|
+
|
|
155
|
+
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.
|
|
156
|
+
|
|
157
|
+
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.
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
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.
|
|
164
|
+
|
|
165
|
+
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.
|
|
166
|
+
|
|
167
|
+
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.
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
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.
|
|
172
|
+
|
|
173
|
+
## Priority Audit Areas
|
|
174
|
+
|
|
175
|
+
Audit in this order:
|
|
176
|
+
|
|
177
|
+
### 1. Split Distribution (Highest Risk)
|
|
178
|
+
|
|
179
|
+
The split distribution path in `JB721TiersHookLib.distributeAll()` is the largest attack surface:
|
|
180
|
+
|
|
181
|
+
- External calls to untrusted split hooks (`processSplitWith{value}`)
|
|
182
|
+
- External calls to arbitrary terminals (`terminal.pay()`, `terminal.addToBalanceOf()`)
|
|
183
|
+
- External calls to arbitrary beneficiary addresses (`.call{value}`)
|
|
184
|
+
- ERC-20 token transfers and approvals before external calls
|
|
185
|
+
- No `ReentrancyGuard` -- relies on state ordering and try-catch wrapping
|
|
186
|
+
|
|
187
|
+
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:
|
|
188
|
+
- **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`.
|
|
189
|
+
- **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.
|
|
190
|
+
- **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`.
|
|
191
|
+
|
|
192
|
+
Verify that:
|
|
193
|
+
- State is fully settled before any external call in the distribution loop
|
|
194
|
+
- A reentering call through `terminal.pay()` cannot corrupt hook state
|
|
195
|
+
- `leftoverAmount` accounting is correct when `_sendPayoutToSplit` returns false
|
|
196
|
+
- ERC-20 `forceApprove` followed by external call cannot be exploited (approval not consumed -> leftover approval)
|
|
197
|
+
- The ERC-20 split hook path correctly returns `true` after `safeTransfer` regardless of callback outcome (prevents double-spend via leftover miscounting)
|
|
198
|
+
|
|
199
|
+
### 2. Discount / Cash Out Weight Interaction
|
|
200
|
+
|
|
201
|
+
The discount system creates a price asymmetry:
|
|
202
|
+
- Mint price: `price - mulDiv(price, discountPercent, 200)` (can be zero)
|
|
203
|
+
- Cash out weight: `price` (always original, never discounted)
|
|
204
|
+
|
|
205
|
+
Verify that:
|
|
206
|
+
- `cannotIncreaseDiscountPercent` is correctly enforced in `recordSetDiscountPercentOf`
|
|
207
|
+
- Split amounts use the discounted price (they do -- `calculateSplitAmounts` applies discount)
|
|
208
|
+
- There is no path to mint at discounted price and cash out at original weight without the owner explicitly enabling it
|
|
209
|
+
|
|
210
|
+
### 3. Reserve Accounting
|
|
211
|
+
|
|
212
|
+
Reserve mints interact with supply tracking:
|
|
213
|
+
- `_numberOfPendingReservesFor` uses `ceil(nonReserveMints / reserveFrequency) - reservesMinted`
|
|
214
|
+
- `recordMint` checks `remainingSupply < pendingReserves` after decrementing
|
|
215
|
+
- Reserve mints decrement `remainingSupply` and increment `numberOfReservesMintedFor`
|
|
216
|
+
|
|
217
|
+
Verify that:
|
|
218
|
+
- A paid mint cannot steal the last slot reserved for a pending reserve
|
|
219
|
+
- `_numberOfPendingReservesFor` never returns more than `remainingSupply`
|
|
220
|
+
- The rounding-up in pending reserve calculation is correct and consistent
|
|
221
|
+
- Changing `defaultReserveBeneficiaryOf` cannot create ghost reserves or destroy legitimate ones
|
|
222
|
+
|
|
223
|
+
### 4. Cross-Currency Price Normalization
|
|
224
|
+
|
|
225
|
+
Two conversion points:
|
|
226
|
+
- `normalizePaymentValue` -- converts payment amount to pricing currency for tier price comparison
|
|
227
|
+
- `convertSplitAmounts` -- converts split amounts from pricing currency to payment token denomination
|
|
228
|
+
|
|
229
|
+
Verify that:
|
|
230
|
+
- When `address(prices) == address(0)` and currencies differ, `normalizePaymentValue` returns `(0, false)` and the hook skips minting (no silent fund loss)
|
|
231
|
+
- A reverting price feed blocks payments but does not lose funds
|
|
232
|
+
- Rounding through the conversion chain (normalize -> split calc -> convert back) does not systematically favor the attacker
|
|
233
|
+
- The ratio used in `convertSplitAmounts` is the inverse of what `normalizePaymentValue` uses (it should be -- verify)
|
|
234
|
+
|
|
235
|
+
### 5. Initialization and Clone Security
|
|
236
|
+
|
|
237
|
+
`JB721TiersHookDeployer` creates minimal proxy clones:
|
|
238
|
+
- `initialize()` is guarded by `PROJECT_ID != 0` (not `Initializable`)
|
|
239
|
+
- Ownership is transferred to `_msgSender()` inside `initialize`, then to the deployer caller in `deployHookFor`
|
|
240
|
+
|
|
241
|
+
Verify that:
|
|
242
|
+
- The implementation contract (HOOK) cannot be initialized (its `PROJECT_ID` is 0 by default -- can someone call initialize on it?)
|
|
243
|
+
- Deterministic salt derivation (`keccak256(abi.encode(_msgSender(), salt))`) prevents cross-deployer address collision
|
|
244
|
+
- Front-running `deployHookFor` cannot hijack ownership
|
|
245
|
+
|
|
246
|
+
### 6. Linked List Integrity
|
|
247
|
+
|
|
248
|
+
Tier sorting is maintained by `_tierIdAfter` mappings:
|
|
249
|
+
- `recordAddTiers` inserts new tiers into the sorted list
|
|
250
|
+
- `cleanTiers` removes gaps from the sorted list
|
|
251
|
+
- `_nextSortedTierIdOf` defaults to `id + 1` when no explicit next is stored
|
|
252
|
+
|
|
253
|
+
Verify that:
|
|
254
|
+
- Adding tiers to an existing set preserves the correct sort order
|
|
255
|
+
- Removing and re-adding tiers does not corrupt the linked list
|
|
256
|
+
- `cleanTiers` (permissionless) cannot be used to manipulate tier ordering in a way that affects minting or pricing
|
|
257
|
+
|
|
258
|
+
## Invariants
|
|
259
|
+
|
|
260
|
+
These must hold. If you can break any, it's a finding:
|
|
261
|
+
|
|
262
|
+
1. **Supply cap**: For every tier, `initialSupply - remainingSupply` (minted count) never exceeds `initialSupply`.
|
|
263
|
+
2. **Reserve protection**: After any `recordMint`, `remainingSupply >= numberOfPendingReserves` for that tier.
|
|
264
|
+
3. **Token ID uniqueness**: No two distinct mints produce the same `tokenId` (guaranteed by `initialSupply - --remainingSupply` pattern).
|
|
265
|
+
4. **Cash out weight conservation**: `totalCashOutWeight` equals the sum of `price * (mintedCount + pendingReserves)` across all tiers.
|
|
266
|
+
5. **Balance tracking**: `sum(tierBalanceOf[hook][owner][tierId])` across all owners equals `initialSupply - remainingSupply - burned` for each tier.
|
|
267
|
+
6. **Credit conservation**: Pay credits increase by leftover after minting, decrease by amount used for minting. Never negative.
|
|
268
|
+
7. **Linked list completeness**: Iterating from `_firstSortedTierIdOf(hook, 0)` via `_nextSortedTierIdOf` visits every non-removed tier exactly once.
|
|
269
|
+
8. **Discount bound**: `discountPercent <= DISCOUNT_DENOMINATOR (200)` for every stored tier.
|
|
270
|
+
9. **Removal idempotency**: Removing an already-removed tier is a no-op (bitmap set is idempotent).
|
|
271
|
+
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
|
+
|
|
273
|
+
## Testing Setup
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
cd nana-721-hook-v6
|
|
277
|
+
npm install
|
|
278
|
+
forge build
|
|
279
|
+
forge test
|
|
280
|
+
|
|
281
|
+
# Run with high verbosity
|
|
282
|
+
forge test -vvvv --match-test testExploitName
|
|
283
|
+
|
|
284
|
+
# Write a PoC
|
|
285
|
+
forge test --match-path test/audit/ExploitPoC.t.sol -vvv
|
|
286
|
+
|
|
287
|
+
# Run invariant tests
|
|
288
|
+
forge test --match-contract Invariant
|
|
289
|
+
|
|
290
|
+
# Gas analysis
|
|
291
|
+
forge test --gas-report
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Existing Test Coverage
|
|
295
|
+
|
|
296
|
+
| Category | Files | Coverage |
|
|
297
|
+
|----------|------:|---------|
|
|
298
|
+
| Unit tests | 10 | adjustTier, deployer, getters/constructor, mintFor/mintReservesFor, pay, redeem, tierSplitRouting, splitHookDistribution, JBBitmap, JBIpfsDecoder |
|
|
299
|
+
| Invariant tests | 2 + 2 handlers | TierLifecycleInvariant (6), TieredHookStoreInvariant (3) |
|
|
300
|
+
| Attack tests | 1 | 10 adversarial scenarios |
|
|
301
|
+
| Regression tests | 3 | L34, L35, L36 |
|
|
302
|
+
| E2E tests | 1 | Full lifecycle |
|
|
303
|
+
| Fork tests | 1 | Live chain state |
|
|
304
|
+
| Cross-currency | 1 | 9 tests for price feed behavior |
|
|
305
|
+
| Supply edge cases | 1 | M6 -- 4 targeted tests |
|
|
306
|
+
|
|
307
|
+
### Notable Coverage Gaps
|
|
308
|
+
|
|
309
|
+
1. ~~No reentrancy test for split distribution `.call{value}` or `terminal.pay()` path.~~ Mitigated: all external calls in `_sendPayoutToSplit` are now wrapped in try-catch. `TestAuditGaps_Reentrancy` confirms reentrancy is blocked by terminal check.
|
|
310
|
+
2. No gas limit test for operations with hundreds of tiers.
|
|
311
|
+
3. No test for malicious/reverting token URI resolver.
|
|
312
|
+
4. No test for `initialize()` front-running on deterministic clones.
|
|
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.
|
|
315
|
+
|
|
316
|
+
## How to Report Findings
|
|
317
|
+
|
|
318
|
+
For each finding:
|
|
319
|
+
|
|
320
|
+
1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
|
|
321
|
+
2. **Affected contract(s)** -- exact file path and line numbers
|
|
322
|
+
3. **Description** -- what's wrong, in plain language
|
|
323
|
+
4. **Trigger sequence** -- step-by-step, minimal steps to reproduce
|
|
324
|
+
5. **Impact** -- what an attacker gains, what a user loses (with numbers if possible)
|
|
325
|
+
6. **Proof** -- code trace showing the exact execution path, or a Foundry test
|
|
326
|
+
7. **Fix** -- minimal code change that resolves the issue
|
|
327
|
+
|
|
328
|
+
**Severity guide:**
|
|
329
|
+
- **CRITICAL**: Direct fund loss, permanent DoS, or broken core invariant. Exploitable with no preconditions.
|
|
330
|
+
- **HIGH**: Conditional fund loss, privilege escalation, or broken invariant. Requires specific but realistic setup.
|
|
331
|
+
- **MEDIUM**: Value leakage, griefing with cost to attacker, incorrect accounting, degraded functionality.
|
|
332
|
+
- **LOW**: Informational, cosmetic, edge-case-only with no material impact.
|
|
333
|
+
|
|
334
|
+
**Before reporting -- verify it's not a false positive:**
|
|
335
|
+
- Is the "bug" already documented in [RISKS.md](./RISKS.md)?
|
|
336
|
+
- Does cash out weight using original price (not discounted) look intentional? (It is.)
|
|
337
|
+
- Does `totalCashOutWeight` including pending reserves look wrong? (It's by design.)
|
|
338
|
+
- Is `DISCOUNT_DENOMINATOR = 200` surprising but correct? (It is.)
|
|
339
|
+
- Does the store's `msg.sender`-keyed trust model handle the case? (The store trusts the hook.)
|
|
340
|
+
- Is the economic attack profitable after the core protocol's 2.5% fee on cash outs?
|