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