@bananapus/ownable-v6 0.0.8 → 0.0.10

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 CHANGED
@@ -47,7 +47,7 @@ JBOwner {
47
47
  2. If `projectId == 0`, the owner is `JBOwner.owner` directly.
48
48
  3. In both cases, `_checkOwner()` calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`, which passes if `msg.sender` is the resolved owner OR has the configured `permissionId` granted through `JBPermissions`.
49
49
 
50
- **Permission delegation** uses the nana-core `JBPermissions` contract. The owner calls `JBPermissions.setPermissionsFor(...)` to grant `permissionId` to an operator address. That operator can then call any `onlyOwner` function on this contract. The ROOT permission (ID 255) in `JBPermissions` grants all permission IDs, including whatever `permissionId` is configured here.
50
+ **Permission delegation** uses the nana-core `JBPermissions` contract. The owner calls `JBPermissions.setPermissionsFor(...)` to grant `permissionId` to an operator address. That operator can then call any `onlyOwner` function on this contract. The ROOT permission (ID 1) in `JBPermissions` grants all permission IDs, including whatever `permissionId` is configured here.
51
51
 
52
52
  **Ownership transfer resets `permissionId` to 0.** This prevents the previous owner's delegates from retaining access after a transfer. The new owner must explicitly call `setPermissionId()` to configure delegation.
53
53
 
@@ -0,0 +1,133 @@
1
+ # Audit Instructions -- nana-ownable-v6
2
+
3
+ You are auditing a Juicebox-aware ownership module that extends OpenZeppelin's Ownable pattern. A contract inheriting `JBOwnable` can be owned by a Juicebox project (via its ERC-721 NFT) or a direct address, with delegated access through `JBPermissions`. This is a foundational access control primitive used by hooks and extensions across the Juicebox V6 ecosystem. Read [RISKS.md](./RISKS.md) first -- it documents all known risks and trust assumptions. Then come back here.
4
+
5
+ ## Scope
6
+
7
+ **In scope -- all Solidity in `src/`:**
8
+ ```
9
+ src/JBOwnable.sol # Concrete implementation with onlyOwner modifier (~76 lines)
10
+ src/JBOwnableOverrides.sol # Abstract base with all ownership logic (~244 lines)
11
+ src/structs/JBOwner.sol # Owner struct: {owner, projectId, permissionId} (~15 lines)
12
+ src/interfaces/IJBOwnable.sol # Interface (~47 lines)
13
+ ```
14
+
15
+ **Out of scope:** Test files, JB Core contracts (`JBPermissioned`, `IJBPermissions`, `IJBProjects`), OpenZeppelin contracts, forge-std. Assume these dependencies are correct.
16
+
17
+ ## Architecture
18
+
19
+ ### Ownership Model
20
+
21
+ The `JBOwner` struct packs into a single 256-bit storage slot:
22
+
23
+ ```solidity
24
+ struct JBOwner {
25
+ address owner; // 160 bits -- direct owner (used when projectId == 0)
26
+ uint88 projectId; // 88 bits -- JB project whose NFT holder is owner (used when != 0)
27
+ uint8 permissionId; // 8 bits -- JBPermissions ID for delegated access
28
+ }
29
+ ```
30
+
31
+ **Resolution rules:**
32
+ 1. If `projectId != 0`: owner = `PROJECTS.ownerOf(projectId)` (external call, try-catch wrapped)
33
+ 2. If `projectId == 0`: owner = `jbOwner.owner` (storage read)
34
+ 3. If the `ownerOf` call reverts (e.g., burned NFT): owner resolves to `address(0)`, effectively renouncing the contract
35
+
36
+ **Delegated access:** `_checkOwner()` calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)` from `JBPermissioned`. This passes if `msg.sender == resolvedOwner` OR if `msg.sender` has the configured `permissionId` (or ROOT) in `JBPermissions`.
37
+
38
+ ### Inheritance Chain
39
+
40
+ ```
41
+ JBOwnable (concrete)
42
+ extends JBOwnableOverrides (abstract)
43
+ extends Context (OpenZeppelin)
44
+ extends JBPermissioned (nana-core)
45
+ implements IJBOwnable (interface)
46
+ ```
47
+
48
+ `JBOwnable` adds the `onlyOwner` modifier and implements `_emitTransferEvent`. `JBOwnableOverrides` contains all state management, transfer logic, and the `_checkOwner` function. Contracts that need custom `_emitTransferEvent` behavior inherit `JBOwnableOverrides` directly.
49
+
50
+ ### Key Functions
51
+
52
+ | Function | Access | What it does |
53
+ |----------|--------|--------------|
54
+ | `owner()` | `public view` | Resolves and returns the current owner address. Makes an external call to `PROJECTS.ownerOf()` when project-owned. |
55
+ | `transferOwnership(address newOwner)` | `onlyOwner` | Transfers ownership to a direct address. Reverts on `address(0)`. Resets `permissionId` to 0. |
56
+ | `transferOwnershipToProject(uint256 projectId)` | `onlyOwner` | Transfers ownership to a JB project. Validates: non-zero, fits `uint88`, project exists (`<= PROJECTS.count()`). Resets `permissionId` to 0. |
57
+ | `renounceOwnership()` | `onlyOwner` | Sets owner to `address(0)` and projectId to 0. Irreversible. |
58
+ | `setPermissionId(uint8 permissionId)` | `onlyOwner` | Sets which JBPermissions ID grants delegated owner access. |
59
+ | `_checkOwner()` | `internal view` | Resolves owner, then calls `_requirePermissionFrom`. Used by `onlyOwner` modifier. |
60
+ | `_transferOwnership(address, uint88)` | `internal` | Core transfer logic. Updates `jbOwner`, resets `permissionId`, calls `_emitTransferEvent`. No access restriction. |
61
+
62
+ ## Priority Audit Areas
63
+
64
+ ### 1. Ownership Resolution Correctness (Highest Priority)
65
+
66
+ The `owner()` and `_checkOwner()` functions both resolve ownership via the same pattern but are separate implementations (not shared helper). Verify:
67
+
68
+ - **Consistency between `owner()` and `_checkOwner()`.** Both use try-catch on `PROJECTS.ownerOf()` and fall back to `address(0)` on revert. Verify they always agree on who the owner is. A divergence could allow someone to pass `_checkOwner()` while `owner()` returns a different address (or vice versa).
69
+ - **Try-catch scope.** The try-catch catches ALL reverts from `PROJECTS.ownerOf()`, including out-of-gas. Could an attacker force an OOG in the external call to make `_checkOwner()` resolve to `address(0)`, then bypass access control? (This would require `_requirePermissionFrom(address(0), ...)` to pass, which should not be possible for any non-zero `msg.sender` unless they have ROOT permission for the project.)
70
+ - **Zero-address as resolved owner.** When the resolved owner is `address(0)` (burned NFT or renounced), `_requirePermissionFrom(address(0), projectId, permissionId)` should revert for any caller. Verify this holds in `JBPermissioned` -- specifically that `msg.sender != address(0)` and no permission is granted to `address(0)`.
71
+
72
+ ### 2. Ownership Transfer State Machine
73
+
74
+ Ownership transitions must be airtight:
75
+
76
+ - **Mutual exclusivity.** `_transferOwnership(address newOwner, uint88 projectId)` reverts if both are non-zero. Verify there is no code path that sets both `jbOwner.owner` and `jbOwner.projectId` to non-zero values simultaneously.
77
+ - **Permission reset.** Every ownership transfer resets `permissionId` to 0. Verify this happens in `_transferOwnership` (it does -- the entire `JBOwner` struct is overwritten). Verify there is no path to transfer ownership without going through `_transferOwnership`.
78
+ - **Constructor validation.** The constructor rejects `(address(0), 0)` as initial owner (both zero). It also rejects `(non-zero projectId, address(0) projects)`. Verify these are the only two invalid initial states.
79
+ - **Project existence check.** `transferOwnershipToProject` checks `projectId <= PROJECTS.count()`. Verify this prevents transferring to a non-existent project. Note: `PROJECTS.count()` returns the total number of projects ever created, and project IDs are sequential starting from 1.
80
+
81
+ ### 3. Permission Delegation Security
82
+
83
+ `_checkOwner()` delegates to `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`. Verify:
84
+
85
+ - **When `permissionId == 0`.** Permission ID 0 is forbidden in `JBPermissions` (cannot be set). This means when `permissionId` is 0 (the default after any transfer), ONLY the resolved owner or ROOT holders can pass `_checkOwner()`. Delegated access is effectively disabled until `setPermissionId()` is called.
86
+ - **ROOT override.** ROOT (permission ID 1) always grants access via `_requirePermissionFrom`. An address with ROOT for the relevant project can act as owner of any `JBOwnable` contract owned by that project, regardless of the configured `permissionId`. Verify this is intentional and documented.
87
+ - **Wildcard project ID.** Permissions granted with `projectId = 0` in `JBPermissions` apply to all projects. Verify that a wildcard ROOT grant (which `JBPermissions` should reject) cannot be used to bypass `_checkOwner()`.
88
+
89
+ ### 4. Renunciation Edge Cases
90
+
91
+ - **Renouncing when project-owned.** If `projectId != 0` and the NFT holder calls `renounceOwnership()`, both `owner` and `projectId` are set to 0. Verify the NFT holder can still call `renounceOwnership()` (passes `_checkOwner` with the project-resolved owner).
92
+ - **Renouncing after NFT burn.** If the project NFT is burned (hypothetically -- JBProjects V6 has no burn), `owner()` returns `address(0)`, making `_checkOwner()` revert for everyone. The contract is effectively renounced without anyone calling `renounceOwnership()`. Verify this state is consistent and cannot be escaped.
93
+ - **Double renounce.** After renouncing, calling `renounceOwnership()` again should revert because `_checkOwner()` will fail (resolved owner is `address(0)` and `msg.sender` cannot be `address(0)`).
94
+
95
+ ### 5. Storage Slot Packing
96
+
97
+ The `JBOwner` struct fits in a single 256-bit slot: `address` (160) + `uint88` (88) + `uint8` (8) = 256. Verify:
98
+ - The Solidity compiler lays out the struct as expected (no padding gaps).
99
+ - The `_transferOwnership` function writes the entire struct atomically (`jbOwner = JBOwner({...})`), preventing partial-write inconsistencies.
100
+
101
+ ## Invariants to Verify
102
+
103
+ 1. **Mutual exclusivity**: At no point can both `jbOwner.owner != address(0)` and `jbOwner.projectId != 0`.
104
+ 2. **Transfer resets permission**: After any call to `_transferOwnership`, `jbOwner.permissionId == 0`.
105
+ 3. **Renounce is terminal**: After `renounceOwnership()`, `jbOwner.owner == address(0)` AND `jbOwner.projectId == 0` AND all subsequent `_checkOwner()` calls revert.
106
+ 4. **owner() consistency**: `owner()` and the resolved owner in `_checkOwner()` always agree.
107
+ 5. **No unauthorized transfer**: Only addresses passing `_checkOwner()` can call `transferOwnership`, `transferOwnershipToProject`, `renounceOwnership`, or `setPermissionId`.
108
+
109
+ ## Testing Setup
110
+
111
+ ```bash
112
+ cd nana-ownable-v6
113
+ npm install
114
+ forge build
115
+ forge test
116
+
117
+ # Run attack scenarios
118
+ forge test --match-contract OwnableAttacks -vvv
119
+
120
+ # Run edge cases
121
+ forge test --match-contract OwnableEdgeCases -vvv
122
+
123
+ # Run invariant tests
124
+ forge test --match-contract OwnableInvariant -vvv
125
+
126
+ # Run regression tests
127
+ forge test --match-path test/regression/ -vvv
128
+
129
+ # Write a PoC
130
+ forge test --match-path test/audit/ExploitPoC.t.sol -vvv
131
+ ```
132
+
133
+ Go break it.
package/CHANGE_LOG.md ADDED
@@ -0,0 +1,221 @@
1
+ # nana-ownable-v6 Changelog (v5 → v6)
2
+
3
+ This document describes all changes between `nana-ownable` (v5) and `nana-ownable-v6` (v6).
4
+
5
+ ---
6
+
7
+ ## 1. Breaking Changes
8
+
9
+ ### Solidity Version Pinned
10
+
11
+ All contracts changed from `pragma solidity ^0.8.23` (floating) to `pragma solidity 0.8.26` (pinned). This means v6 can only compile with Solidity 0.8.26 exactly.
12
+
13
+ **Affected files:** `JBOwnable.sol`, `JBOwnableOverrides.sol`
14
+
15
+ ### Import Paths Updated
16
+
17
+ All imports reference `@bananapus/core-v6` instead of `@bananapus/core-v5`. Any project depending on `nana-ownable` must also migrate to `nana-core-v6`.
18
+
19
+ ### `owner()` Returns `address(0)` Instead of Reverting for Invalid Projects
20
+
21
+ In v5, if a project NFT was burned or otherwise invalid, `owner()` would revert because `PROJECTS.ownerOf()` reverts for nonexistent tokens.
22
+
23
+ In v6, `owner()` wraps the call in `try-catch` and returns `address(0)` if `ownerOf()` reverts. This changes the observable behavior: callers that previously relied on a revert to detect invalid project ownership will now receive `address(0)` instead.
24
+
25
+ ### `_checkOwner()` Behavior Change for Invalid Projects
26
+
27
+ In v5, `_checkOwner()` would revert with the ERC-721 "nonexistent token" error if the owning project NFT was burned.
28
+
29
+ In v6, it resolves the owner to `address(0)` via `try-catch` and then passes `address(0)` to `_requirePermissionFrom`. This still causes a revert (no one can authenticate as `address(0)`), but the revert reason changes from an ERC-721 error to a permissions error.
30
+
31
+ ### `transferOwnershipToProject()` Now Validates Project Existence
32
+
33
+ In v6, `transferOwnershipToProject()` checks `projectId > PROJECTS.count()` and reverts with `JBOwnableOverrides_ProjectDoesNotExist()` if the project has not been created yet. In v5, no such check existed, so ownership could be transferred to a nonexistent project ID.
34
+
35
+ ### Constructor Validates `PROJECTS` Address for Project-Based Ownership
36
+
37
+ In v6, the constructor reverts with `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` if `initialProjectIdOwner != 0` and `address(projects) == address(0)`. In v5, this combination was silently accepted, which would permanently break ownership resolution since `ownerOf()` calls on `address(0)` would always revert.
38
+
39
+ ### `IJBOwnable.jbOwner()` Return Name Change
40
+
41
+ The second return value of `jbOwner()` was renamed from `projectOwner` (v5) to `projectId` (v6) in the interface. The type (`uint88`) and position are unchanged, so the ABI is identical, but code referencing the named return will need updating.
42
+
43
+ ---
44
+
45
+ ## 2. New Features
46
+
47
+ ### Defensive `try-catch` on All `PROJECTS.ownerOf()` Calls
48
+
49
+ Every call to `PROJECTS.ownerOf()` in v6 is wrapped in a `try-catch` block. If the call reverts (e.g., burned NFT, broken ERC-721 implementation), the resolved owner falls back to `address(0)`. This applies to:
50
+
51
+ - `owner()` -- returns `address(0)` instead of reverting.
52
+ - `_checkOwner()` -- resolves to `address(0)`, causing a permissions revert.
53
+ - `_transferOwnership(address, uint88)` -- resolves old owner to `address(0)` for the transfer event.
54
+
55
+ ### Project Existence Validation on `transferOwnershipToProject()`
56
+
57
+ `transferOwnershipToProject()` now calls `PROJECTS.count()` to verify the target project exists before transferring ownership, preventing accidental loss of contract control by transferring to a nonexistent project.
58
+
59
+ ### Constructor Guard Against Zero-Address `PROJECTS` with Project Ownership
60
+
61
+ A new constructor check prevents deploying with `projects == address(0)` when `initialProjectIdOwner != 0`, which would make ownership irrecoverable.
62
+
63
+ ### Comprehensive NatSpec Documentation on `IJBOwnable`
64
+
65
+ The v6 interface adds full NatSpec documentation for all events, functions, parameters, and return values. The v5 interface had no NatSpec comments at all.
66
+
67
+ ---
68
+
69
+ ## 3. Event Changes
70
+
71
+ No event signatures changed. Both versions define the same two events with identical parameters and indexing:
72
+
73
+ - `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)`
74
+ - `PermissionIdChanged(uint8 newId, address caller)`
75
+
76
+ The only difference is the addition of NatSpec documentation on both events in v6's `IJBOwnable.sol`, and the declaration order was swapped (v5: `PermissionIdChanged` first, then `OwnershipTransferred`; v6: `OwnershipTransferred` first, then `PermissionIdChanged`).
77
+
78
+ ---
79
+
80
+ ## 4. Error Changes
81
+
82
+ ### New Errors
83
+
84
+ | Error | Contract | Description |
85
+ |---|---|---|
86
+ | `JBOwnableOverrides_ProjectDoesNotExist()` | `JBOwnableOverrides` | Reverts in `transferOwnershipToProject()` if `projectId > PROJECTS.count()`. |
87
+ | `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | `JBOwnableOverrides` | Reverts in constructor if `initialProjectIdOwner != 0` and `address(projects) == address(0)`. |
88
+
89
+ ### Unchanged Errors
90
+
91
+ | Error | Status |
92
+ |---|---|
93
+ | `JBOwnableOverrides_InvalidNewOwner()` | Unchanged -- still used for zero-address owner, zero project ID, and dual-set owner+project scenarios. |
94
+
95
+ ---
96
+
97
+ ## 5. Struct Changes
98
+
99
+ ### `JBOwner` (unchanged)
100
+
101
+ The struct itself is identical in both versions:
102
+
103
+ ```solidity
104
+ struct JBOwner {
105
+ address owner;
106
+ uint88 projectId;
107
+ uint8 permissionId;
108
+ }
109
+ ```
110
+
111
+ The only difference is the addition of a `// forge-lint: disable-next-line(pascal-case-struct)` comment in v6.
112
+
113
+ ---
114
+
115
+ ## 6. Implementation Changes (Non-Interface)
116
+
117
+ ### `JBOwnableOverrides._checkOwner()` -- Refactored Owner Resolution
118
+
119
+ **v5:**
120
+ ```solidity
121
+ function _checkOwner() internal view virtual {
122
+ JBOwner memory ownerInfo = jbOwner;
123
+ _requirePermissionFrom({
124
+ account: ownerInfo.projectId == 0 ? ownerInfo.owner : PROJECTS.ownerOf(ownerInfo.projectId),
125
+ projectId: ownerInfo.projectId,
126
+ permissionId: ownerInfo.permissionId
127
+ });
128
+ }
129
+ ```
130
+
131
+ **v6:**
132
+ ```solidity
133
+ function _checkOwner() internal view virtual {
134
+ JBOwner memory ownerInfo = jbOwner;
135
+ address resolvedOwner;
136
+ if (ownerInfo.projectId == 0) {
137
+ resolvedOwner = ownerInfo.owner;
138
+ } else {
139
+ try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
140
+ resolvedOwner = projectOwner;
141
+ } catch {
142
+ resolvedOwner = address(0);
143
+ }
144
+ }
145
+ _requirePermissionFrom({
146
+ account: resolvedOwner, projectId: ownerInfo.projectId, permissionId: ownerInfo.permissionId
147
+ });
148
+ }
149
+ ```
150
+
151
+ The ternary expression is replaced with an explicit `if/else` block and `try-catch` for defensive error handling.
152
+
153
+ ### `JBOwnableOverrides.owner()` -- Try-Catch on Project Lookup
154
+
155
+ **v5:**
156
+ ```solidity
157
+ return PROJECTS.ownerOf(ownerInfo.projectId);
158
+ ```
159
+
160
+ **v6:**
161
+ ```solidity
162
+ try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
163
+ return projectOwner;
164
+ } catch {
165
+ return address(0);
166
+ }
167
+ ```
168
+
169
+ ### `JBOwnableOverrides._transferOwnership(address, uint88)` -- Try-Catch on Old Owner Lookup
170
+
171
+ **v5:**
172
+ ```solidity
173
+ address oldOwner = ownerInfo.projectId == 0 ? ownerInfo.owner : PROJECTS.ownerOf(ownerInfo.projectId);
174
+ ```
175
+
176
+ **v6:**
177
+ ```solidity
178
+ address oldOwner;
179
+ if (ownerInfo.projectId == 0) {
180
+ oldOwner = ownerInfo.owner;
181
+ } else {
182
+ try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
183
+ oldOwner = projectOwner;
184
+ } catch {
185
+ oldOwner = address(0);
186
+ }
187
+ }
188
+ ```
189
+
190
+ ### Named Arguments Used Throughout
191
+
192
+ All internal function calls in v6 use named arguments (e.g., `_transferOwnership({newOwner: initialOwner, projectId: initialProjectIdOwner})`) instead of positional arguments. This is a style-only change with no behavioral impact.
193
+
194
+ ### NatSpec Improvements
195
+
196
+ - `JBOwnable._emitTransferEvent()`: Incomplete comment in v5 (`"some contracts will try to deploy contracts for a project before"`) is completed in v6 (`"some contracts need to deploy contracts for a project before the project's NFT has been minted, so the transfer event resolves the project's current owner at emission time."`).
197
+ - `JBOwnableOverrides._emitTransferEvent()`: Added `@param` tags for `previousOwner`, `newOwner`, and `newProjectId`.
198
+ - `renounceOwnership()`, `setPermissionId()`: Changed `@notice This can only be called by the current owner.` to `@dev`.
199
+ - `transferOwnership()`, `transferOwnershipToProject()`: Added documentation about `permissionId` being reset to 0 on transfer.
200
+ - Constructor `@param initialOwner`: Fixed typo `intialProjectIdOwner` to `initialProjectIdOwner`.
201
+
202
+ ### Comment/Formatting Fixes
203
+
204
+ - Fixed malformed section header comment in v5 (`custom errors --------------------------//b`) to properly formatted (`custom errors ------------------------- //`).
205
+
206
+ ---
207
+
208
+ ## 7. Migration Table
209
+
210
+ | v5 | v6 | Change Type |
211
+ |---|---|---|
212
+ | `pragma solidity ^0.8.23` | `pragma solidity 0.8.26` | Pinned compiler version |
213
+ | `@bananapus/core-v5` imports | `@bananapus/core-v6` imports | Dependency upgrade |
214
+ | `PROJECTS.ownerOf()` called directly | `try PROJECTS.ownerOf() catch` in `owner()`, `_checkOwner()`, `_transferOwnership()` | Defensive error handling |
215
+ | `transferOwnershipToProject()` -- no existence check | Reverts with `JBOwnableOverrides_ProjectDoesNotExist()` if `projectId > PROJECTS.count()` | New validation |
216
+ | Constructor allows `projects == address(0)` with `initialProjectIdOwner != 0` | Reverts with `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | New validation |
217
+ | `IJBOwnable.jbOwner()` returns `(address owner, uint88 projectOwner, uint8 permissionId)` | Returns `(address owner, uint88 projectId, uint8 permissionId)` | Return name change (ABI compatible) |
218
+ | `IJBOwnable` -- no NatSpec | Full NatSpec on all events, functions, params, and returns | Documentation |
219
+ | Positional arguments in internal calls | Named arguments throughout | Style only |
220
+ | 1 custom error | 3 custom errors (`+ ProjectDoesNotExist`, `+ ZeroAddressProjectsWithProjectOwner`) | New errors |
221
+ | `JBOwner` struct | Identical (added forge-lint comment only) | No change |
package/RISKS.md CHANGED
@@ -1,23 +1,21 @@
1
- # nana-ownable-v6 — Risks
1
+ # RISKS.md -- nana-ownable-v6
2
2
 
3
- ## Trust Assumptions
3
+ ## 1. Trust Assumptions
4
4
 
5
- 1. **JBPermissions** Permission checks delegate to JBPermissions contract. A bug in JBPermissions affects all JBOwnable contracts.
6
- 2. **JBProjects ERC-721** When owned by a project, ownership follows the ERC-721 token. Whoever holds the project NFT has owner access.
7
- 3. **Permission Delegation** Anyone granted the configured `permissionId` via JBPermissions gets owner-equivalent access.
5
+ - **JBPermissions.** Permission checks delegate to JBPermissions contract. A bug in JBPermissions affects all JBOwnable contracts.
6
+ - **JBProjects ERC-721.** When owned by a project, ownership follows the ERC-721 token. Whoever holds the project NFT has owner access.
7
+ - **Permission Delegation.** Anyone granted the configured `permissionId` via JBPermissions gets owner-equivalent access for the scoped function.
8
8
 
9
- ## Known Risks
9
+ ## 2. Known Risks
10
10
 
11
- | Risk | Description | Mitigation |
12
- |------|-------------|------------|
13
- | Permission escalation | Granting `permissionId` gives full owner access to that function | Only grant to trusted operators |
14
- | Project NFT transfer | Transferring the project NFT transfers ownership of all JBOwnable contracts tied to it | Intentional design; use multisig for project NFT |
15
- | Renounce is permanent | `renounceOwnership()` is irreversible | Standard OpenZeppelin pattern |
16
- | Zero address project | Setting `projectId = 0` with `owner = address(0)` permanently locks ownership | Validate before calling |
11
+ - **Project NFT transfer = ownership transfer.** If ownership is tied to a project ID, anyone who acquires the project NFT (via transfer, marketplace purchase, or social engineering) gains full owner access to all contracts using that JBOwnable instance. Project NFT holders must treat the NFT as a high-value key.
12
+ - **Permission ID reset on transfer.** `permissionId` resets to 0 on ownership transfer, which could temporarily lock out delegated operators. By design -- prevents permission clashes for new owners.
13
+ - **Burned/invalid project NFT.** If the project NFT is burned or `ownerOf` reverts, the contract is effectively renounced (owner resolves to `address(0)`). Defensive try-catch in `owner()` and `_checkOwner()`. JBProjects V6 has no burn function, so this is a defensive measure.
14
+ - **Dual ownership ambiguity.** Setting both `newOwner` and `projectId` to non-zero reverts, but the two-mode design could confuse integrators about which mode is active. `jbOwner()` exposes both fields for inspection.
15
+ - **`transferOwnershipToProject` with future project.** Checks `projectId > PROJECTS.count()` to prevent transferring to non-existent projects.
17
16
 
18
- ## Privileged Roles
17
+ ## 3. Invariants to Verify
19
18
 
20
- | Role | Access | Scope |
21
- |------|--------|-------|
22
- | Owner (address or project holder) | All `onlyOwner` functions | Per-contract |
23
- | Permission delegates | `onlyOwner` functions via JBPermissions | Per-contract, per-permissionId |
19
+ - Ownership is always exactly one of: direct address OR project NFT holder (never both, never neither unless renounced).
20
+ - `_checkOwner()` reverts for all callers when the owner resolves to `address(0)`.
21
+ - `permissionId` is correctly reset to 0 on every ownership transfer.
package/STYLE_GUIDE.md CHANGED
@@ -197,7 +197,7 @@ interface IJBExample is IJBBase {
197
197
  | Public/external function | `camelCase` | `cashOutTokensOf` |
198
198
  | Internal/private function | `_camelCase` | `_processFee` |
199
199
  | Internal storage | `_camelCase` | `_accountingContextForTokenOf` |
200
- | Function parameter | `camelCase` | `projectId`, `cashOutCount` |
200
+ | Function parameter | `camelCase` (no underscores) | `projectId`, `cashOutCount` |
201
201
 
202
202
  ## NatSpec
203
203
 
@@ -253,9 +253,12 @@ uint256 public constant MAX_RESERVED_PERCENT = 10_000;
253
253
 
254
254
  ## Function Calls
255
255
 
256
- Use named parameters for readability when calling functions with 3+ arguments:
256
+ Use named arguments for all function calls with 2 or more arguments — in both `src/` and `script/`:
257
257
 
258
258
  ```solidity
259
+ // Good — named arguments
260
+ token.mint({account: beneficiary, amount: count});
261
+ _transferOwnership({newOwner: address(0), projectId: 0});
259
262
  PERMISSIONS.hasPermission({
260
263
  operator: sender,
261
264
  account: account,
@@ -264,8 +267,18 @@ PERMISSIONS.hasPermission({
264
267
  includeRoot: true,
265
268
  includeWildcardProjectId: true
266
269
  });
270
+
271
+ // Bad — positional arguments with 2+ args
272
+ token.mint(beneficiary, count);
273
+ _transferOwnership(address(0), 0);
267
274
  ```
268
275
 
276
+ Single-argument calls use positional style: `_burn(amount)`.
277
+
278
+ This also applies to constructor calls, struct literals, and inherited/library calls (e.g., OZ `_mint`, `_safeMint`, `safeTransfer`, `allowance`, `Clones.cloneDeterministic`).
279
+
280
+ Named argument keys must use **camelCase** — never underscores. If a function's parameter names use underscores, rename them to camelCase first.
281
+
269
282
  ## Multiline Signatures
270
283
 
271
284
  ```solidity
@@ -553,6 +566,7 @@ CI checks formatting via `forge fmt --check`.
553
566
 
554
567
  CI runs `forge build --sizes` to catch contracts approaching the 24KB limit. When the repo's default `optimizer_runs` differs from what you want for size checking, use `FOUNDRY_PROFILE=ci_sizes forge build --sizes` with a `[profile.ci_sizes]` section in `foundry.toml`.
555
568
 
569
+
556
570
  ## Repo-Specific Deviations
557
571
 
558
572
  None. This repo follows the standard configuration exactly.