@bananapus/ownable-v6 0.0.16 → 0.0.18

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/USER_JOURNEYS.md CHANGED
@@ -1,200 +1,119 @@
1
- # User Journeys -- nana-ownable-v6
1
+ # User Journeys
2
2
 
3
- Concrete end-to-end flows through the JBOwnable system. Each journey traces the exact function calls, state changes, events, and edge cases.
3
+ ## Repo Purpose
4
4
 
5
- ---
5
+ This repo adapts `Ownable`-style control to Juicebox project ownership and project-scoped operator permissions.
6
+ It is an ownership adapter. It does not replace the underlying ownership or permission registries in
7
+ [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md).
6
8
 
7
- ## Journey 1: Deploy a Project-Owned Contract
9
+ ## Primary Actors
8
10
 
9
- **Entry point**: `new MyHook(IJBPermissions permissions, IJBProjects projects, address(0), uint88 projectId)`
11
+ - protocol or product teams that want `onlyOwner` to follow a project NFT
12
+ - operators who need owner-like access without receiving the project itself
13
+ - auditors checking whether delegated owner semantics strand or over-grant authority
10
14
 
11
- **Who can call**: Anyone (deployment is permissionless).
15
+ ## Key Surfaces
12
16
 
13
- **Parameters**:
14
- - `permissions` -- The `IJBPermissions` contract used for delegated access checks
15
- - `projects` -- The `IJBProjects` contract used to resolve project NFT ownership
16
- - `initialOwner` -- Set to `address(0)` because ownership is project-based
17
- - `initialProjectIdOwner` -- The ID of the Juicebox project whose NFT holder becomes the owner
17
+ - `JBOwnable`: `Ownable`-style adapter whose owner follows a Juicebox project
18
+ - `JBOwnableOverrides`: extension that lets a project-scoped permission satisfy `onlyOwner`
19
+ - `owner()`, `transferOwnership(...)`, `transferOwnershipToProject(...)`, `setPermissionId(...)`: core ownership-resolution and migration paths
18
20
 
19
- **State changes**:
20
- 1. `PROJECTS` immutable set to `projects`
21
- 2. `PERMISSIONS` immutable set to `permissions` (inherited from `JBPermissioned`)
22
- 3. Constructor validates that if `initialProjectIdOwner != 0`, then `address(projects) != address(0)` (reverts with `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner` if the project ID is non-zero but the projects contract is the zero address). There is no project existence check in the constructor.
23
- 4. Constructor validates that at least one of `initialOwner` or `initialProjectIdOwner` is non-zero (reverts with `JBOwnableOverrides_InvalidNewOwner` if both are zero)
24
- 5. `_transferOwnership(address(0), projectId)` executes:
25
- - Sets `jbOwner = JBOwner({owner: address(0), projectId: projectId, permissionId: 0})`
26
- - Calls `_emitTransferEvent(address(0), address(0), projectId)`
27
- 6. `owner()` now resolves dynamically via `PROJECTS.ownerOf(projectId)`
21
+ ## Journey 1: Give A Contract To A Juicebox Project Instead Of A Wallet
28
22
 
29
- **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(address(0), PROJECTS.ownerOf(projectId), msg.sender)`
23
+ **Actor:** downstream contract author.
30
24
 
31
- **Edge cases**:
32
- - If the project does not exist (ID > `PROJECTS.count()`), the constructor still succeeds -- the existence check is only enforced in `transferOwnershipToProject`, not the constructor. If the project NFT has not yet been minted, `PROJECTS.ownerOf()` reverts, and the try-catch in `owner()` returns `address(0)`, effectively locking the contract until the project is minted.
33
- - `owner()` returns the current NFT holder dynamically. If the NFT is transferred, ownership automatically follows -- no on-chain update to the JBOwnable contract is needed.
34
- - Deploying with both `initialOwner == address(0)` and `initialProjectIdOwner == 0` reverts with `JBOwnableOverrides_InvalidNewOwner`. To create an unowned contract, set an owner and call `renounceOwnership()` in the constructor body.
25
+ **Intent:** make a contract follow Juicebox project ownership instead of a fixed EOA or multisig.
35
26
 
36
- **What to verify**:
37
- - `jbOwner.owner == address(0)` and `jbOwner.projectId == projectId` after construction
38
- - `jbOwner.permissionId == 0` (no delegated access until explicitly configured)
39
- - `owner()` returns the current NFT holder, not a cached value
27
+ **Preconditions**
28
+ - the downstream contract wants `onlyOwner` ergonomics
29
+ - a project ID and `JBProjects` dependency are already known
40
30
 
41
- ---
31
+ **Main Flow**
32
+ 1. Inherit `JBOwnable` or `JBOwnableOverrides`.
33
+ 2. Initialize ownership with the relevant project ID and `JBProjects` reference.
34
+ 3. Let `owner()` resolve through the current project NFT holder rather than a fixed address.
42
35
 
43
- ## Journey 1b: Deploy an Address-Owned Contract
36
+ **Failure Modes**
37
+ - the contract assumes ordinary `Ownable` transfer semantics after adopting project-based ownership
38
+ - the wrong project ID is configured
39
+ - reviewers ignore the adapter and audit the downstream contract as if `owner` were fixed
44
40
 
45
- **Entry point**: `new MyHook(IJBPermissions permissions, IJBProjects projects, address initialOwner, uint88(0))`
41
+ **Postconditions**
42
+ - `owner()` now resolves through the configured project NFT instead of a fixed wallet
46
43
 
47
- **Who can call**: Anyone (deployment is permissionless).
44
+ ## Journey 2: Delegate Owner-Level Access To Operators
48
45
 
49
- **Parameters**:
50
- - `permissions` -- The `IJBPermissions` contract used for delegated access checks
51
- - `projects` -- The `IJBProjects` contract (can be `address(0)` when not using project-based ownership)
52
- - `initialOwner` -- The address that becomes the contract owner (must not be `address(0)`)
53
- - `initialProjectIdOwner` -- Set to `0` because ownership is address-based
46
+ **Actor:** current project owner.
54
47
 
55
- **State changes**:
56
- 1. `PROJECTS` immutable set to `projects`
57
- 2. `PERMISSIONS` immutable set to `permissions` (inherited from `JBPermissioned`)
58
- 3. Constructor validates that `initialOwner != address(0)` (reverts with `JBOwnableOverrides_InvalidNewOwner` if both `initialOwner` and `initialProjectIdOwner` are zero)
59
- 4. `_transferOwnership(initialOwner, 0)` executes:
60
- - Sets `jbOwner = JBOwner({owner: initialOwner, projectId: 0, permissionId: 0})`
61
- - Calls `_emitTransferEvent(address(0), initialOwner, 0)`
48
+ **Intent:** let an operator satisfy `onlyOwner` for one contract without transferring the project.
62
49
 
63
- **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(address(0), initialOwner, msg.sender)`
50
+ **Preconditions**
51
+ - the downstream contract uses `JBOwnableOverrides`
52
+ - the team has chosen the permission ID that should count as delegated owner access
64
53
 
65
- **Edge cases**:
66
- - `owner()` returns `jbOwner.owner` directly (no `PROJECTS.ownerOf()` lookup since `projectId == 0`)
67
- - Ownership does NOT follow NFT transfers -- it is static until explicitly transferred via `transferOwnership()` or `transferOwnershipToProject()`
68
- - `PROJECTS` can be `address(0)` in this mode since it is never consulted for ownership resolution. However, `transferOwnershipToProject()` will revert if `PROJECTS` is `address(0)` (the `PROJECTS.count()` call reverts).
54
+ **Main Flow**
55
+ 1. Choose the permission ID the downstream contract should respect.
56
+ 2. Grant that permission through `JBPermissions`.
57
+ 3. `JBOwnableOverrides` treats the operator as satisfying `onlyOwner` for that contract.
69
58
 
70
- ---
59
+ **Failure Modes**
60
+ - teams grant a broader permission than intended
61
+ - downstream reviewers forget that `onlyOwner` may resolve through permissions instead of direct ownership
62
+ - operators retain stale permissions after governance changes
71
63
 
72
- ## Journey 2: Transfer Ownership to a Different Address
64
+ **Postconditions**
65
+ - the chosen operator can satisfy `onlyOwner` without receiving direct ownership of the project or contract
73
66
 
74
- **Entry point**: `JBOwnableOverrides.transferOwnership(address newOwner)`
67
+ ## Journey 3: Change The Delegated Permission ID Without Changing Ownership
75
68
 
76
- **Who can call**: The current owner (resolved via `PROJECTS.ownerOf()` if project-owned, or `jbOwner.owner` if address-owned), or any address with the configured `permissionId` (or ROOT) via `JBPermissions`.
69
+ **Actor:** current effective owner.
77
70
 
78
- **Parameters**:
79
- - `newOwner` -- The address to transfer ownership to (must not be `address(0)`)
71
+ **Intent:** rotate delegated owner policy without changing the underlying owner.
80
72
 
81
- **State changes**:
82
- 1. `_checkOwner()` validates the caller against the resolved owner and `permissionId`
83
- 2. Validates `newOwner != address(0)` (reverts with `JBOwnableOverrides_InvalidNewOwner`)
84
- 3. `_transferOwnership(newOwner, 0)` executes:
85
- - Records `oldOwner` (resolved from current `jbOwner`, with try-catch for burned project NFTs)
86
- - Overwrites `jbOwner = JBOwner({owner: newOwner, projectId: 0, permissionId: 0})`
87
- - Calls `_emitTransferEvent(oldOwner, newOwner, 0)`
73
+ **Preconditions**
74
+ - the contract already uses `JBOwnableOverrides`
75
+ - all operators who need continued access can be regranted under the new permission ID
88
76
 
89
- **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(oldOwner, newOwner, msg.sender)`
77
+ **Main Flow**
78
+ 1. Update the permission ID the adapter treats as owner-equivalent with `setPermissionId(...)`.
79
+ 2. Re-grant the new permission where needed.
80
+ 3. Re-audit operator assumptions because the old permission no longer satisfies `onlyOwner`.
90
81
 
91
- **Edge cases**:
92
- - If the contract was previously project-owned, `projectId` is now 0 (project ownership is cleared)
93
- - `permissionId` is reset to 0, revoking all previously delegated permissions. The new owner must call `setPermissionId()` to re-enable delegated access.
94
- - The previous owner (or their delegates) can no longer call `onlyOwner` functions
95
- - `newOwner` can immediately call `onlyOwner` functions without any additional setup
82
+ **Failure Modes**
83
+ - operator access disappears unintentionally after a permission-ID rotation
84
+ - teams forget that old delegations stop working immediately
96
85
 
97
- ---
86
+ **Postconditions**
87
+ - the adapter now resolves delegated owner access through the new permission ID only
98
88
 
99
- ## Journey 3: Transfer Ownership to a Juicebox Project
89
+ ## Journey 4: Transfer Or Burn Ownership Deliberately
100
90
 
101
- **Entry point**: `JBOwnableOverrides.transferOwnershipToProject(uint256 projectId)`
91
+ **Actor:** current effective owner.
102
92
 
103
- **Who can call**: The current owner (resolved via `PROJECTS.ownerOf()` if project-owned, or `jbOwner.owner` if address-owned), or any address with the configured `permissionId` (or ROOT) via `JBPermissions`.
93
+ **Intent:** move or remove control with full awareness of the consequences.
104
94
 
105
- **Parameters**:
106
- - `projectId` -- The ID of the Juicebox project to transfer ownership to (must be non-zero, fit in `uint88`, and refer to an existing project)
95
+ **Preconditions**
96
+ - the team understands whether admin recovery should remain possible
97
+ - downstream integrations can tolerate the new owner model
107
98
 
108
- **State changes**:
109
- 1. `_checkOwner()` validates the caller
110
- 2. Validates `projectId != 0` and `projectId <= type(uint88).max` (reverts with `JBOwnableOverrides_InvalidNewOwner`)
111
- 3. Validates `projectId <= PROJECTS.count()` (reverts with `JBOwnableOverrides_ProjectDoesNotExist`)
112
- 4. `_transferOwnership(address(0), uint88(projectId))` executes:
113
- - Records `oldOwner` (resolved from current `jbOwner`)
114
- - Overwrites `jbOwner = JBOwner({owner: address(0), projectId: uint88(projectId), permissionId: 0})`
115
- - Calls `_emitTransferEvent(oldOwner, address(0), uint88(projectId))`
99
+ **Main Flow**
100
+ 1. Use `transferOwnership(...)` for an address owner or `transferOwnershipToProject(...)` for a project owner.
101
+ 2. Re-establish delegated permission policy if the new owner still wants operators.
102
+ 3. Renounce or burn only when permanent admin loss is intentional.
116
103
 
117
- **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(oldOwner, PROJECTS.ownerOf(projectId), msg.sender)`
104
+ **Failure Modes**
105
+ - ownership is burned even though the downstream contract still needs administration
106
+ - teams forget that permission-ID delegation resets across ownership changes
118
107
 
119
- **Edge cases**:
120
- - The project existence check (`projectId <= PROJECTS.count()`) prevents transferring to a nonexistent project
121
- - The `uint88` cast does not truncate (the preceding `type(uint88).max` check ensures this)
122
- - `permissionId` is reset to 0 on transfer. The new project owner must call `setPermissionId()` to configure delegation.
123
- - If the project NFT is subsequently burned (hypothetically), `owner()` returns `address(0)` and the contract is effectively renounced
124
- - Unlike the constructor, `_emitTransferEvent` calls `PROJECTS.ownerOf(newProjectId)` without try-catch -- if the project does not exist, the transaction reverts. This is intentional: the `PROJECTS.count()` check above prevents this path.
108
+ **Postconditions**
109
+ - control moves to the chosen address or project, or is intentionally removed
125
110
 
126
- ---
111
+ ## Trust Boundaries
127
112
 
128
- ## Journey 4: Delegate Access via Permission ID
113
+ - this repo trusts `JBProjects` for project ownership and `JBPermissions` for delegated authority
114
+ - downstream contracts still need their own audit because this adapter changes who satisfies `onlyOwner`
129
115
 
130
- **Entry point**: `JBOwnableOverrides.setPermissionId(uint8 permissionId)`
116
+ ## Hand-Offs
131
117
 
132
- **Who can call**: The current owner (resolved via `PROJECTS.ownerOf()` if project-owned, or `jbOwner.owner` if address-owned), or any address with the currently configured `permissionId` (or ROOT) via `JBPermissions`.
133
-
134
- **Parameters**:
135
- - `permissionId` -- The new permission ID to use for `onlyOwner` access delegation
136
-
137
- **State changes**:
138
- 1. `_checkOwner()` validates the caller
139
- 2. `_setPermissionId(permissionId)` writes `jbOwner.permissionId = permissionId`
140
-
141
- **Events**: `PermissionIdChanged(uint8 newId, address caller)` -- emitted as `PermissionIdChanged(permissionId, msg.sender)`
142
-
143
- **Granting the permission to operators** (external step, not on JBOwnable):
144
- - The owner calls `permissions.setPermissionsFor(account, JBPermissionsData({operator: operatorAddress, projectId: projectId, permissionIds: [permissionId]}))` on the `JBPermissions` contract
145
- - Operators can then call any `onlyOwner` function. `_checkOwner()` resolves the owner and calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`, which passes if the operator has the matching permission.
146
-
147
- **Edge cases**:
148
- - `permissionId == 0` effectively disables delegation (permission ID 0 cannot be set in `JBPermissions`). Only the owner (or ROOT holders) can call `onlyOwner` functions.
149
- - If the owner transfers ownership, `permissionId` resets to 0. The new owner must re-configure delegation.
150
- - ROOT (permission ID 1) bypasses the configured `permissionId` check, but only when the operator has been granted ROOT **by the resolved owner** via `JBPermissions`. ROOT is not a global override -- it is scoped to the `account` (i.e. the resolved owner) that granted it. This is a feature of `JBPermissioned`, not specific to `JBOwnable`. After renouncement (resolved owner = `address(0)`), ROOT cannot help because no one can obtain permissions granted by `address(0)`.
151
- - 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.
152
-
153
- ---
154
-
155
- ## Journey 5: Renounce Ownership
156
-
157
- **Entry point**: `JBOwnableOverrides.renounceOwnership()`
158
-
159
- **Who can call**: The current owner (resolved via `PROJECTS.ownerOf()` if project-owned, or `jbOwner.owner` if address-owned), or any address with the configured `permissionId` (or ROOT) via `JBPermissions`.
160
-
161
- **Parameters**: None.
162
-
163
- **State changes**:
164
- 1. `_checkOwner()` validates the caller
165
- 2. `_transferOwnership(address(0), 0)` executes:
166
- - Records `oldOwner` (resolved from current `jbOwner`)
167
- - Overwrites `jbOwner = JBOwner({owner: address(0), projectId: 0, permissionId: 0})`
168
- - Calls `_emitTransferEvent(oldOwner, address(0), 0)`
169
-
170
- **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(oldOwner, address(0), msg.sender)`
171
-
172
- **Edge cases**:
173
- - After renouncing, `transferOwnership`, `transferOwnershipToProject`, `setPermissionId`, and `renounceOwnership` all revert
174
- - There is no recovery mechanism. No admin backdoor. No timelock. Renouncement is permanent.
175
- - A second call to `renounceOwnership()` also reverts (because `_checkOwner()` fails)
176
- - Even ROOT holders cannot act as owner after renouncement. Although `_requirePermissionFrom(address(0), 0, 0)` is called with `includeRoot: true`, no operator can possess ROOT (or any permission) granted by `address(0)` -- `JBPermissions.setPermissionsFor` requires the caller to be the account or an existing ROOT operator of the account, and no one starts with permissions from `address(0)`
177
-
178
- ---
179
-
180
- ## Journey 6: Implicit Renouncement via Project NFT Burn
181
-
182
- **Actor**: None (system behavior).
183
-
184
- **Who can call**: N/A -- this is an emergent behavior, not a direct function call.
185
-
186
- **Parameters**: None.
187
-
188
- **Precondition**: 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).
189
-
190
- **State changes**:
191
- 1. `PROJECTS.ownerOf(projectId)` starts reverting (ERC-721 `ownerOf` reverts for burned tokens)
192
- 2. `owner()` catches the revert via try-catch and returns `address(0)`
193
- 3. `_checkOwner()` catches the revert and resolves owner to `address(0)`, causing `_requirePermissionFrom(address(0), projectId, permissionId)` to fail for any `msg.sender` -- no operator can possess permissions granted by `address(0)` (same reason as explicit renouncement in Journey 5)
194
-
195
- **Events**: None (no transaction occurs on the JBOwnable contract).
196
-
197
- **Edge cases**:
198
- - There is no way to "revive" ownership after the NFT is burned. However, re-minting an NFT with the same ID (if possible) would restore ownership.
199
- - The `jbOwner` struct is NOT cleared -- it still shows the old `projectId`. Only the resolved owner is `address(0)`.
200
- - This behavior is consistent between `owner()` and `_checkOwner()` (both use the same try-catch pattern)
118
+ - Use [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md) for the project-NFT and permission machinery this adapter depends on.
119
+ - Use [nana-permission-ids-v6](../nana-permission-ids-v6/USER_JOURNEYS.md) if you need the shared numeric permission vocabulary for delegated `onlyOwner` checks.
package/foundry.toml CHANGED
@@ -13,6 +13,8 @@ runs = 1024
13
13
  depth = 100
14
14
  fail_on_revert = false
15
15
 
16
+ [lint]
17
+ exclude_lints = ["pascal-case-struct", "mixed-case-variable"]
16
18
  [fmt]
17
19
  number_underscore = "thousands"
18
20
  multiline_func_header = "all"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/ownable-v6",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,8 +10,8 @@
10
10
  "node": ">=20.0.0"
11
11
  },
12
12
  "dependencies": {
13
- "@bananapus/core-v6": "^0.0.30",
14
- "@bananapus/permission-ids-v6": "^0.0.15",
13
+ "@bananapus/core-v6": "^0.0.34",
14
+ "@bananapus/permission-ids-v6": "^0.0.17",
15
15
  "@openzeppelin/contracts": "^5.6.1"
16
16
  },
17
17
  "scripts": {
@@ -0,0 +1,14 @@
1
+ # Ownable Operations
2
+
3
+ ## Change Checklist
4
+
5
+ - If you edit owner resolution, verify both direct ownership and project-owned cases.
6
+ - If you edit permission handling, verify transfer-time reset behavior.
7
+ - If an integration expects long-lived delegated access, confirm whether the transfer-reset rule invalidates that assumption.
8
+ - If the change touches project ownership, check unminted-project and burn-lock regressions before assuming the happy-path tests are enough.
9
+
10
+ ## Common Failure Modes
11
+
12
+ - Integrations assume delegated operators survive ownership transfer.
13
+ - Bugs are blamed on this repo when the underlying project NFT ownership changed upstream.
14
+ - A project-owned contract is treated like an address-owned contract and the wrong actor is allowed through `onlyOwner`.
@@ -0,0 +1,19 @@
1
+ # Ownable Runtime
2
+
3
+ ## Core Roles
4
+
5
+ - [`src/JBOwnable.sol`](../src/JBOwnable.sol) is the concrete downstream inheritance surface.
6
+ - [`src/JBOwnableOverrides.sol`](../src/JBOwnableOverrides.sol) owns owner resolution and delegated permission checks.
7
+
8
+ ## High-Risk Areas
9
+
10
+ - Effective-owner resolution: ownership may follow a project NFT rather than a fixed address.
11
+ - Delegated `onlyOwner` permissions: the chosen permission ID changes who can administer a contract.
12
+ - Transfer semantics: permission IDs reset on transfer, which is safer but easy to forget.
13
+
14
+ ## Tests To Trust First
15
+
16
+ - [`test/Ownable.t.sol`](../test/Ownable.t.sol) for baseline behavior.
17
+ - [`test/OwnableEdgeCases.t.sol`](../test/OwnableEdgeCases.t.sol) and [`test/OwnableAttacks.t.sol`](../test/OwnableAttacks.t.sol) for edge and adversarial cases.
18
+ - [`test/OwnableInvariantTests.sol`](../test/OwnableInvariantTests.sol) for broader invariants.
19
+ - [`test/regression/BurnLockProtection.t.sol`](../test/regression/BurnLockProtection.t.sol), [`test/regression/ZeroAddressValidation.t.sol`](../test/regression/ZeroAddressValidation.t.sol), and [`test/CodexUnmintedProjectHijack.t.sol`](../test/CodexUnmintedProjectHijack.t.sol) for the regressions most likely to matter in review.
package/src/JBOwnable.sol CHANGED
@@ -59,8 +59,8 @@ contract JBOwnable is JBOwnableOverrides {
59
59
  /// @dev This function exists because some contracts need to deploy contracts for a project before the project's NFT
60
60
  /// has been minted, so the transfer event resolves the project's current owner at emission time.
61
61
  /// @dev Unlike `_transferOwnership` (which uses try-catch to resolve the *old* owner in case its project NFT was
62
- /// burned), this function intentionally lets `PROJECTS.ownerOf(newProjectId)` revert if the new project doesn't
63
- /// exist. A revert here is desirable it prevents transferring ownership to a non-existent project.
62
+ /// burned), this function resolves the *new* owner's current address for event purposes only.
63
+ /// If the new project NFT does not exist yet, the event uses `address(0)` until ownership can resolve normally.
64
64
  function _emitTransferEvent(
65
65
  address previousOwner,
66
66
  address newOwner,
@@ -70,10 +70,17 @@ contract JBOwnable is JBOwnableOverrides {
70
70
  virtual
71
71
  override
72
72
  {
73
- emit OwnershipTransferred({
74
- previousOwner: previousOwner,
75
- newOwner: newProjectId == 0 ? newOwner : PROJECTS.ownerOf(newProjectId),
76
- caller: _msgSender()
77
- });
73
+ address resolvedNewOwner = newOwner;
74
+ if (newProjectId != 0) {
75
+ try PROJECTS.ownerOf(newProjectId) returns (address projectOwner) {
76
+ resolvedNewOwner = projectOwner;
77
+ } catch {
78
+ // Allow constructor-time handoff to an unminted project. Ownership resolves dynamically
79
+ // once the project NFT exists, so the transfer event uses address(0) until then.
80
+ resolvedNewOwner = address(0);
81
+ }
82
+ }
83
+
84
+ emit OwnershipTransferred({previousOwner: previousOwner, newOwner: resolvedNewOwner, caller: _msgSender()});
78
85
  }
79
86
  }
@@ -45,6 +45,10 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
45
45
  /// To restrict access to a specific address, pass that address as the `initialOwner` and `0` as the
46
46
  /// `initialProjectIdOwner`.
47
47
  /// @dev The owner can give owner access to other addresses through the `permissions` contract.
48
+ /// @dev If `initialProjectIdOwner` references a project ID that has not yet been minted, all ownership checks will
49
+ /// revert until that project is created, leaving the contract unusable. Deployers must ensure that the referenced
50
+ /// project is minted before or atomically with this contract's deployment — this is a deployment trust
51
+ /// assumption.
48
52
  /// @param permissions A contract storing permissions.
49
53
  /// @param projects Mints ERC-721s that represent project ownership and transfers.
50
54
  /// @param initialOwner The owner if the `initialProjectIdOwner` is 0 (until ownership is transferred).
@@ -7,7 +7,6 @@ pragma solidity ^0.8.0;
7
7
  /// `owner` address has owner access.
8
8
  /// @custom:member permissionId The permission ID which corresponds to owner access. See `JBPermissions` in `nana-core`
9
9
  /// and `nana-permission-ids`.
10
- // forge-lint: disable-next-line(pascal-case-struct)
11
10
  struct JBOwner {
12
11
  address owner;
13
12
  uint88 projectId;
@@ -0,0 +1,45 @@
1
+ // SPDX-License-Identifier: UNLICENSED
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {MockOwnable} from "./mocks/MockOwnable.sol";
7
+
8
+ import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
9
+ import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
10
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
11
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
12
+
13
+ contract CodexUnmintedProjectHijackTest is Test {
14
+ IJBProjects internal projects;
15
+ IJBPermissions internal permissions;
16
+
17
+ address internal deployer = makeAddr("deployer");
18
+ address internal attacker = makeAddr("attacker");
19
+ address internal intendedOwner = makeAddr("intendedOwner");
20
+
21
+ function setUp() public {
22
+ permissions = new JBPermissions(address(0));
23
+ projects = new JBProjects(address(this), address(0), address(0));
24
+ }
25
+
26
+ function test_unmintedProjectOwnerCanBeHijackedByFirstMinter() external {
27
+ vm.prank(deployer);
28
+ MockOwnable ownable = new MockOwnable(projects, permissions, address(0), 1);
29
+
30
+ assertEq(ownable.owner(), address(0), "owner should be unresolved before project 1 exists");
31
+
32
+ vm.prank(attacker);
33
+ uint256 hijackedProjectId = projects.createFor(attacker);
34
+
35
+ assertEq(hijackedProjectId, 1, "attacker should receive the configured project ID");
36
+ assertEq(ownable.owner(), attacker, "first minter becomes owner of the ownable contract");
37
+
38
+ vm.prank(attacker);
39
+ ownable.protectedMethod();
40
+
41
+ vm.prank(intendedOwner);
42
+ vm.expectRevert();
43
+ ownable.protectedMethod();
44
+ }
45
+ }
@@ -292,7 +292,23 @@ contract OwnableEdgeCases is Test {
292
292
  }
293
293
 
294
294
  // =========================================================================
295
- // Test 10: Fuzz transfer to any valid project, verify owner resolution
295
+ // Test 10: Constructor tolerates an unminted project owner
296
+ // =========================================================================
297
+ function test_constructorWithUnmintedProject_emitsZeroOwnerUntilMinted() public {
298
+ vm.expectEmit(true, true, false, true);
299
+ emit IJBOwnable.OwnershipTransferred(address(0), address(0), address(this));
300
+
301
+ MockOwnable ownable = new MockOwnable(projects, permissions, address(0), uint88(1));
302
+
303
+ assertEq(ownable.owner(), address(0), "Owner should resolve to zero before the project exists");
304
+
305
+ uint256 projectId = projects.createFor(alice);
306
+ assertEq(projectId, 1, "Expected the first minted project to match the configured future owner");
307
+ assertEq(ownable.owner(), alice, "Owner should resolve once the project is minted");
308
+ }
309
+
310
+ // =========================================================================
311
+ // Test 11: Fuzz — transfer to any valid project, verify owner resolution
296
312
  // =========================================================================
297
313
  function testFuzz_transferToProject(address projectOwner) public isNotContract(projectOwner) {
298
314
  vm.assume(projectOwner != address(0));
@@ -313,7 +329,7 @@ contract OwnableEdgeCases is Test {
313
329
  }
314
330
 
315
331
  // =========================================================================
316
- // Test 11: Renounced contract cannot reclaim ownership
332
+ // Test 12: Renounced contract cannot reclaim ownership
317
333
  // =========================================================================
318
334
  /// @notice After renouncing, no one can call transferOwnership, transferOwnershipToProject,
319
335
  /// setPermissionId, or renounceOwnership again.
@@ -350,7 +366,7 @@ contract OwnableEdgeCases is Test {
350
366
  }
351
367
 
352
368
  // =========================================================================
353
- // Test 12: _msgSender is NOT ERC2771-aware (design documentation)
369
+ // Test 13: _msgSender is NOT ERC2771-aware (design documentation)
354
370
  // =========================================================================
355
371
  /// @notice JBOwnable uses plain Context._msgSender() (returns msg.sender),
356
372
  /// NOT ERC2771Context. This test documents that a trusted forwarder
@@ -374,7 +390,7 @@ contract OwnableEdgeCases is Test {
374
390
  }
375
391
 
376
392
  // =========================================================================
377
- // Test 13: OwnershipTransferred event uses _msgSender() (L-27 fix)
393
+ // Test 14: OwnershipTransferred event uses _msgSender() (L-27 fix)
378
394
  // =========================================================================
379
395
  /// @notice When a subclass overrides _msgSender() (e.g., for ERC-2771),
380
396
  /// the OwnershipTransferred event's caller field should reflect the
@@ -397,7 +413,7 @@ contract OwnableEdgeCases is Test {
397
413
  }
398
414
 
399
415
  // =========================================================================
400
- // Test 14: PermissionIdChanged event uses _msgSender() (L-27 fix)
416
+ // Test 15: PermissionIdChanged event uses _msgSender() (L-27 fix)
401
417
  // =========================================================================
402
418
  /// @notice When a subclass overrides _msgSender() (e.g., for ERC-2771),
403
419
  /// the PermissionIdChanged event's caller field should reflect the