@bananapus/721-hook-v6 0.0.22 → 0.0.24
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 +3 -3
- package/ARCHITECTURE.md +3 -3
- package/AUDIT_INSTRUCTIONS.md +13 -13
- package/CHANGE_LOG.md +3 -3
- package/RISKS.md +2 -2
- package/SKILLS.md +4 -4
- package/USER_JOURNEYS.md +3 -3
- package/assets/findings/nana-721-hook-v6-pashov-ai-audit-report-20260330-091257.md +83 -0
- package/package.json +4 -4
- package/src/JB721TiersHook.sol +23 -16
- package/src/JB721TiersHookStore.sol +15 -1
- package/src/libraries/JB721TiersHookLib.sol +139 -89
- package/test/audit/CodexPayCreditsBypassTierSplits.t.sol +196 -0
- package/test/audit/CodexSplitCreditsMismatch.t.sol +214 -0
- package/test/audit/CrossCurrencySplitNoPrices.t.sol +1 -1
- package/test/audit/SplitFailureRedistribution.t.sol +23 -3
- package/test/unit/AuditFixes_Unit.t.sol +611 -0
- package/test/unit/pay_CrossCurrency_Unit.t.sol +1 -1
package/ADMINISTRATION.md
CHANGED
|
@@ -39,7 +39,7 @@ Admin privileges and their scope in nana-721-hook-v6.
|
|
|
39
39
|
| `setDiscountPercentOf()` | `SET_721_DISCOUNT_PERCENT` | `owner()` | Sets the discount percentage for a single tier. |
|
|
40
40
|
| `setDiscountPercentsOf()` | `SET_721_DISCOUNT_PERCENT` | `owner()` | Batch-sets discount percentages for multiple tiers. |
|
|
41
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) | `
|
|
42
|
+
| `initialize()` | None (one-time) | `_initialized` flag check | Initializes a cloned hook. Can only be called once. Transfers ownership to caller on completion. |
|
|
43
43
|
|
|
44
44
|
### JB721TiersHookProjectDeployer
|
|
45
45
|
|
|
@@ -131,7 +131,7 @@ The following are set at deploy/initialization time and **cannot be changed afte
|
|
|
131
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
132
|
- Each clone has its own storage (including `PROJECT_ID`, ownership, and tier data). The implementation's storage is unused.
|
|
133
133
|
- `METADATA_ID_TARGET` is set to the original implementation address, ensuring consistent metadata ID derivation across all clones.
|
|
134
|
-
- The `initialize()` function uses
|
|
134
|
+
- The `initialize()` function uses an `_initialized` bool flag to prevent re-initialization. The implementation contract's constructor sets `_initialized = true`, blocking direct initialization. Clones start with `_initialized = false` and set it to `true` during `initialize()`.
|
|
135
135
|
|
|
136
136
|
## Ruleset-Level Pauses
|
|
137
137
|
|
|
@@ -155,7 +155,7 @@ What the hook owner **cannot** do:
|
|
|
155
155
|
- **Cannot remove a tier marked `cannotBeRemoved`.** The store enforces this in `recordRemoveTierIds()`.
|
|
156
156
|
- **Cannot increase a tier's discount if `cannotIncreaseDiscountPercent` is set.** The store enforces this in `recordSetDiscountPercentOf()`.
|
|
157
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 `
|
|
158
|
+
- **Cannot re-initialize a hook.** The `initialize()` function reverts if `_initialized` is already true.
|
|
159
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.
|
|
160
160
|
- **Cannot bypass the flag restrictions.** Once `noNewTiersWithReserves`, `noNewTiersWithVotes`, or `noNewTiersWithOwnerMinting` are set, all future tiers added via `adjustTiers()` must comply.
|
|
161
161
|
- **Cannot mint more reserves than the formula allows.** Reserve mints are bounded by `ceil(nonReserveMints / reserveFrequency)`.
|
package/ARCHITECTURE.md
CHANGED
|
@@ -36,7 +36,7 @@ src/
|
|
|
36
36
|
User → JBMultiTerminal.pay(metadata)
|
|
37
37
|
→ beforePayRecordedWith()
|
|
38
38
|
→ calculateSplitAmounts(): per-tier split amounts (in tier pricing denomination)
|
|
39
|
-
→
|
|
39
|
+
→ convertAndCapSplitAmounts(): convert to payment token denomination (if currencies differ)
|
|
40
40
|
→ calculateWeight(): adjust weight down by split fraction
|
|
41
41
|
→ JBTerminalStore records payment
|
|
42
42
|
→ afterPayRecordedWith() → _processPayment()
|
|
@@ -56,7 +56,7 @@ Tiers can be priced in a different currency than the payment token (e.g. tiers p
|
|
|
56
56
|
|
|
57
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
58
|
|
|
59
|
-
2. **`
|
|
59
|
+
2. **`convertAndCapSplitAmounts`** — 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`, and caps the total at the actual payment value. This is necessary because the terminal will subtract the split amount from the payment value, so both must be in the same unit.
|
|
60
60
|
|
|
61
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
62
|
|
|
@@ -110,7 +110,7 @@ Owner → JB721TiersHook.adjustTiers()
|
|
|
110
110
|
|
|
111
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
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.
|
|
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. Failed amounts are accumulated separately during the loop and routed to the project balance afterward, ensuring proportional redistribution -- later recipients receive only their configured share, not an inflated share from earlier failures.
|
|
114
114
|
|
|
115
115
|
## Dependencies
|
|
116
116
|
- `@bananapus/core-v6` — Core protocol interfaces
|
package/AUDIT_INSTRUCTIONS.md
CHANGED
|
@@ -19,17 +19,17 @@ Source: [`foundry.toml`](./foundry.toml)
|
|
|
19
19
|
|
|
20
20
|
## Previous Audit Findings
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
An automated audit was conducted on 2026-03-17. Summary:
|
|
23
23
|
|
|
24
|
-
|
|
|
25
|
-
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
|
|
|
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
31
|
|
|
32
|
-
|
|
32
|
+
See [RISKS.md](./RISKS.md) for the project's own risk assessment.
|
|
33
33
|
|
|
34
34
|
## Error Reference
|
|
35
35
|
|
|
@@ -52,7 +52,7 @@ No prior formal audit with finding IDs from an external security firm has been c
|
|
|
52
52
|
| `JB721TiersHookStore_UnrecognizedTier(uint256 tierId)` | JB721TiersHookStore | Any operation referencing a `tierId` that does not exist (> `maxTierIdOf` or never created) |
|
|
53
53
|
| `JB721TiersHookStore_VotingUnitsNotAllowed(uint256 tierId)` | JB721TiersHookStore | `recordAddTiers` with `votingUnits > 0` when `noNewTiersWithVotes` flag is set |
|
|
54
54
|
| `JB721TiersHookStore_ZeroInitialSupply(uint256 tierId)` | JB721TiersHookStore | `recordAddTiers` with `initialSupply == 0` |
|
|
55
|
-
| `JB721TiersHook_AlreadyInitialized(uint256 projectId)` | JB721TiersHook | `initialize()` called on a hook
|
|
55
|
+
| `JB721TiersHook_AlreadyInitialized(uint256 projectId)` | JB721TiersHook | `initialize()` called on a hook where `_initialized` is already true |
|
|
56
56
|
| `JB721TiersHook_CurrencyMismatch(uint256 paymentCurrency, uint256 tierCurrency)` | JB721TiersHook | Payment currency differs from tier pricing currency and no price feed is configured |
|
|
57
57
|
| `JB721TiersHook_InvalidPricingDecimals(uint256 decimals)` | JB721TiersHook | `initialize()` with `pricingDecimals > 18` |
|
|
58
58
|
| `JB721TiersHook_MintReserveNftsPaused()` | JB721TiersHook | `mintPendingReservesFor` called when `mintPendingReservesPaused` ruleset flag is active |
|
|
@@ -295,11 +295,11 @@ Verify that:
|
|
|
295
295
|
### 5. Initialization and Clone Security
|
|
296
296
|
|
|
297
297
|
`JB721TiersHookDeployer` creates minimal proxy clones:
|
|
298
|
-
- `initialize()` is guarded by
|
|
298
|
+
- `initialize()` is guarded by an `_initialized` bool flag
|
|
299
299
|
- Ownership is transferred to `_msgSender()` inside `initialize`, then to the deployer caller in `deployHookFor`
|
|
300
300
|
|
|
301
301
|
Verify that:
|
|
302
|
-
- The implementation contract
|
|
302
|
+
- The implementation contract's constructor sets `_initialized = true`, preventing initialization.
|
|
303
303
|
- Deterministic salt derivation (`keccak256(abi.encode(_msgSender(), salt))`) prevents cross-deployer address collision
|
|
304
304
|
- Front-running `deployHookFor` cannot hijack ownership
|
|
305
305
|
|
|
@@ -339,7 +339,7 @@ These must hold. If you can break any, it's a finding:
|
|
|
339
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
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
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 `
|
|
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
343
|
| `_mint` instead of `_safeMint` | `JB721TiersHook` | No `onERC721Received` callback. Prevents mint-time DoS but contracts won't detect incoming NFTs. |
|
|
344
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
345
|
|
package/CHANGE_LOG.md
CHANGED
|
@@ -105,7 +105,7 @@ Contains functions extracted from `JB721TiersHook` to stay within the EIP-170 co
|
|
|
105
105
|
| `recordAddTiersFor(...)` | Records new tiers and sets their split groups (used during initialization) |
|
|
106
106
|
| `normalizePaymentValue(...)` | Normalizes payment value based on pricing context |
|
|
107
107
|
| `calculateSplitAmounts(...)` | Calculates per-tier split amounts for a pay event |
|
|
108
|
-
| `
|
|
108
|
+
| `convertAndCapSplitAmounts(...)` | Converts split amounts between currencies, capped at payment value |
|
|
109
109
|
| `calculateWeight(...)` | Adjusts minting weight to account for split amounts |
|
|
110
110
|
| `distributeAll(...)` | Pulls tokens and distributes forwarded funds to tier split recipients |
|
|
111
111
|
| `resolveTokenURI(...)` | Resolves the token URI (moved IPFS decoding out of hook) |
|
|
@@ -133,7 +133,7 @@ Tier split payouts now support the `split.hook` field (`IJBSplitHook`). The dist
|
|
|
133
133
|
|
|
134
134
|
### 2.4 DOS Protection on Split Distribution
|
|
135
135
|
|
|
136
|
-
All external calls during tier split distribution (split hooks, terminal `pay`/`addToBalanceOf`, and leftover distribution) are wrapped in try-catch. A reverting split recipient or terminal cannot brick payments to the project. On failure,
|
|
136
|
+
All external calls during tier split distribution (split hooks, terminal `pay`/`addToBalanceOf`, and leftover distribution) are wrapped in try-catch. A reverting split recipient or terminal cannot brick payments to the project. On failure, the failed amount is accumulated separately and routed to the project balance after the distribution loop completes. This proportional redistribution ensures that later split recipients receive only their configured share, not an inflated share from the failed recipient's funds. A `SplitPayoutReverted` or `AddToBalanceReverted` event is emitted with the revert reason. For ERC-20 split hook failures, tokens are transferred first and the hook callback is best-effort; for ERC-20 terminal failures, the approval is reset to zero.
|
|
137
137
|
|
|
138
138
|
### 2.5 `splitPercent` Bounds Validation
|
|
139
139
|
|
|
@@ -280,7 +280,7 @@ Split payouts follow the same priority order as `JBMultiTerminal`: if `split.hoo
|
|
|
280
280
|
|
|
281
281
|
### 6.9 `JB721TiersHookLib` — Try-Catch on All External Calls
|
|
282
282
|
|
|
283
|
-
All external calls during split distribution (split hooks, terminal `pay`, terminal `addToBalanceOf`, and leftover `addToBalanceOf`) are wrapped in try-catch. On failure:
|
|
283
|
+
All external calls during split distribution (split hooks, terminal `pay`, terminal `addToBalanceOf`, and leftover `addToBalanceOf`) are wrapped in try-catch. On failure: the failed amount is accumulated separately and routed to the project balance after the distribution loop, ensuring proportional (not equal) redistribution among remaining recipients. Native token calls return false (funds stay in the hook), ERC-20 terminal call approvals are reset to zero, and `SplitPayoutReverted` or `AddToBalanceReverted` events are emitted with the revert reason.
|
|
284
284
|
|
|
285
285
|
### 6.10 `JB721TiersHookLib.calculateSplitAmounts` — Discount Applied Before Splits
|
|
286
286
|
|
package/RISKS.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
- **Tier configuration is partially immutable.** Once created: `price`, `initialSupply`, `reserveFrequency`, `category`, `votingUnits`, `splitPercent` are permanent. Mutable: `discountPercent` (owner-controlled, subject to `cannotIncreaseDiscountPercent`), `encodedIPFSUri` (owner-controlled).
|
|
7
7
|
- **Category sort order is enforced only at insertion.** `recordAddTiers` reverts `InvalidCategorySortOrder` if tiers are not ascending by category. The sorted linked list (`_tierIdAfter`) depends on this invariant across all `adjustTiers` calls. Direct store callers could corrupt the list.
|
|
8
8
|
- **`useReserveBeneficiaryAsDefault` has global side effects.** Setting this on ANY new tier overwrites `defaultReserveBeneficiaryOf` for ALL existing tiers that lack a tier-specific `_reserveBeneficiaryOf` entry. Documented but dangerous when calling `adjustTiers` on hooks with existing tiers.
|
|
9
|
-
- **Clone initialization is one-shot, atomic.** `initialize()` guards via `
|
|
9
|
+
- **Clone initialization is one-shot, atomic.** `initialize()` guards via an `_initialized` bool flag. The implementation contract's constructor sets `_initialized = true`, blocking direct initialization. Clones start with `_initialized = false` and set it to `true` during `initialize()`. After `initialize()` sets it, any subsequent call reverts. Deployer contracts call deploy+initialize in a single transaction, preventing front-running. Ownership transfers to `_msgSender()` at the end of `initialize`.
|
|
10
10
|
- **`balanceOf(address(0))` reverts.** Unlike the standard ERC-721 `balanceOf` which may return zero, the hook overrides `balanceOf` to revert with `JB721TiersHook_ZeroAddress` when called with `address(0)`. This guards against misleading zero-balance results for the zero address.
|
|
11
11
|
- **`tokenURI` reverts for nonexistent tokens.** Calling `tokenURI` with a token ID that has never been minted reverts with `ERC721NonexistentToken(tokenId)`. The check is `_ownerOf(tokenId) == address(0)`.
|
|
12
12
|
- **JBDirectory is trusted for terminal authentication.** `afterPayRecordedWith` and `afterCashOutRecordedWith` check `DIRECTORY.isTerminalOf()`. If the directory is compromised, arbitrary addresses can invoke pay/cashout hooks.
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
## 3. Reentrancy Surface
|
|
28
28
|
|
|
29
29
|
- **Split hook callbacks (`processSplitWith`).** During `afterPayRecordedWith` -> `_processPayment` -> `distributeAll`, the library calls `split.hook.processSplitWith{value}()` for each split with a hook. This executes arbitrary code. At callback time: NFTs already minted, `payCreditsOf` updated, `remainingSupply` decremented in the store. Reentering `afterPayRecordedWith` requires terminal authentication and processes as an independent payment. All split hook and terminal calls are wrapped in try-catch to prevent a single reverting recipient from bricking all payments to the project. For native token hooks, a revert returns false (ETH stays in the contract and routes to project balance). For ERC20 hooks, tokens are transferred before the callback; a revert still returns true because the tokens have already left the contract. Tested: `TestAuditGaps_Reentrancy` confirms reentrancy is blocked by terminal check.
|
|
30
|
-
- **Split beneficiary ETH sends.** `_sendPayoutToSplit` uses `beneficiary.call{value: amount}("")`. If the beneficiary reverts, the function returns `false` and
|
|
30
|
+
- **Split beneficiary ETH sends.** `_sendPayoutToSplit` uses `beneficiary.call{value: amount}("")`. If the beneficiary reverts, the function returns `false` and the failed amount is accumulated separately, then routed to the project's balance after the distribution loop via `addToBalanceOf`. Later split recipients receive only their proportional share, not the failed recipient's share. Does not revert the entire payment.
|
|
31
31
|
- **Terminal `.pay()` / `.addToBalanceOf()` during split distribution.** For project-targeted splits, the library calls the target project's primary terminal via try-catch. A reverting terminal returns false, routing the funds to the project's balance instead. For ERC20 terminal calls, approval is reset to zero on failure to prevent dangling approvals. The target terminal could call back into the hook, but the hook's state is fully settled (supply, credits, mint state). Reentrancy through this path cannot double-mint or corrupt state.
|
|
32
32
|
- **`afterCashOutRecordedWith` execution order.** Burns tokens via `_burn()` -> `_update()` -> `STORE.recordTransferForTier()` in a loop, then calls `STORE.recordBurn()`. ERC721 `_update` triggers the store's tier balance decrement. Burns go to `address(0)`, so no `onERC721Received` callback.
|
|
33
33
|
- **No `ReentrancyGuard`.** Protection relies on state ordering (all `STORE.record*` calls before external calls), terminal authentication checks, and try-catch wrapping of all external calls in `_sendPayoutToSplit`. `_mint()` uses the non-safe variant, avoiding `onERC721Received` callbacks during minting.
|
package/SKILLS.md
CHANGED
|
@@ -60,7 +60,7 @@ Tiered ERC-721 NFT hook for Juicebox V6 that mints NFTs when a project is paid a
|
|
|
60
60
|
| `votingUnitsOf(hook, account)` | `JB721TiersHookStore` | Returns total voting units for an address across all tiers. |
|
|
61
61
|
| `tierVotingUnitsOf(hook, account, tierId)` | `JB721TiersHookStore` | Returns voting units for an address within a specific tier. |
|
|
62
62
|
| `calculateSplitAmounts(store, hook, metadataIdTarget, metadata)` | `JB721TiersHookLib` | Decodes tier IDs from metadata, computes per-tier split amounts from effective price and `splitPercent`. |
|
|
63
|
-
| `
|
|
63
|
+
| `convertAndCapSplitAmounts(totalSplitAmount, splitMetadata, packedPricingContext, prices, projectId, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts split amounts from tier pricing denomination to payment token denomination via `JBPrices`, capped at the actual payment value. |
|
|
64
64
|
| `distributeAll(directory, splits, projectId, hookAddress, token, amount, decimals, encodedSplitData)` | `JB721TiersHookLib` | Distributes forwarded split funds to tier split recipients. Leftover goes to project balance via `addToBalance`. |
|
|
65
65
|
| `adjustTiersFor(store, splits, projectId, hookAddress, caller, tiersToAdd, tierIdsToRemove)` | `JB721TiersHookLib` | Called via DELEGATECALL from `adjustTiers`. Removes tiers, adds tiers, emits events, registers splits. |
|
|
66
66
|
| `normalizePaymentValue(packedPricingContext, prices, projectId, amountValue, amountCurrency, amountDecimals)` | `JB721TiersHookLib` | Converts a payment value to the hook's pricing currency using `JBPrices`. |
|
|
@@ -144,11 +144,11 @@ Each tier has configurable voting power:
|
|
|
144
144
|
- Applies the tier's `discountPercent` to derive the effective price.
|
|
145
145
|
- Computes `mulDiv(effectivePrice, splitPercent, SPLITS_TOTAL_PERCENT)` per tier.
|
|
146
146
|
- Returns the total to be forwarded to the hook.
|
|
147
|
-
- If the payment currency differs from the tier pricing currency, `
|
|
147
|
+
- If the payment currency differs from the tier pricing currency, `convertAndCapSplitAmounts` converts amounts to the payment token denomination via `JBPrices` and caps the total at the actual payment value.
|
|
148
148
|
- **Pay credits cap**: `totalSplitAmount` is capped at `context.amount.value` before weight calculation and before being returned in the hook specification. Pay credits fund NFT minting (virtual -- they reduce the price threshold in `recordMint`), but splits require real tokens to distribute. Without this cap, a payer with sufficient pay credits but insufficient actual payment value would cause the terminal to attempt forwarding more tokens than were actually received, reverting the transaction. This means the project receiving the NFT payment must have received the full amount including what would have gone to splits -- split recipients are paid from the project's balance, not directly from the payer's contribution.
|
|
149
149
|
- Weight is adjusted down proportionally (`weight = mulDiv(weight, amount - totalSplitAmount, amount)`) unless the `issueTokensForSplits` flag is set, in which case the full `context.weight` is returned.
|
|
150
150
|
- In `afterPayRecordedWith`, `distributeAll` distributes forwarded funds to each tier's split group recipients. Leftover after all splits goes back to the project's balance via `addToBalance`.
|
|
151
|
-
- **Leftover accounting in `_distributeSingleSplit`**: `leftoverAmount` is always decremented **before** the send attempt. If `_sendPayoutToSplit` returns `false` (i.e., the send reverted or had no valid recipient), the amount is added
|
|
151
|
+
- **Leftover accounting in `_distributeSingleSplit`**: `leftoverAmount` is always decremented **before** the send attempt. If `_sendPayoutToSplit` returns `false` (i.e., the send reverted or had no valid recipient), the failed amount is accumulated in a separate variable (not added back to `leftoverAmount`). After the loop completes, accumulated failed amounts are added to `leftoverAmount` and routed to the project's balance. This "decrement-first, accumulate-failures-separately" pattern uses proportional redistribution: a failed split does not inflate subsequent split recipients' shares because the failed amount is withheld from `leftoverAmount` during the loop, and `leftoverPercentage` still decreases regardless of success.
|
|
152
152
|
- Split recipients follow the same priority chain as `JBMultiTerminal`: `split.hook` > `split.projectId` > `split.beneficiary`:
|
|
153
153
|
- **Split hooks**: receive a `JBSplitHookContext` struct (`token`, `amount`, `decimals`, `projectId`, `groupId`, full `split`). Native tokens forwarded via `{value: amount}`; ERC-20s transferred via `SafeERC20.safeTransfer` before calling `processSplitWith`.
|
|
154
154
|
- **Project splits**: route via `terminal.pay` or `terminal.addToBalance`.
|
|
@@ -199,7 +199,7 @@ Each tier has configurable voting power:
|
|
|
199
199
|
| `JB721Hook_InvalidPay()` | `JB721Hook` | `afterPayRecordedWith` caller is not a project terminal. |
|
|
200
200
|
| `JB721Hook_UnauthorizedToken(tokenId, holder)` | `JB721Hook` | Cash out attempts to burn a token not owned by `context.holder`. |
|
|
201
201
|
| `JB721Hook_UnexpectedTokenCashedOut()` | `JB721Hook` | `beforeCashOutRecordedWith` called with `cashOutCount > 0` (fungible tokens mixed with NFT cash out). |
|
|
202
|
-
| `JB721TiersHook_AlreadyInitialized(projectId)` | `JB721TiersHook` | `initialize` called on a hook
|
|
202
|
+
| `JB721TiersHook_AlreadyInitialized(projectId)` | `JB721TiersHook` | `initialize` called on a hook where `_initialized` is already true. |
|
|
203
203
|
| `JB721TiersHook_CurrencyMismatch(paymentCurrency, tierCurrency)` | `JB721TiersHook` | Payment currency differs from tier pricing currency and no `PRICES` contract is configured for conversion. |
|
|
204
204
|
| `JB721TiersHook_InvalidPricingDecimals(decimals)` | `JB721TiersHook` | `initialize` called with `decimals > 18`. |
|
|
205
205
|
| `JB721TiersHook_MintReserveNftsPaused()` | `JB721TiersHook` | `mintPendingReservesFor` called while `pauseMintPendingReserves` is set in the current ruleset metadata. |
|
package/USER_JOURNEYS.md
CHANGED
|
@@ -47,7 +47,7 @@ A user pays a Juicebox project and receives tiered NFTs based on the amount paid
|
|
|
47
47
|
- **Last slot is reserved**: If minting would leave `remainingSupply < pendingReserves`, reverts with `JB721TiersHookStore_InsufficientSupplyRemaining`.
|
|
48
48
|
- **Cross-currency payment with no price feed**: `normalizePaymentValue` returns `(0, false)`. The hook returns without minting or reverting. The payment is still processed by the terminal.
|
|
49
49
|
- **Discounted tier**: Effective price is `price - mulDiv(price, discountPercent, 200)`. A `discountPercent` of 200 makes it free.
|
|
50
|
-
- **Split distribution**: If a split recipient's
|
|
50
|
+
- **Split distribution**: If a split recipient's payout reverts, the failed amount is accumulated separately and added to the project's balance after the loop. Later split recipients receive only their proportional share, not the failed recipient's share.
|
|
51
51
|
|
|
52
52
|
---
|
|
53
53
|
|
|
@@ -330,11 +330,11 @@ Deploy a 721 tiers hook for an existing project.
|
|
|
330
330
|
- `SetDefaultReserveBeneficiary(hook, newBeneficiary, caller)` -- if any initial tier has `useReserveBeneficiaryAsDefault = true`.
|
|
331
331
|
|
|
332
332
|
**Edge cases**:
|
|
333
|
-
- **Re-initialization**: Reverts with `JB721TiersHook_AlreadyInitialized` if `
|
|
333
|
+
- **Re-initialization**: Reverts with `JB721TiersHook_AlreadyInitialized` if `_initialized` is already true.
|
|
334
334
|
- **`projectId == 0`**: Reverts with `JB721TiersHook_NoProjectId`.
|
|
335
335
|
- **`decimals > 18`**: Reverts with `JB721TiersHook_InvalidPricingDecimals`.
|
|
336
336
|
- **Deterministic salt collision**: Reverts at the EVM level (CREATE2 collision).
|
|
337
|
-
- **Implementation contract**: The
|
|
337
|
+
- **Implementation contract**: The implementation contract's constructor sets `_initialized = true`, so no one can call `initialize` on it.
|
|
338
338
|
|
|
339
339
|
---
|
|
340
340
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# 🔐 Security Review — nana-721-hook-v6
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
| | |
|
|
8
|
+
| -------------------------------- | ------------------------------------------------------ |
|
|
9
|
+
| **Mode** | ALL / default |
|
|
10
|
+
| **Files reviewed** | `script/Deploy.s.sol` · `script/helpers/Hook721DeploymentLib.sol` · `src/JB721TiersHook.sol`<br>`src/JB721TiersHookDeployer.sol` · `src/JB721TiersHookProjectDeployer.sol` · `src/JB721TiersHookStore.sol`<br>`src/abstract/ERC721.sol` · `src/abstract/JB721Hook.sol` · `src/libraries/JB721Constants.sol`<br>`src/libraries/JB721TiersHookLib.sol` · `src/libraries/JB721TiersRulesetMetadataResolver.sol` · `src/libraries/JBBitmap.sol`<br>`src/libraries/JBIpfsDecoder.sol` · `src/structs/JB721InitTiersConfig.sol` · `src/structs/JB721Tier.sol`<br>`src/structs/JB721TierConfig.sol` · `src/structs/JB721TiersHookFlags.sol` · `src/structs/JB721TiersMintReservesConfig.sol`<br>`src/structs/JB721TiersRulesetMetadata.sol` · `src/structs/JB721TiersSetDiscountPercentConfig.sol` · `src/structs/JBBitmapWord.sol`<br>`src/structs/JBDeploy721TiersHookConfig.sol` · `src/structs/JBLaunchProjectConfig.sol` · `src/structs/JBLaunchRulesetsConfig.sol`<br>`src/structs/JBPayDataHookRulesetConfig.sol` · `src/structs/JBPayDataHookRulesetMetadata.sol` · `src/structs/JBQueueRulesetsConfig.sol`<br>`src/structs/JBStored721Tier.sol` |
|
|
11
|
+
| **Confidence threshold (1-100)** | 75 |
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Findings
|
|
16
|
+
|
|
17
|
+
[90] **1. Pay Credits Let Buyers Bypass Tier Split Payments**
|
|
18
|
+
|
|
19
|
+
`JB721TiersHook._mintAndUpdateCredits` · Confidence: 90
|
|
20
|
+
|
|
21
|
+
**Description**
|
|
22
|
+
`payCreditsOf` is merged into NFT purchasing power, but tier split payouts are still capped to `context.forwardedAmount.value`, so a buyer can mint a split-bearing tier mostly with credits while paying only a dust-sized fresh split.
|
|
23
|
+
|
|
24
|
+
**Fix**
|
|
25
|
+
|
|
26
|
+
```diff
|
|
27
|
+
- leftoverAmount += payCredits;
|
|
28
|
+
- if (context.hookMetadata.length != 0 && context.forwardedAmount.value != 0) {
|
|
29
|
+
- JB721TiersHookLib.distributeAll(... amount: context.forwardedAmount.value, ...);
|
|
30
|
+
- }
|
|
31
|
+
+ uint256 creditBackedAmount = context.payer == context.beneficiary ? payCredits : 0;
|
|
32
|
+
+ uint256 splitFundingAmount = context.forwardedAmount.value + creditBackedAmountUsedForMint;
|
|
33
|
+
+ if (context.hookMetadata.length != 0 && splitFundingAmount != 0) {
|
|
34
|
+
+ JB721TiersHookLib.distributeAll(... amount: splitFundingAmount, ...);
|
|
35
|
+
+ }
|
|
36
|
+
```
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
[90] **2. Failed Early Splits Are Redistributed to Later Recipients**
|
|
40
|
+
|
|
41
|
+
`JB721TiersHookLib._distributeSingleSplit` · Confidence: 90
|
|
42
|
+
|
|
43
|
+
**Description**
|
|
44
|
+
When an early split payout fails, its amount is added back into `leftoverAmount` before later splits are calculated, so later recipients can receive the failed recipient’s share instead of that value falling back to project balance.
|
|
45
|
+
|
|
46
|
+
**Fix**
|
|
47
|
+
|
|
48
|
+
```diff
|
|
49
|
+
- if (!_sendPayoutToSplit(...)) {
|
|
50
|
+
- leftoverAmount += payoutAmount;
|
|
51
|
+
- }
|
|
52
|
+
+ if (!_sendPayoutToSplit(...)) {
|
|
53
|
+
+ failedAmount += payoutAmount;
|
|
54
|
+
+ }
|
|
55
|
+
...
|
|
56
|
+
- if (leftoverAmount != 0) {
|
|
57
|
+
- terminal.addToBalanceOf(... leftoverAmount ...);
|
|
58
|
+
+ uint256 amountToProject = leftoverAmount + failedAmount;
|
|
59
|
+
+ if (amountToProject != 0) {
|
|
60
|
+
+ terminal.addToBalanceOf(... amountToProject ...);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
Findings List
|
|
66
|
+
|
|
67
|
+
| # | Confidence | Title |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| 1 | [90] | Pay Credits Let Buyers Bypass Tier Split Payments |
|
|
70
|
+
| 2 | [90] | Failed Early Splits Are Redistributed to Later Recipients |
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Leads
|
|
75
|
+
|
|
76
|
+
_Vulnerability trails with concrete code smells where the full exploit path could not be completed in one analysis pass. These are not false positives — they are high-signal leads for manual review. Not scored._
|
|
77
|
+
|
|
78
|
+
- **Public Address Registry Can Grief Hook Deployments** — `JB721TiersHookDeployer.deployHookFor` — Code smells: deployment success depends on `JBAddressRegistry.registerAddress`, and the registry is permissionless plus duplicate-registration-reverting — A third party can likely pre-register a predicted hook address and make `deployHookFor` revert at the registry step, but the practical griefing envelope depends on deployment mode and the caller’s ability to retry with a different salt.
|
|
79
|
+
- **Shared `_nonce` Can Desync Registry Provenance After Mixed CREATE/CREATE2 Deployments** — `JB721TiersHookDeployer.deployHookFor` — Code smells: `_nonce` is incremented for both deterministic and non-deterministic deployments, while only the CREATE path consumes the deployer nonce the registry models — Mixed deployment modes may cause later saltless registrations to point at the wrong CREATE address, but I did not complete an end-to-end exploit showing downstream trust assumptions being violated.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
> ⚠️ This review was performed by an AI assistant. AI analysis can never verify the complete absence of vulnerabilities and no guarantee of security is given. Team security reviews, bug bounty programs, and on-chain monitoring are strongly recommended. For a consultation regarding your projects' security, visit [https://www.pashov.com](https://www.pashov.com)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/721-hook-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@bananapus/address-registry-v6": "^0.0.16",
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
22
|
-
"@bananapus/ownable-v6": "^0.0.
|
|
23
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
21
|
+
"@bananapus/core-v6": "^0.0.30",
|
|
22
|
+
"@bananapus/ownable-v6": "^0.0.16",
|
|
23
|
+
"@bananapus/permission-ids-v6": "^0.0.15",
|
|
24
24
|
"@openzeppelin/contracts": "^5.6.1",
|
|
25
25
|
"@prb/math": "^4.1.0",
|
|
26
26
|
"solady": "^0.1.8"
|
package/src/JB721TiersHook.sol
CHANGED
|
@@ -65,6 +65,15 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
65
65
|
/// @notice The contract that stores and manages splits.
|
|
66
66
|
IJBSplits public immutable override SPLITS;
|
|
67
67
|
|
|
68
|
+
//*********************************************************************//
|
|
69
|
+
// --------------------- private stored properties ------------------ //
|
|
70
|
+
//*********************************************************************//
|
|
71
|
+
|
|
72
|
+
/// @notice Whether this contract has been initialized. Used to prevent re-initialization of both the
|
|
73
|
+
/// implementation contract itself and its clones.
|
|
74
|
+
/// @dev Internal (not private) so test harnesses that extend this contract can reset it in their constructors.
|
|
75
|
+
bool internal _initialized;
|
|
76
|
+
|
|
68
77
|
//*********************************************************************//
|
|
69
78
|
// ---------------------- public stored properties ------------------- //
|
|
70
79
|
//*********************************************************************//
|
|
@@ -123,6 +132,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
123
132
|
RULESETS = rulesets;
|
|
124
133
|
STORE = store;
|
|
125
134
|
SPLITS = splits;
|
|
135
|
+
|
|
136
|
+
// Prevent the implementation contract from being initialized.
|
|
137
|
+
_initialized = true;
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
//*********************************************************************//
|
|
@@ -189,35 +201,28 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
189
201
|
store: STORE, hook: address(this), metadataIdTarget: METADATA_ID_TARGET, metadata: context.metadata
|
|
190
202
|
});
|
|
191
203
|
|
|
192
|
-
// Convert split amounts from tier pricing to payment token denomination if currencies differ
|
|
204
|
+
// Convert split amounts from tier pricing to payment token denomination (if currencies differ)
|
|
205
|
+
// and cap at the actual payment value so the terminal never forwards more than was paid.
|
|
193
206
|
if (totalSplitAmount != 0) {
|
|
194
|
-
(totalSplitAmount, splitMetadata) = JB721TiersHookLib.
|
|
207
|
+
(totalSplitAmount, splitMetadata) = JB721TiersHookLib.convertAndCapSplitAmounts({
|
|
195
208
|
totalSplitAmount: totalSplitAmount,
|
|
196
209
|
splitMetadata: splitMetadata,
|
|
197
210
|
packedPricingContext: _packedPricingContext,
|
|
198
211
|
prices: PRICES,
|
|
199
212
|
projectId: context.projectId,
|
|
200
213
|
amountCurrency: context.amount.currency,
|
|
201
|
-
amountDecimals: context.amount.decimals
|
|
214
|
+
amountDecimals: context.amount.decimals,
|
|
215
|
+
amountValue: context.amount.value
|
|
202
216
|
});
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
// Cap the split amount at the actual payment value. Pay credits fund NFT minting (virtual), but splits
|
|
206
|
-
// require real tokens to distribute. Without this cap, a user with sufficient pay credits but insufficient
|
|
207
|
-
// ETH would revert because the terminal can't forward more than what was actually paid.
|
|
208
|
-
// This requires the project receiving the NFT payment to have received the full amount including what
|
|
209
|
-
// would have gone to splits — the split recipients get paid from the project's balance, not directly
|
|
210
|
-
// from the payer's contribution.
|
|
211
|
-
if (totalSplitAmount > context.amount.value) {
|
|
212
|
-
totalSplitAmount = context.amount.value;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
219
|
// Adjust weight so the terminal mints tokens only for the amount that actually enters the project.
|
|
216
220
|
weight = JB721TiersHookLib.calculateWeight({
|
|
217
221
|
contextWeight: context.weight,
|
|
218
222
|
amountValue: context.amount.value,
|
|
219
223
|
totalSplitAmount: totalSplitAmount,
|
|
220
|
-
|
|
224
|
+
store: STORE,
|
|
225
|
+
hook: address(this)
|
|
221
226
|
});
|
|
222
227
|
|
|
223
228
|
hookSpecifications[0] =
|
|
@@ -256,8 +261,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
256
261
|
public
|
|
257
262
|
override
|
|
258
263
|
{
|
|
259
|
-
// Stop re-initialization
|
|
260
|
-
|
|
264
|
+
// Stop re-initialization. This protects both the implementation contract (initialized in constructor)
|
|
265
|
+
// and clones (initialized via this function).
|
|
266
|
+
if (_initialized) revert JB721TiersHook_AlreadyInitialized(PROJECT_ID);
|
|
267
|
+
_initialized = true;
|
|
261
268
|
|
|
262
269
|
// Make sure a projectId is provided.
|
|
263
270
|
if (projectId == 0) revert JB721TiersHook_NoProjectId();
|
|
@@ -749,7 +749,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
749
749
|
|
|
750
750
|
// Make the sorted array.
|
|
751
751
|
while (currentSortedTierId != 0) {
|
|
752
|
-
//
|
|
752
|
+
// Check if the current tier has been removed.
|
|
753
753
|
if (!_isTierRemovedWithRefresh({hook: hook, tierId: currentSortedTierId, bitmapWord: bitmapWord})) {
|
|
754
754
|
// Update its `_tierIdAfter` if needed.
|
|
755
755
|
if (currentSortedTierId != previousSortedTierId + 1) {
|
|
@@ -763,8 +763,22 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
763
763
|
_tierIdAfter[hook][previousSortedTierId] = 0;
|
|
764
764
|
}
|
|
765
765
|
|
|
766
|
+
// If this tier's category has a stale `_startingTierIdOfCategory` (reset to 0 because the
|
|
767
|
+
// previous starting tier was removed), set this tier as the new starting tier for its category.
|
|
768
|
+
uint256 tierCategory = _storedTierOf[hook][currentSortedTierId].category;
|
|
769
|
+
if (tierCategory != 0 && _startingTierIdOfCategory[hook][tierCategory] == 0) {
|
|
770
|
+
_startingTierIdOfCategory[hook][tierCategory] = currentSortedTierId;
|
|
771
|
+
}
|
|
772
|
+
|
|
766
773
|
// Iterate by setting the previous tier ID for the next loop to the current tier ID.
|
|
767
774
|
previousSortedTierId = currentSortedTierId;
|
|
775
|
+
} else {
|
|
776
|
+
// The tier was removed. If it was the `_startingTierIdOfCategory` for its category, reset the
|
|
777
|
+
// pointer so the next non-removed tier in the same category can claim it.
|
|
778
|
+
uint256 removedCategory = _storedTierOf[hook][currentSortedTierId].category;
|
|
779
|
+
if (removedCategory != 0 && _startingTierIdOfCategory[hook][removedCategory] == currentSortedTierId) {
|
|
780
|
+
_startingTierIdOfCategory[hook][removedCategory] = 0;
|
|
781
|
+
}
|
|
768
782
|
}
|
|
769
783
|
// Iterate by updating the current sorted tier ID to the next sorted tier ID.
|
|
770
784
|
currentSortedTierId = _nextSortedTierIdOf({hook: hook, id: currentSortedTierId, max: lastSortedTierId});
|