@bananapus/ownable-v6 0.0.9 → 0.0.11

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,229 @@
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` Return Name Changes
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
+ Additionally, `PROJECTS()` and `owner()` gained named return values in the interface:
44
+
45
+ - `PROJECTS()`: `returns (IJBProjects)` (v5) -> `returns (IJBProjects projects)` (v6)
46
+ - `owner()`: `returns (address)` (v5) -> `returns (address owner)` (v6)
47
+
48
+ These are ABI-compatible but change the Solidity-level interface signature.
49
+
50
+ ---
51
+
52
+ ## 2. New Features
53
+
54
+ ### Defensive `try-catch` on All `PROJECTS.ownerOf()` Calls
55
+
56
+ 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:
57
+
58
+ - `owner()` -- returns `address(0)` instead of reverting.
59
+ - `_checkOwner()` -- resolves to `address(0)`, causing a permissions revert.
60
+ - `_transferOwnership(address, uint88)` -- resolves old owner to `address(0)` for the transfer event.
61
+
62
+ ### Project Existence Validation on `transferOwnershipToProject()`
63
+
64
+ `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.
65
+
66
+ ### Constructor Guard Against Zero-Address `PROJECTS` with Project Ownership
67
+
68
+ A new constructor check prevents deploying with `projects == address(0)` when `initialProjectIdOwner != 0`, which would make ownership irrecoverable.
69
+
70
+ ### Comprehensive NatSpec Documentation on `IJBOwnable`
71
+
72
+ The v6 interface adds full NatSpec documentation for all events, functions, parameters, and return values. The v5 interface had no NatSpec comments at all.
73
+
74
+ ---
75
+
76
+ ## 3. Event Changes
77
+
78
+ No event signatures changed. Both versions define the same two events with identical parameters and indexing:
79
+
80
+ - `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)`
81
+ - `PermissionIdChanged(uint8 newId, address caller)`
82
+
83
+ 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`).
84
+
85
+ ---
86
+
87
+ ## 4. Error Changes
88
+
89
+ ### New Errors
90
+
91
+ | Error | Contract | Description |
92
+ |---|---|---|
93
+ | `JBOwnableOverrides_ProjectDoesNotExist()` | `JBOwnableOverrides` | Reverts in `transferOwnershipToProject()` if `projectId > PROJECTS.count()`. |
94
+ | `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | `JBOwnableOverrides` | Reverts in constructor if `initialProjectIdOwner != 0` and `address(projects) == address(0)`. |
95
+
96
+ ### Unchanged Errors
97
+
98
+ | Error | Status |
99
+ |---|---|
100
+ | `JBOwnableOverrides_InvalidNewOwner()` | Unchanged -- still used for zero-address owner, zero project ID, and dual-set owner+project scenarios. |
101
+
102
+ ---
103
+
104
+ ## 5. Struct Changes
105
+
106
+ ### `JBOwner` (unchanged)
107
+
108
+ The struct itself is identical in both versions:
109
+
110
+ ```solidity
111
+ struct JBOwner {
112
+ address owner;
113
+ uint88 projectId;
114
+ uint8 permissionId;
115
+ }
116
+ ```
117
+
118
+ The only difference is the addition of a `// forge-lint: disable-next-line(pascal-case-struct)` comment in v6.
119
+
120
+ ---
121
+
122
+ ## 6. Implementation Changes (Non-Interface)
123
+
124
+ ### `JBOwnableOverrides._checkOwner()` -- Refactored Owner Resolution
125
+
126
+ **v5:**
127
+ ```solidity
128
+ function _checkOwner() internal view virtual {
129
+ JBOwner memory ownerInfo = jbOwner;
130
+ _requirePermissionFrom({
131
+ account: ownerInfo.projectId == 0 ? ownerInfo.owner : PROJECTS.ownerOf(ownerInfo.projectId),
132
+ projectId: ownerInfo.projectId,
133
+ permissionId: ownerInfo.permissionId
134
+ });
135
+ }
136
+ ```
137
+
138
+ **v6:**
139
+ ```solidity
140
+ function _checkOwner() internal view virtual {
141
+ JBOwner memory ownerInfo = jbOwner;
142
+ address resolvedOwner;
143
+ if (ownerInfo.projectId == 0) {
144
+ resolvedOwner = ownerInfo.owner;
145
+ } else {
146
+ try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
147
+ resolvedOwner = projectOwner;
148
+ } catch {
149
+ resolvedOwner = address(0);
150
+ }
151
+ }
152
+ _requirePermissionFrom({
153
+ account: resolvedOwner, projectId: ownerInfo.projectId, permissionId: ownerInfo.permissionId
154
+ });
155
+ }
156
+ ```
157
+
158
+ The ternary expression is replaced with an explicit `if/else` block and `try-catch` for defensive error handling.
159
+
160
+ ### `JBOwnableOverrides.owner()` -- Try-Catch on Project Lookup
161
+
162
+ **v5:**
163
+ ```solidity
164
+ return PROJECTS.ownerOf(ownerInfo.projectId);
165
+ ```
166
+
167
+ **v6:**
168
+ ```solidity
169
+ try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
170
+ return projectOwner;
171
+ } catch {
172
+ return address(0);
173
+ }
174
+ ```
175
+
176
+ ### `JBOwnableOverrides._transferOwnership(address, uint88)` -- Try-Catch on Old Owner Lookup
177
+
178
+ **v5:**
179
+ ```solidity
180
+ address oldOwner = ownerInfo.projectId == 0 ? ownerInfo.owner : PROJECTS.ownerOf(ownerInfo.projectId);
181
+ ```
182
+
183
+ **v6:**
184
+ ```solidity
185
+ address oldOwner;
186
+ if (ownerInfo.projectId == 0) {
187
+ oldOwner = ownerInfo.owner;
188
+ } else {
189
+ try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
190
+ oldOwner = projectOwner;
191
+ } catch {
192
+ oldOwner = address(0);
193
+ }
194
+ }
195
+ ```
196
+
197
+ ### Named Arguments Used Throughout
198
+
199
+ 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.
200
+
201
+ ### NatSpec Improvements
202
+
203
+ - `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."`). A new `@dev` comment also explains why this function intentionally does NOT use try-catch (unlike `_transferOwnership`): reverting on a nonexistent new project is desirable to prevent transferring ownership to an invalid project.
204
+ - `JBOwnableOverrides._emitTransferEvent()`: Added `@param` tags for `previousOwner`, `newOwner`, and `newProjectId`.
205
+ - `renounceOwnership()`, `setPermissionId()`: Changed `@notice This can only be called by the current owner.` to `@dev`.
206
+ - `transferOwnership()`, `transferOwnershipToProject()`: Added documentation about `permissionId` being reset to 0 on transfer.
207
+ - Constructor `@param initialOwner`: Fixed typo `intialProjectIdOwner` to `initialProjectIdOwner`.
208
+
209
+ ### Comment/Formatting Fixes
210
+
211
+ - Fixed malformed section header comment in v5 (`custom errors --------------------------//b`) to properly formatted (`custom errors ------------------------- //`).
212
+
213
+ ---
214
+
215
+ ## 7. Migration Table
216
+
217
+ | v5 | v6 | Change Type |
218
+ |---|---|---|
219
+ | `pragma solidity ^0.8.23` | `pragma solidity 0.8.26` | Pinned compiler version |
220
+ | `@bananapus/core-v5` imports | `@bananapus/core-v6` imports | Dependency upgrade |
221
+ | `PROJECTS.ownerOf()` called directly | `try PROJECTS.ownerOf() catch` in `owner()`, `_checkOwner()`, `_transferOwnership()` | Defensive error handling |
222
+ | `transferOwnershipToProject()` -- no existence check | Reverts with `JBOwnableOverrides_ProjectDoesNotExist()` if `projectId > PROJECTS.count()` | New validation |
223
+ | Constructor allows `projects == address(0)` with `initialProjectIdOwner != 0` | Reverts with `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | New validation |
224
+ | `IJBOwnable.jbOwner()` returns `(address owner, uint88 projectOwner, uint8 permissionId)` | Returns `(address owner, uint88 projectId, uint8 permissionId)` | Return name change (ABI compatible) |
225
+ | `IJBOwnable.PROJECTS()` returns `(IJBProjects)`, `owner()` returns `(address)` | Returns `(IJBProjects projects)`, `(address owner)` | Named return values added (ABI compatible) |
226
+ | `IJBOwnable` -- no NatSpec | Full NatSpec on all events, functions, params, and returns | Documentation |
227
+ | Positional arguments in internal calls | Named arguments throughout | Style only |
228
+ | 1 custom error | 3 custom errors (`+ ProjectDoesNotExist`, `+ ZeroAddressProjectsWithProjectOwner`) | New errors |
229
+ | `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.
@@ -0,0 +1,245 @@
1
+ # User Journeys -- nana-ownable-v6
2
+
3
+ Concrete end-to-end flows through the JBOwnable system. Each journey traces the exact function calls, state changes, and external interactions.
4
+
5
+ ## Journey 1: Deploy a Project-Owned Contract
6
+
7
+ **Actor:** Protocol developer deploying a hook or extension that should be owned by a Juicebox project.
8
+ **Goal:** Create a contract where the project NFT holder has owner access.
9
+
10
+ ### Precondition
11
+
12
+ A Juicebox project exists with ID `projectId`. The `JBProjects` and `JBPermissions` contracts are deployed.
13
+
14
+ ### Steps
15
+
16
+ 1. **Developer deploys a contract inheriting `JBOwnable`**
17
+
18
+ ```solidity
19
+ new MyHook(permissions, projects, address(0), projectId)
20
+ ```
21
+
22
+ - `initialOwner = address(0)` because ownership is project-based
23
+ - `initialProjectIdOwner = projectId`
24
+
25
+ 2. **Constructor execution in `JBOwnableOverrides`**
26
+
27
+ - Stores `PROJECTS = projects` (immutable)
28
+ - Validates: `initialProjectIdOwner != 0` AND `address(projects) != address(0)` -- passes
29
+ - Validates: not both zero (passes because `initialProjectIdOwner != 0`)
30
+ - Calls `_transferOwnership(address(0), projectId)`:
31
+ - Sets `jbOwner = JBOwner({owner: address(0), projectId: projectId, permissionId: 0})`
32
+ - Calls `_emitTransferEvent(address(0), address(0), projectId)`
33
+ - In `JBOwnable._emitTransferEvent`: emits `OwnershipTransferred(address(0), PROJECTS.ownerOf(projectId), msg.sender)`
34
+
35
+ 3. **Ownership is now live**
36
+
37
+ - `owner()` calls `PROJECTS.ownerOf(projectId)` and returns the current NFT holder
38
+ - `_checkOwner()` validates `msg.sender` against the NFT holder (or permission delegates)
39
+
40
+ ### Result
41
+
42
+ The contract is owned by whichever address holds the project NFT. If the NFT is transferred, ownership automatically follows -- no on-chain update to the JBOwnable contract is needed.
43
+
44
+ ### What to verify
45
+
46
+ - `jbOwner.owner == address(0)` and `jbOwner.projectId == projectId` after construction.
47
+ - `jbOwner.permissionId == 0` (no delegated access until explicitly configured).
48
+ - `owner()` returns the current NFT holder, not a cached value.
49
+ - If the project does not exist (ID > `PROJECTS.count()`), the constructor still succeeds -- the existence check is only enforced in `transferOwnershipToProject`, not the constructor. Verify whether this is safe (the deployer presumably knows the project exists).
50
+
51
+ ---
52
+
53
+ ## Journey 2: Transfer Ownership to a Different Address
54
+
55
+ **Actor:** Current owner (direct address or project NFT holder).
56
+ **Goal:** Transfer ownership from the current owner to a new direct address.
57
+
58
+ ### Precondition
59
+
60
+ The caller is the current owner or has the configured `permissionId` (or ROOT) via `JBPermissions`.
61
+
62
+ ### Steps
63
+
64
+ 1. **Owner calls `transferOwnership(newOwner)`**
65
+
66
+ - `newOwner` must not be `address(0)` (reverts with `JBOwnableOverrides_InvalidNewOwner`)
67
+
68
+ 2. **`_checkOwner()` validates the caller**
69
+
70
+ - Resolves the current owner (via `PROJECTS.ownerOf()` if project-owned, or `jbOwner.owner` if address-owned)
71
+ - Calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`
72
+ - Passes if `msg.sender == resolvedOwner` OR `msg.sender` has the required permission
73
+
74
+ 3. **`_transferOwnership(newOwner, 0)` executes the transfer**
75
+
76
+ - Records `oldOwner` (resolved from current `jbOwner`)
77
+ - Overwrites `jbOwner = JBOwner({owner: newOwner, projectId: 0, permissionId: 0})`
78
+ - Calls `_emitTransferEvent(oldOwner, newOwner, 0)`
79
+
80
+ 4. **`_emitTransferEvent` in `JBOwnable`**
81
+
82
+ - Since `newProjectId == 0`: emits `OwnershipTransferred(oldOwner, newOwner, msg.sender)`
83
+
84
+ ### Result
85
+
86
+ `jbOwner.owner == newOwner`, `jbOwner.projectId == 0`, `jbOwner.permissionId == 0`. The new owner must call `setPermissionId()` to re-enable delegated access.
87
+
88
+ ### What to verify
89
+
90
+ - If the contract was previously project-owned, `projectId` is now 0 (project ownership is cleared).
91
+ - `permissionId` is reset to 0, revoking all previously delegated permissions.
92
+ - The previous owner (or their delegates) can no longer call `onlyOwner` functions.
93
+ - `newOwner` can immediately call `onlyOwner` functions without any additional setup.
94
+
95
+ ---
96
+
97
+ ## Journey 3: Transfer Ownership to a Juicebox Project
98
+
99
+ **Actor:** Current owner (direct address or project NFT holder).
100
+ **Goal:** Transfer ownership from the current owner to a Juicebox project, so the NFT holder becomes the new owner.
101
+
102
+ ### Precondition
103
+
104
+ The target project exists (ID <= `PROJECTS.count()`). The caller is the current owner or has adequate permissions.
105
+
106
+ ### Steps
107
+
108
+ 1. **Owner calls `transferOwnershipToProject(projectId)`**
109
+
110
+ - Validates: `projectId != 0` (reverts with `JBOwnableOverrides_InvalidNewOwner`)
111
+ - Validates: `projectId <= type(uint88).max` (reverts with `JBOwnableOverrides_InvalidNewOwner`)
112
+ - Validates: `projectId <= PROJECTS.count()` (reverts with `JBOwnableOverrides_ProjectDoesNotExist`)
113
+
114
+ 2. **`_checkOwner()` validates the caller** (same as Journey 2, Step 2)
115
+
116
+ 3. **`_transferOwnership(address(0), uint88(projectId))` executes the transfer**
117
+
118
+ - Records `oldOwner` (resolved from current `jbOwner`)
119
+ - Overwrites `jbOwner = JBOwner({owner: address(0), projectId: uint88(projectId), permissionId: 0})`
120
+ - Calls `_emitTransferEvent(oldOwner, address(0), uint88(projectId))`
121
+
122
+ 4. **`_emitTransferEvent` in `JBOwnable`**
123
+
124
+ - Since `newProjectId != 0`: emits `OwnershipTransferred(oldOwner, PROJECTS.ownerOf(projectId), msg.sender)`
125
+
126
+ ### Result
127
+
128
+ `jbOwner.owner == address(0)`, `jbOwner.projectId == projectId`, `jbOwner.permissionId == 0`. The project NFT holder is now the owner. Ownership dynamically follows NFT transfers.
129
+
130
+ ### What to verify
131
+
132
+ - The project existence check (`projectId <= PROJECTS.count()`) prevents transferring to a nonexistent project.
133
+ - The `uint88` cast does not truncate (the preceding `type(uint88).max` check ensures this).
134
+ - If the project NFT is subsequently burned (hypothetically), `owner()` returns `address(0)` and the contract is effectively renounced.
135
+
136
+ ---
137
+
138
+ ## Journey 4: Delegate Access via Permission ID
139
+
140
+ **Actor:** Current owner.
141
+ **Goal:** Allow additional addresses to call `onlyOwner` functions through the JBPermissions system.
142
+
143
+ ### Precondition
144
+
145
+ The contract has an owner. The owner wants to delegate access to one or more operators.
146
+
147
+ ### Steps
148
+
149
+ 1. **Owner calls `setPermissionId(permissionId)`**
150
+
151
+ - `_checkOwner()` validates the caller
152
+ - `_setPermissionId(permissionId)` writes `jbOwner.permissionId = permissionId`
153
+ - Emits `PermissionIdChanged(permissionId, msg.sender)`
154
+
155
+ 2. **Owner grants the permission to operators via JBPermissions**
156
+
157
+ - `permissions.setPermissionsFor(account, JBPermissionsData({operator: operatorAddress, projectId: projectId, permissionIds: [permissionId]}))`
158
+ - This is an external call on the JBPermissions contract, not on the JBOwnable contract
159
+
160
+ 3. **Operator calls an `onlyOwner` function**
161
+
162
+ - `_checkOwner()` resolves the owner and calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`
163
+ - `JBPermissioned._requirePermissionFrom` checks `JBPermissions.hasPermission(msg.sender, resolvedOwner, projectId, permissionId)` -- passes
164
+
165
+ ### Result
166
+
167
+ The operator can call any function protected by `onlyOwner` on this contract. The permission is scoped to the owner's account and project ID.
168
+
169
+ ### What to verify
170
+
171
+ - `permissionId == 0` effectively disables delegation (permission ID 0 cannot be set in `JBPermissions`). Only the owner (or ROOT holders) can call `onlyOwner` functions.
172
+ - If the owner transfers ownership, `permissionId` resets to 0. The new owner must re-configure delegation.
173
+ - ROOT (permission ID 1) always grants access regardless of the configured `permissionId`. This is a feature of `JBPermissioned`, not specific to `JBOwnable`.
174
+ - The operator's access is not stored on the JBOwnable contract -- it lives in JBPermissions. Changing the `permissionId` on JBOwnable instantly changes which JBPermissions grants are recognized.
175
+
176
+ ---
177
+
178
+ ## Journey 5: Renounce Ownership
179
+
180
+ **Actor:** Current owner.
181
+ **Goal:** Permanently give up ownership, making `onlyOwner` functions uncallable.
182
+
183
+ ### Precondition
184
+
185
+ The caller is the current owner and understands this action is irreversible.
186
+
187
+ ### Steps
188
+
189
+ 1. **Owner calls `renounceOwnership()`**
190
+
191
+ - `_checkOwner()` validates the caller
192
+
193
+ 2. **`_transferOwnership(address(0), 0)` executes**
194
+
195
+ - Records `oldOwner` (resolved from current `jbOwner`)
196
+ - Overwrites `jbOwner = JBOwner({owner: address(0), projectId: 0, permissionId: 0})`
197
+ - Calls `_emitTransferEvent(oldOwner, address(0), 0)`
198
+ - Emits `OwnershipTransferred(oldOwner, address(0), msg.sender)`
199
+
200
+ ### Result
201
+
202
+ `jbOwner` is zeroed out. `owner()` returns `address(0)`. All future calls to `_checkOwner()` revert because `_requirePermissionFrom(address(0), 0, 0)` fails for any `msg.sender` (no address equals `address(0)`, and no permission can satisfy the check against a zero-address account).
203
+
204
+ ### What to verify
205
+
206
+ - After renouncing, `transferOwnership`, `transferOwnershipToProject`, `setPermissionId`, and `renounceOwnership` all revert.
207
+ - There is no recovery mechanism. No admin backdoor. No timelock. Renouncement is permanent.
208
+ - A second call to `renounceOwnership()` also reverts (because `_checkOwner()` fails).
209
+ - Even ROOT holders cannot act as owner after renouncement, because `_requirePermissionFrom(address(0), 0, 0)` does not recognize ROOT as a valid bypass when the account is `address(0)`.
210
+
211
+ ---
212
+
213
+ ## Journey 6: Implicit Renouncement via Project NFT Burn
214
+
215
+ **Actor:** None (system behavior).
216
+ **Goal:** Understand what happens when the project NFT underlying a project-owned contract ceases to exist.
217
+
218
+ ### Precondition
219
+
220
+ The contract is project-owned (`jbOwner.projectId != 0`). The project NFT is burned or otherwise invalidated (note: JBProjects V6 has no burn function, so this is a defensive scenario).
221
+
222
+ ### Steps
223
+
224
+ 1. **`PROJECTS.ownerOf(projectId)` starts reverting**
225
+
226
+ - The ERC-721 `ownerOf` function reverts for burned tokens
227
+
228
+ 2. **`owner()` catches the revert and returns `address(0)`**
229
+
230
+ - The try-catch in `owner()` returns `address(0)` when `ownerOf` reverts
231
+
232
+ 3. **`_checkOwner()` catches the revert and resolves owner to `address(0)`**
233
+
234
+ - `_requirePermissionFrom(address(0), projectId, permissionId)` is called
235
+ - No `msg.sender` can equal `address(0)`, so the check always fails
236
+
237
+ ### Result
238
+
239
+ The contract is effectively renounced without anyone calling `renounceOwnership()`. All `onlyOwner` functions permanently revert. The `jbOwner` struct still contains the old `projectId`, but it has no practical effect.
240
+
241
+ ### What to verify
242
+
243
+ - There is no way to "revive" ownership after the NFT is burned. Even re-minting an NFT with the same ID (if possible) would restore ownership.
244
+ - The `jbOwner` struct is NOT cleared in this scenario -- it still shows the old `projectId`. Only the resolved owner is `address(0)`.
245
+ - This behavior is consistent between `owner()` and `_checkOwner()` (both use the same try-catch pattern).