@bananapus/ownable-v6 0.0.4 → 0.0.5

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/README.md CHANGED
@@ -1,40 +1,82 @@
1
- # nana-ownable-v6
1
+ # Juicebox Ownable
2
2
 
3
- Juicebox-aware ownership model that ties contract ownership to a Juicebox project NFT or an address, with delegated access through `JBPermissions`.
3
+ Ownership that follows the project, not a person. Transfer control of any contract to a Juicebox project NFT, and anyone the project owner delegates through `JBPermissions` can act as owner.
4
4
 
5
5
  This is a variation on OpenZeppelin [`Ownable`](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/access/Ownable.sol) that adds:
6
6
 
7
- - The ability to transfer contract ownership to a Juicebox project instead of a specific address.
8
- - The ability to grant other addresses `onlyOwner` access using `JBPermissions`.
9
- - `JBPermissioned` modifiers with support for OpenZeppelin `Context` (enabling optional meta-transaction support).
7
+ - The ability to transfer contract ownership to a Juicebox project instead of a specific address. When owned by a project, ownership dynamically follows whoever holds that project's ERC-721 NFT -- no on-chain update needed.
8
+ - The ability to grant other addresses `onlyOwner` access using `JBPermissions`, with a configurable `permissionId`.
9
+ - `JBPermissioned` base class with support for OpenZeppelin `Context` (enabling optional meta-transaction support).
10
10
 
11
- All features are backwards compatible with OpenZeppelin `Ownable`. This should be a drop-in replacement. Only use `JBOwnableOverrides` if you are overriding OpenZeppelin `Ownable` v4.7.0 or higher.
11
+ All features are backwards compatible with OpenZeppelin `Ownable`. `JBOwnable` is a drop-in replacement: it provides the same `onlyOwner` modifier, `owner()` view, `transferOwnership(address)`, and `renounceOwnership()` functions.
12
12
 
13
13
  Forked from [`jbx-protocol/juice-ownable`](https://github.com/jbx-protocol/juice-ownable).
14
14
 
15
- _If you have questions, take a look at the [core protocol contracts](https://github.com/Bananapus/nana-core) and the [documentation](https://docs.juicebox.money/) first, or reach out on [Discord](https://discord.com/invite/ErQYmth4dS)._
15
+ _If you have questions, take a look at the [core protocol contracts](https://github.com/Bananapus/nana-core-v6) and the [documentation](https://docs.juicebox.money/) first, or reach out on [Discord](https://discord.com/invite/ErQYmth4dS)._
16
16
 
17
17
  ## Architecture
18
18
 
19
+ ```
20
+ JBOwnable
21
+ └── JBOwnableOverrides (abstract)
22
+ ├── Context (OpenZeppelin)
23
+ ├── JBPermissioned (nana-core-v6)
24
+ └── IJBOwnable (interface)
25
+ ```
26
+
19
27
  | Contract | Description |
20
28
  |----------|-------------|
21
- | `JBOwnable` | Concrete implementation providing an `onlyOwner` modifier and ownership transfer events. Inherits `JBOwnableOverrides`. |
22
- | `JBOwnableOverrides` | Abstract base containing all ownership logic: owner resolution, transfers, renunciation, and permission delegation via `JBPermissions`. |
29
+ | [`JBOwnable`](src/JBOwnable.sol) | Concrete implementation. Provides the `onlyOwner` modifier and emits `OwnershipTransferred` events (resolving project NFT holders at emission time). Inherit this in your contract. |
30
+ | [`JBOwnableOverrides`](src/JBOwnableOverrides.sol) | Abstract base containing all ownership logic: owner resolution, transfers, renunciation, permission delegation, and internal helpers. Use this directly only if you need to customize `_emitTransferEvent` (e.g., when deploying contracts before the project NFT is minted). |
23
31
 
24
32
  ### Supporting Types
25
33
 
26
- | Type | Description |
27
- |------|-------------|
28
- | `JBOwner` | Struct packing `address owner`, `uint88 projectId`, and `uint8 permissionId` into a single slot. |
29
- | `IJBOwnable` | Interface exposing ownership queries, transfers, renunciation, and permission ID management. |
34
+ | Type | Location | Description |
35
+ |------|----------|-------------|
36
+ | [`JBOwner`](src/structs/JBOwner.sol) | `src/structs/` | Struct packing `address owner` (160 bits), `uint88 projectId` (88 bits), and `uint8 permissionId` (8 bits) into a single 256-bit storage slot. |
37
+ | [`IJBOwnable`](src/interfaces/IJBOwnable.sol) | `src/interfaces/` | Interface exposing ownership queries, transfers, renunciation, permission ID management, and events. |
30
38
 
31
39
  ### Ownership Modes
32
40
 
33
- 1. **Project ownership** -- If `JBOwner.projectId` is nonzero, the holder of that `JBProjects` ERC-721 is the owner.
41
+ 1. **Project ownership** -- If `JBOwner.projectId` is nonzero, the current holder of that `JBProjects` ERC-721 is the owner. Ownership automatically follows the NFT: when the NFT is transferred, `owner()` immediately reflects the new holder without any additional transaction.
34
42
  2. **Address ownership** -- If `projectId` is zero, `JBOwner.owner` is the owner directly.
35
- 3. **Delegated access** -- The owner can grant others access via `JBPermissions.setPermissionsFor(...)` using the configured `permissionId`.
43
+ 3. **Delegated access** -- The owner can grant other addresses access via `JBPermissions.setPermissionsFor(...)` using the configured `permissionId`. The owner must first call `setPermissionId(uint8)` to set which permission ID represents owner-level access.
44
+ 4. **Renounced** -- After calling `renounceOwnership()`, both `owner` and `projectId` are set to zero. No one can call `onlyOwner` functions. This is irreversible.
45
+
46
+ The `permissionId` resets to 0 on every ownership transfer to prevent permission clashes for the new owner.
47
+
48
+ ### Events
49
+
50
+ | Event | Emitted By |
51
+ |-------|-----------|
52
+ | `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` | `_emitTransferEvent` (called on every ownership change) |
53
+ | `PermissionIdChanged(uint8 newId, address caller)` | `_setPermissionId` |
54
+
55
+ ### Errors
56
+
57
+ | Error | Thrown When |
58
+ |-------|-----------|
59
+ | `JBOwnableOverrides_InvalidNewOwner()` | Constructor receives both zero owner and zero projectId; `transferOwnership` receives zero address; `transferOwnershipToProject` receives zero or overflow projectId; `_transferOwnership` receives both non-zero owner and non-zero projectId. |
60
+ | `JBOwnableOverrides_ProjectDoesNotExist()` | `transferOwnershipToProject` receives a projectId greater than `PROJECTS.count()`. |
61
+ | `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | Constructor receives a non-zero `initialProjectIdOwner` with `projects` set to `address(0)`. |
62
+
63
+ ## How Ownership Resolution Works
64
+
65
+ When `_checkOwner()` is called (by the `onlyOwner` modifier), it:
66
+
67
+ 1. Reads the `JBOwner` struct from storage.
68
+ 2. Resolves the owner address: if `projectId != 0`, calls `PROJECTS.ownerOf(projectId)` via try-catch (returns `address(0)` if the call reverts, e.g., burned NFT); otherwise uses the stored `owner` address.
69
+ 3. Calls `_requirePermissionFrom(account, projectId, permissionId)` from `JBPermissioned`, which passes if the caller is:
70
+ - The resolved owner address, OR
71
+ - An address granted the configured `permissionId` on the relevant project via `JBPermissions`, OR
72
+ - An address granted the ROOT permission (permission ID 1) on the relevant project via `JBPermissions`.
73
+
74
+ ## How Delegated Access Works
36
75
 
37
- The `permissionId` resets to 0 on every ownership transfer to prevent permission clashes.
76
+ 1. The owner calls `setPermissionId(uint8)` on the `JBOwnable` contract to configure which permission ID represents owner-level access.
77
+ 2. The owner calls `JBPermissions.setPermissionsFor(...)` to grant that permission ID to specific addresses on the relevant project.
78
+ 3. Those addresses can now call `onlyOwner` functions.
79
+ 4. If the project NFT is transferred to a new holder, delegated permissions granted by the previous holder stop working -- the new holder must re-grant permissions.
38
80
 
39
81
  ## Install
40
82
 
package/SKILLS.md CHANGED
@@ -1,52 +1,104 @@
1
- # nana-ownable-v6
1
+ # Juicebox Ownable
2
2
 
3
3
  ## Purpose
4
4
 
5
- Drop-in Juicebox-aware replacement for OpenZeppelin `Ownable` that lets a contract be owned by a Juicebox project (via its ERC-721) or a plain address, with delegated access through `JBPermissions`.
5
+ Drop-in Juicebox-aware replacement for OpenZeppelin `Ownable`. A contract inheriting `JBOwnable` can be owned by a Juicebox project (via its ERC-721 NFT) or a plain address, with delegated access through `JBPermissions`. When owned by a project, ownership dynamically follows the NFT holder -- no on-chain update is needed when the NFT changes hands.
6
6
 
7
7
  ## Contracts
8
8
 
9
9
  | Contract | Role |
10
10
  |----------|------|
11
- | `JBOwnable` | Concrete modifier (`onlyOwner`) and event emission for ownership transfers. |
12
- | `JBOwnableOverrides` | Abstract base with ownership state, resolution, transfers, renunciation, and permission delegation. |
11
+ | `JBOwnable` | Concrete implementation. Provides the `onlyOwner` modifier and `_emitTransferEvent` (resolves project NFT holder at emission time). Inherit this in your contract. |
12
+ | `JBOwnableOverrides` | Abstract base with all ownership state and logic: owner resolution, transfers, renunciation, permission delegation, and internal helpers. Only inherit this directly if you need to customize `_emitTransferEvent`. |
13
+
14
+ ## Inheritance Chain
15
+
16
+ ```
17
+ JBOwnable
18
+ └── JBOwnableOverrides (abstract)
19
+ ├── Context (@openzeppelin/contracts)
20
+ ├── JBPermissioned (@bananapus/core-v6)
21
+ └── IJBOwnable (interface)
22
+ ```
13
23
 
14
24
  ## Key Functions
15
25
 
26
+ ### Public / External
27
+
16
28
  | Function | Contract | What it does |
17
29
  |----------|----------|--------------|
18
- | `owner()` | `JBOwnableOverrides` | Returns the current owner address -- resolves project NFT holder if `projectId` is set. |
19
- | `transferOwnership(address)` | `JBOwnableOverrides` | Transfers ownership to a new address. Resets `permissionId`. |
20
- | `transferOwnershipToProject(uint256)` | `JBOwnableOverrides` | Transfers ownership to a Juicebox project. The project NFT holder becomes owner. |
21
- | `renounceOwnership()` | `JBOwnableOverrides` | Permanently gives up ownership (sets owner to zero address, projectId to 0). |
22
- | `setPermissionId(uint8)` | `JBOwnableOverrides` | Changes which `JBPermissions` permission ID grants delegated owner access. |
23
- | `_checkOwner()` | `JBOwnableOverrides` | Internal view used by `onlyOwner`; calls `_requirePermissionFrom` against the resolved owner. |
30
+ | `owner()` | `JBOwnableOverrides` | Returns the current owner address. If `projectId != 0`, calls `PROJECTS.ownerOf(projectId)` via try-catch -- returns the project NFT holder on success, or `address(0)` if the call reverts (e.g., burned NFT). Otherwise returns `jbOwner.owner`. |
31
+ | `transferOwnership(address newOwner)` | `JBOwnableOverrides` | Transfers ownership to a new address. Reverts if `newOwner` is `address(0)`. Resets `permissionId` to 0. |
32
+ | `transferOwnershipToProject(uint256 projectId)` | `JBOwnableOverrides` | Transfers ownership to a Juicebox project. The NFT holder becomes the owner. Validates: `projectId != 0`, fits in `uint88`, and `projectId <= PROJECTS.count()`. Resets `permissionId` to 0. |
33
+ | `renounceOwnership()` | `JBOwnableOverrides` | Permanently gives up ownership. Sets both `owner` and `projectId` to zero. Irreversible -- no one can call `onlyOwner` functions afterward. |
34
+ | `setPermissionId(uint8 permissionId)` | `JBOwnableOverrides` | Sets which `JBPermissions` permission ID grants delegated owner access. Only callable by the current owner. |
24
35
 
25
- ## Integration Points
36
+ ### Internal
26
37
 
27
- | Dependency | Import | Used For |
28
- |------------|--------|----------|
29
- | `nana-core-v6` | `IJBPermissions`, `JBPermissioned` | Permission checks for delegated access |
30
- | `nana-core-v6` | `IJBProjects` | Resolving project NFT holder as owner |
31
- | `@openzeppelin/contracts` | `Context` | `_msgSender()` support |
38
+ | Function | Contract | What it does |
39
+ |----------|----------|--------------|
40
+ | `_checkOwner()` | `JBOwnableOverrides` | Resolves the owner, then calls `_requirePermissionFrom(account, projectId, permissionId)`. Used by the `onlyOwner` modifier. |
41
+ | `_transferOwnership(address newOwner, uint88 projectId)` | `JBOwnableOverrides` | Core transfer logic. Reverts if both `newOwner` and `projectId` are non-zero. Updates `jbOwner` struct and resets `permissionId` to 0. Calls `_emitTransferEvent`. No access restriction. |
42
+ | `_transferOwnership(address newOwner)` | `JBOwnableOverrides` | Convenience overload that calls `_transferOwnership(newOwner, 0)`. Exists for drop-in compatibility with OpenZeppelin `Ownable`. |
43
+ | `_setPermissionId(uint8 permissionId)` | `JBOwnableOverrides` | Sets `jbOwner.permissionId` and emits `PermissionIdChanged`. No access restriction -- meant for internal use. |
44
+ | `_emitTransferEvent(address previousOwner, address newOwner, uint88 newProjectId)` | `JBOwnable` (overrides abstract in `JBOwnableOverrides`) | Emits `OwnershipTransferred`. Resolves `newProjectId` to the current NFT holder via `PROJECTS.ownerOf()`. Override this in `JBOwnableOverrides` subclasses if you need custom transfer event behavior (e.g., deploying before a project NFT is minted). |
45
+
46
+ ## Immutable State
47
+
48
+ | Variable | Type | Description |
49
+ |----------|------|-------------|
50
+ | `PROJECTS` | `IJBProjects` | The `JBProjects` ERC-721 contract used to resolve project ownership. |
51
+ | `PERMISSIONS` | `IJBPermissions` | Inherited from `JBPermissioned`. The `JBPermissions` contract used for delegated access checks. |
32
52
 
33
53
  ## Key Types
34
54
 
35
- | Struct/Enum | Key Fields | Used In |
36
- |-------------|------------|---------|
37
- | `JBOwner` | `address owner`, `uint88 projectId`, `uint8 permissionId` | `JBOwnableOverrides.jbOwner` -- single storage slot (160+88+8=256 bits) |
55
+ | Type | Fields | Storage |
56
+ |------|--------|---------|
57
+ | `JBOwner` | `address owner` (160 bits), `uint88 projectId` (88 bits), `uint8 permissionId` (8 bits) | Single 256-bit slot. `owner` and `projectId` are mutually exclusive (both non-zero is invalid). |
58
+
59
+ ## Events
60
+
61
+ | Event | Fields | When |
62
+ |-------|--------|------|
63
+ | `OwnershipTransferred` | `address indexed previousOwner`, `address indexed newOwner`, `address caller` | Every ownership change (transfer, project transfer, renounce). |
64
+ | `PermissionIdChanged` | `uint8 newId`, `address caller` | When `setPermissionId` or `_setPermissionId` is called. |
65
+
66
+ ## Errors
67
+
68
+ | Error | When |
69
+ |-------|------|
70
+ | `JBOwnableOverrides_InvalidNewOwner()` | Constructor gets both zero owner and zero projectId. `transferOwnership(address(0))`. `transferOwnershipToProject(0)` or `transferOwnershipToProject(id > type(uint88).max)`. `_transferOwnership` called with both non-zero owner and non-zero projectId. |
71
+ | `JBOwnableOverrides_ProjectDoesNotExist()` | `transferOwnershipToProject(id)` where `id > PROJECTS.count()`. |
72
+ | `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | Constructor receives a non-zero `initialProjectIdOwner` with `projects` set to `address(0)`. |
73
+
74
+ ## Integration Points
75
+
76
+ | Dependency | Import | Used For |
77
+ |------------|--------|----------|
78
+ | `nana-core-v6` | `IJBPermissions`, `JBPermissioned` | Permission checks for delegated access via `_requirePermissionFrom` |
79
+ | `nana-core-v6` | `IJBProjects` | Resolving project NFT holder as owner via `PROJECTS.ownerOf(projectId)` |
80
+ | `@openzeppelin/contracts` | `Context` | `_msgSender()` support for meta-transaction compatibility |
38
81
 
39
82
  ## Gotchas
40
83
 
41
- - You cannot set both `owner` and `projectId` to nonzero values simultaneously -- `_transferOwnership` reverts with `JBOwnableOverrides_InvalidNewOwner`.
42
- - Constructor reverts if both `initialOwner` and `initialProjectIdOwner` are zero. To create an unowned contract, set an owner then call `renounceOwnership()` in the constructor body.
43
- - `_transferOwnership` resets `permissionId` to 0 on every transfer, which revokes all previously delegated access.
44
- - `projectId` is `uint88`, so project IDs above `type(uint88).max` are rejected by `transferOwnershipToProject`.
84
+ - **Mutually exclusive ownership modes.** You cannot set both `owner` and `projectId` to non-zero values. The constructor, `transferOwnership`, and `transferOwnershipToProject` all enforce this. `_transferOwnership` reverts with `JBOwnableOverrides_InvalidNewOwner` if both are non-zero.
85
+ - **Constructor rejects zero-zero initialization.** If both `initialOwner` and `initialProjectIdOwner` are zero, the constructor reverts. To create an unowned contract, set an initial owner and call `renounceOwnership()` in the constructor body.
86
+ - **`permissionId` resets to 0 on every ownership transfer.** This is intentional -- it prevents stale permission delegation from carrying over to new owners. The new owner must explicitly call `setPermissionId()` to re-enable delegated access.
87
+ - **Delegated permissions are scoped to the granting address.** When a project NFT is transferred, delegates authorized by the old holder lose access immediately. The new holder must re-grant permissions via `JBPermissions.setPermissionsFor(...)`.
88
+ - **ROOT permission (ID 1) always grants access.** `_checkOwner()` delegates to `_requirePermissionFrom` from `JBPermissioned`, which recognizes the ROOT permission (ID 1) as granting access regardless of the configured `permissionId`.
89
+ - **`renounceOwnership()` is irreversible.** After renouncing, `owner()` returns `address(0)`, and all `onlyOwner` functions permanently revert. There is no recovery mechanism.
90
+ - **`projectId` is `uint88`.** Project IDs above `type(uint88).max` (309,485,009,821,345,068,724,781,055) are rejected by `transferOwnershipToProject`. This constraint enables the `JBOwner` struct to fit in a single storage slot.
91
+ - **`transferOwnershipToProject` checks existence.** It compares the project ID against `PROJECTS.count()` and reverts with `JBOwnableOverrides_ProjectDoesNotExist` if the project does not exist, preventing permanent loss of contract control.
92
+ - **`owner()` makes an external call in project mode.** When `projectId != 0`, `owner()` calls `PROJECTS.ownerOf(projectId)`, which is an external call. This is relevant for gas-sensitive contexts. If the call reverts (e.g., the project NFT was burned), `owner()` returns `address(0)` and the contract degrades to a renounced state.
93
+ - **Constructor rejects zero-address PROJECTS with project ownership.** Deploying with `projects = address(0)` and a non-zero `initialProjectIdOwner` reverts with `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner`, preventing permanently broken ownership resolution.
94
+ - **No ERC2771 support.** Despite inheriting `Context`, `JBOwnable` uses plain `Context._msgSender()` (which returns `msg.sender`), not `ERC2771Context`. A trusted forwarder appending a sender address to calldata has no effect on ownership checks.
45
95
 
46
- ## Example Integration
96
+ ## Example: Inherit JBOwnable
47
97
 
48
98
  ```solidity
49
99
  import {JBOwnable} from "@bananapus/ownable-v6/src/JBOwnable.sol";
100
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
101
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
50
102
 
51
103
  contract MyHook is JBOwnable {
52
104
  constructor(
@@ -60,3 +112,54 @@ contract MyHook is JBOwnable {
60
112
  }
61
113
  }
62
114
  ```
115
+
116
+ ## Example: Address-Owned Contract
117
+
118
+ ```solidity
119
+ contract MyContract is JBOwnable {
120
+ constructor(
121
+ IJBPermissions permissions,
122
+ IJBProjects projects,
123
+ address initialOwner
124
+ ) JBOwnable(permissions, projects, initialOwner, 0) {}
125
+
126
+ function adminFunction() external onlyOwner {
127
+ // Only initialOwner (or delegated addresses) can call this.
128
+ }
129
+ }
130
+ ```
131
+
132
+ ## Example: Override _emitTransferEvent
133
+
134
+ If you need to deploy a `JBOwnableOverrides`-based contract before the project NFT exists (e.g., the contract is deployed as part of the project creation flow), override `_emitTransferEvent` to handle the case where the project ID is not yet minted:
135
+
136
+ ```solidity
137
+ import {JBOwnableOverrides} from "@bananapus/ownable-v6/src/JBOwnableOverrides.sol";
138
+
139
+ contract MyPreDeployContract is JBOwnableOverrides {
140
+ modifier onlyOwner() {
141
+ _checkOwner();
142
+ _;
143
+ }
144
+
145
+ constructor(
146
+ IJBPermissions permissions,
147
+ IJBProjects projects,
148
+ address initialOwner,
149
+ uint88 initialProjectIdOwner
150
+ ) JBOwnableOverrides(permissions, projects, initialOwner, initialProjectIdOwner) {}
151
+
152
+ function _emitTransferEvent(
153
+ address previousOwner,
154
+ address newOwner,
155
+ uint88 newProjectId
156
+ ) internal override {
157
+ // Custom logic -- e.g., skip ownerOf() if the project NFT is not yet minted.
158
+ emit OwnershipTransferred({
159
+ previousOwner: previousOwner,
160
+ newOwner: newProjectId == 0 ? newOwner : address(0), // Resolve later.
161
+ caller: msg.sender
162
+ });
163
+ }
164
+ }
165
+ ```
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "@bananapus/ownable-v6",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Bananapus/nana-ownable-v6"
8
8
  },
9
+ "engines": {
10
+ "node": ">=20.0.0"
11
+ },
9
12
  "dependencies": {
10
- "@bananapus/core-v6": "^0.0.5",
13
+ "@bananapus/core-v6": "^0.0.9",
11
14
  "@openzeppelin/contracts": "^5.0.2"
12
15
  },
13
16
  "scripts": {
14
17
  "test": "forge test",
15
- "coverage:integration": "forge coverage --match-path \"./src/*.sol\" --report lcov --report summary"
18
+ "coverage": "forge coverage --match-path \"./src/*.sol\" --report lcov --report summary"
16
19
  }
17
20
  }
@@ -5,6 +5,6 @@
5
5
  "exclude_medium": false,
6
6
  "exclude_high": false,
7
7
  "disable_color": false,
8
- "filter_paths": "(mocks/|test/|node_modules/)",
8
+ "filter_paths": "(mocks/|test/|node_modules/|lib/)",
9
9
  "legacy_ast": false
10
10
  }
@@ -20,6 +20,7 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
20
20
 
21
21
  error JBOwnableOverrides_InvalidNewOwner();
22
22
  error JBOwnableOverrides_ProjectDoesNotExist();
23
+ error JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner();
23
24
 
24
25
  //*********************************************************************//
25
26
  // ---------------- public immutable stored properties --------------- //
@@ -59,6 +60,13 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
59
60
  {
60
61
  PROJECTS = projects;
61
62
 
63
+ // If using project-based ownership, the PROJECTS contract must be provided.
64
+ // Deploying with projects=address(0) and a non-zero projectId would permanently disable
65
+ // ownership resolution, as all ownerOf() calls would revert on the zero address.
66
+ if (initialProjectIdOwner != 0 && address(projects) == address(0)) {
67
+ revert JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner();
68
+ }
69
+
62
70
  // We force the inheriting contract to set an owner, as there is a low chance someone will use `JBOwnable` to
63
71
  // create an unowned contract.
64
72
  // It's more likely both were accidentally set to `0`. If you really want an unowned contract, set the owner to
@@ -75,6 +83,11 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
75
83
  //*********************************************************************//
76
84
 
77
85
  /// @notice Returns the owner's address based on this contract's `JBOwner`.
86
+ /// @dev If `projectId` is non-zero, resolves via `PROJECTS.ownerOf()`. If that call reverts (e.g., because the
87
+ /// project NFT was burned or invalidated), returns `address(0)` — effectively treating the contract as renounced.
88
+ /// @dev **Assumption:** `JBProjects` V6 has no burn function, so this scenario cannot occur under normal
89
+ /// conditions. The try-catch is a defensive measure against hypothetical future changes to `JBProjects` or
90
+ /// unexpected ERC-721 behavior.
78
91
  function owner() public view virtual returns (address) {
79
92
  JBOwner memory ownerInfo = jbOwner;
80
93
 
@@ -82,7 +95,13 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
82
95
  return ownerInfo.owner;
83
96
  }
84
97
 
85
- return PROJECTS.ownerOf(ownerInfo.projectId);
98
+ // Use try-catch to gracefully handle the case where the project NFT no longer exists.
99
+ // If ownerOf reverts, the contract is effectively renounced (returns address(0)).
100
+ try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
101
+ return projectOwner;
102
+ } catch {
103
+ return address(0);
104
+ }
86
105
  }
87
106
 
88
107
  //*********************************************************************//
@@ -90,13 +109,25 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
90
109
  //*********************************************************************//
91
110
 
92
111
  /// @notice Reverts if the sender is not the owner.
112
+ /// @dev If `projectId` is non-zero and `PROJECTS.ownerOf()` reverts (e.g., burned NFT), the resolved owner is
113
+ /// `address(0)`, causing all `_checkOwner` calls to revert — equivalent to a renounced contract.
93
114
  function _checkOwner() internal view virtual {
94
115
  JBOwner memory ownerInfo = jbOwner;
95
116
 
117
+ address resolvedOwner;
118
+ if (ownerInfo.projectId == 0) {
119
+ resolvedOwner = ownerInfo.owner;
120
+ } else {
121
+ // Use try-catch to gracefully handle the case where the project NFT no longer exists.
122
+ try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
123
+ resolvedOwner = projectOwner;
124
+ } catch {
125
+ resolvedOwner = address(0);
126
+ }
127
+ }
128
+
96
129
  _requirePermissionFrom({
97
- account: ownerInfo.projectId == 0 ? ownerInfo.owner : PROJECTS.ownerOf(ownerInfo.projectId),
98
- projectId: ownerInfo.projectId,
99
- permissionId: ownerInfo.permissionId
130
+ account: resolvedOwner, projectId: ownerInfo.projectId, permissionId: ownerInfo.permissionId
100
131
  });
101
132
  }
102
133
 
@@ -192,8 +223,17 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
192
223
  }
193
224
  // Load the owner information from storage.
194
225
  JBOwner memory ownerInfo = jbOwner;
195
- // Get the address of the old owner.
196
- address oldOwner = ownerInfo.projectId == 0 ? ownerInfo.owner : PROJECTS.ownerOf(ownerInfo.projectId);
226
+ // Get the address of the old owner. Use try-catch for project-based ownership in case the NFT was burned.
227
+ address oldOwner;
228
+ if (ownerInfo.projectId == 0) {
229
+ oldOwner = ownerInfo.owner;
230
+ } else {
231
+ try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
232
+ oldOwner = projectOwner;
233
+ } catch {
234
+ oldOwner = address(0);
235
+ }
236
+ }
197
237
  // Update the stored owner information to the new owner and reset the `permissionId`.
198
238
  // This is to prevent permissions clashes for the new user/owner.
199
239
  jbOwner = JBOwner({owner: newOwner, projectId: projectId, permissionId: 0});
@@ -0,0 +1,368 @@
1
+ // SPDX-License-Identifier: UNLICENSED
2
+ pragma solidity ^0.8.26;
3
+
4
+ import "forge-std/Test.sol";
5
+ import {MockOwnable} from "./mocks/MockOwnable.sol";
6
+ import {JBOwnableOverrides} from "../src/JBOwnableOverrides.sol";
7
+ import {JBOwner} from "../src/structs/JBOwner.sol";
8
+ import {IJBOwnable} from "../src/interfaces/IJBOwnable.sol";
9
+
10
+ import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
11
+ import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
12
+ import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
13
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
14
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
15
+ import {JBPermissionsData} from "@bananapus/core-v6/src/structs/JBPermissionsData.sol";
16
+
17
+ /// @title OwnableEdgeCases
18
+ /// @notice Edge case and gap tests for JBOwnable: multi-hop NFT transfers,
19
+ /// project-to-project ownership, permissionId lifecycle, and nonexistent projects.
20
+ contract OwnableEdgeCases is Test {
21
+ IJBProjects PROJECTS;
22
+ IJBPermissions PERMISSIONS;
23
+
24
+ address alice = makeAddr("alice");
25
+ address bob = makeAddr("bob");
26
+ address charlie = makeAddr("charlie");
27
+ address dave = makeAddr("dave");
28
+
29
+ modifier isNotContract(address a) {
30
+ uint256 size;
31
+ assembly {
32
+ size := extcodesize(a)
33
+ }
34
+ vm.assume(size == 0);
35
+ _;
36
+ }
37
+
38
+ function setUp() public {
39
+ PERMISSIONS = new JBPermissions(address(0));
40
+ PROJECTS = new JBProjects(address(123), address(0), address(0));
41
+ }
42
+
43
+ // =========================================================================
44
+ // Test 1: Multi-hop NFT transfer — ownership follows through A→B→C→D
45
+ // =========================================================================
46
+ function test_multiHopNFTTransfer_ownerFollows() public {
47
+ uint256 projectId = PROJECTS.createFor(alice);
48
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, address(0), uint88(projectId));
49
+
50
+ assertEq(ownable.owner(), alice);
51
+
52
+ // Transfer NFT: alice → bob
53
+ vm.prank(alice);
54
+ PROJECTS.transferFrom(alice, bob, projectId);
55
+ assertEq(ownable.owner(), bob, "Should follow to bob");
56
+
57
+ // Transfer NFT: bob → charlie
58
+ vm.prank(bob);
59
+ PROJECTS.transferFrom(bob, charlie, projectId);
60
+ assertEq(ownable.owner(), charlie, "Should follow to charlie");
61
+
62
+ // Transfer NFT: charlie → dave
63
+ vm.prank(charlie);
64
+ PROJECTS.transferFrom(charlie, dave, projectId);
65
+ assertEq(ownable.owner(), dave, "Should follow to dave");
66
+
67
+ // dave can call protectedMethod, alice/bob/charlie cannot
68
+ vm.prank(dave);
69
+ ownable.protectedMethod();
70
+
71
+ vm.prank(alice);
72
+ vm.expectRevert();
73
+ ownable.protectedMethod();
74
+
75
+ vm.prank(bob);
76
+ vm.expectRevert();
77
+ ownable.protectedMethod();
78
+
79
+ vm.prank(charlie);
80
+ vm.expectRevert();
81
+ ownable.protectedMethod();
82
+ }
83
+
84
+ // =========================================================================
85
+ // Test 2: Transfer project → different project
86
+ // =========================================================================
87
+ function test_transferProjectToProject() public {
88
+ uint256 projectA = PROJECTS.createFor(alice);
89
+ uint256 projectB = PROJECTS.createFor(bob);
90
+
91
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, address(0), uint88(projectA));
92
+ assertEq(ownable.owner(), alice);
93
+
94
+ // Transfer ownership from project A to project B.
95
+ vm.prank(alice);
96
+ ownable.transferOwnershipToProject(projectB);
97
+
98
+ // Owner should now be bob (owner of project B).
99
+ assertEq(ownable.owner(), bob, "Owner should be projectB's owner (bob)");
100
+
101
+ // alice no longer has access
102
+ vm.prank(alice);
103
+ vm.expectRevert();
104
+ ownable.protectedMethod();
105
+
106
+ // bob has access
107
+ vm.prank(bob);
108
+ ownable.protectedMethod();
109
+ }
110
+
111
+ // =========================================================================
112
+ // Test 3: Full ownership cycle: address → project → address → project
113
+ // =========================================================================
114
+ function test_fullOwnershipCycle() public {
115
+ uint256 projectA = PROJECTS.createFor(alice);
116
+ uint256 projectB = PROJECTS.createFor(bob);
117
+
118
+ // Start with address ownership.
119
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, charlie, 0);
120
+ assertEq(ownable.owner(), charlie);
121
+
122
+ // charlie → project A (alice)
123
+ vm.prank(charlie);
124
+ ownable.transferOwnershipToProject(projectA);
125
+ assertEq(ownable.owner(), alice);
126
+
127
+ // project A → bob (address)
128
+ vm.prank(alice);
129
+ ownable.transferOwnership(bob);
130
+ assertEq(ownable.owner(), bob);
131
+
132
+ // bob → project B (bob is also project B owner, but that's fine)
133
+ vm.prank(bob);
134
+ ownable.transferOwnershipToProject(projectB);
135
+ assertEq(ownable.owner(), bob, "bob owns projectB so still bob");
136
+
137
+ // Verify jbOwner struct is correct (projectId set, owner zeroed).
138
+ (address storedOwner, uint88 storedProjectId, uint8 storedPermId) = ownable.jbOwner();
139
+ assertEq(storedOwner, address(0), "owner field should be zero in project mode");
140
+ assertEq(storedProjectId, uint88(projectB), "projectId should be projectB");
141
+ assertEq(storedPermId, 0, "permissionId should be 0");
142
+ }
143
+
144
+ // =========================================================================
145
+ // Test 4: permissionId lifecycle through multiple transfers
146
+ // =========================================================================
147
+ function test_permissionIdLifecycle() public {
148
+ uint256 projectA = PROJECTS.createFor(alice);
149
+
150
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, address(0), uint88(projectA));
151
+
152
+ // Set permissionId to 42.
153
+ vm.prank(alice);
154
+ ownable.setPermissionId(42);
155
+ (,, uint8 permId) = ownable.jbOwner();
156
+ assertEq(permId, 42);
157
+
158
+ // Transfer to bob — permissionId should reset.
159
+ vm.prank(alice);
160
+ ownable.transferOwnership(bob);
161
+ (,, permId) = ownable.jbOwner();
162
+ assertEq(permId, 0, "permissionId should reset after transferOwnership");
163
+
164
+ // Set permissionId again as new owner.
165
+ vm.prank(bob);
166
+ ownable.setPermissionId(99);
167
+ (,, permId) = ownable.jbOwner();
168
+ assertEq(permId, 99);
169
+
170
+ // Transfer to project — permissionId should reset again.
171
+ vm.prank(bob);
172
+ ownable.transferOwnershipToProject(projectA);
173
+ (,, permId) = ownable.jbOwner();
174
+ assertEq(permId, 0, "permissionId should reset after transferOwnershipToProject");
175
+
176
+ // Set permissionId as project owner.
177
+ vm.prank(alice);
178
+ ownable.setPermissionId(200);
179
+ (,, permId) = ownable.jbOwner();
180
+ assertEq(permId, 200);
181
+
182
+ // Renounce — permissionId should be 0.
183
+ vm.prank(alice);
184
+ ownable.renounceOwnership();
185
+ (,, permId) = ownable.jbOwner();
186
+ assertEq(permId, 0, "permissionId should be 0 after renounce");
187
+ }
188
+
189
+ // =========================================================================
190
+ // Test 5: Non-owner cannot call setPermissionId
191
+ // =========================================================================
192
+ function test_nonOwnerCannotSetPermissionId(address nonOwner) public {
193
+ vm.assume(nonOwner != alice && nonOwner != address(0));
194
+
195
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, alice, 0);
196
+
197
+ vm.prank(nonOwner);
198
+ vm.expectRevert();
199
+ ownable.setPermissionId(42);
200
+ }
201
+
202
+ // =========================================================================
203
+ // Test 6: Transfer to nonexistent project reverts
204
+ // =========================================================================
205
+ function test_transferToNonexistentProject_reverts() public {
206
+ // Create one project so count == 1.
207
+ PROJECTS.createFor(alice);
208
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, alice, 0);
209
+
210
+ // Project 2 doesn't exist.
211
+ vm.prank(alice);
212
+ vm.expectRevert(abi.encodeWithSelector(JBOwnableOverrides.JBOwnableOverrides_ProjectDoesNotExist.selector));
213
+ ownable.transferOwnershipToProject(2);
214
+
215
+ // Project 999 doesn't exist.
216
+ vm.prank(alice);
217
+ vm.expectRevert(abi.encodeWithSelector(JBOwnableOverrides.JBOwnableOverrides_ProjectDoesNotExist.selector));
218
+ ownable.transferOwnershipToProject(999);
219
+ }
220
+
221
+ // =========================================================================
222
+ // Test 7: Delegated access — permission granted on project, then NFT
223
+ // transferred, old delegate loses access
224
+ // =========================================================================
225
+ function test_delegatedAccess_lostAfterNFTTransfer() public {
226
+ uint256 projectId = PROJECTS.createFor(alice);
227
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, address(0), uint88(projectId));
228
+
229
+ // Set permissionId so delegation is possible.
230
+ vm.prank(alice);
231
+ ownable.setPermissionId(42);
232
+
233
+ // Alice grants charlie permission 42 on the project.
234
+ uint8[] memory permIds = new uint8[](1);
235
+ permIds[0] = 42;
236
+ vm.prank(alice);
237
+ PERMISSIONS.setPermissionsFor(
238
+ alice, JBPermissionsData({operator: charlie, projectId: uint56(projectId), permissionIds: permIds})
239
+ );
240
+
241
+ // Charlie can call protectedMethod (delegated via permissions).
242
+ vm.prank(charlie);
243
+ ownable.protectedMethod();
244
+
245
+ // Transfer NFT to bob.
246
+ vm.prank(alice);
247
+ PROJECTS.transferFrom(alice, bob, projectId);
248
+
249
+ // Charlie's delegation was from alice. Now owner is bob.
250
+ // Charlie should lose access because _checkOwner resolves to bob,
251
+ // and charlie has no permissions from bob.
252
+ vm.prank(charlie);
253
+ vm.expectRevert();
254
+ ownable.protectedMethod();
255
+
256
+ // bob can still call directly.
257
+ vm.prank(bob);
258
+ ownable.protectedMethod();
259
+ }
260
+
261
+ // =========================================================================
262
+ // Test 8: OwnershipTransferred event emitted correctly
263
+ // =========================================================================
264
+ function test_ownershipTransferredEvent() public {
265
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, alice, 0);
266
+
267
+ // Transfer to bob — expect event.
268
+ vm.expectEmit(true, true, false, true);
269
+ emit IJBOwnable.OwnershipTransferred(alice, bob, alice);
270
+
271
+ vm.prank(alice);
272
+ ownable.transferOwnership(bob);
273
+ }
274
+
275
+ // =========================================================================
276
+ // Test 9: PermissionIdChanged event emitted correctly
277
+ // =========================================================================
278
+ function test_permissionIdChangedEvent() public {
279
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, alice, 0);
280
+
281
+ vm.expectEmit(true, true, false, true);
282
+ emit IJBOwnable.PermissionIdChanged(42, alice);
283
+
284
+ vm.prank(alice);
285
+ ownable.setPermissionId(42);
286
+ }
287
+
288
+ // =========================================================================
289
+ // Test 10: Fuzz — transfer to any valid project, verify owner resolution
290
+ // =========================================================================
291
+ function testFuzz_transferToProject(address projectOwner) public isNotContract(projectOwner) {
292
+ vm.assume(projectOwner != address(0));
293
+
294
+ uint256 projectId = PROJECTS.createFor(projectOwner);
295
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, alice, 0);
296
+
297
+ vm.prank(alice);
298
+ ownable.transferOwnershipToProject(projectId);
299
+
300
+ assertEq(ownable.owner(), projectOwner, "Owner should match project owner");
301
+
302
+ // Verify jbOwner struct.
303
+ (address storedOwner, uint88 storedProjectId,) = ownable.jbOwner();
304
+ assertEq(storedOwner, address(0), "stored owner should be zero");
305
+ assertEq(storedProjectId, uint88(projectId), "stored projectId should match");
306
+ }
307
+
308
+ // =========================================================================
309
+ // Test 11: Renounced contract cannot reclaim ownership
310
+ // =========================================================================
311
+ /// @notice After renouncing, no one can call transferOwnership, transferOwnershipToProject,
312
+ /// setPermissionId, or renounceOwnership again.
313
+ function test_renouncedContract_cannotReclaim() public {
314
+ uint256 projectId = PROJECTS.createFor(alice);
315
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, alice, 0);
316
+
317
+ vm.prank(alice);
318
+ ownable.renounceOwnership();
319
+
320
+ // Nobody can transfer ownership back.
321
+ vm.prank(alice);
322
+ vm.expectRevert();
323
+ ownable.transferOwnership(alice);
324
+
325
+ vm.prank(bob);
326
+ vm.expectRevert();
327
+ ownable.transferOwnership(bob);
328
+
329
+ // Nobody can transfer to a project.
330
+ vm.prank(alice);
331
+ vm.expectRevert();
332
+ ownable.transferOwnershipToProject(projectId);
333
+
334
+ // Nobody can set permissionId.
335
+ vm.prank(alice);
336
+ vm.expectRevert();
337
+ ownable.setPermissionId(1);
338
+
339
+ // Nobody can renounce again (already renounced, _checkOwner fails).
340
+ vm.prank(alice);
341
+ vm.expectRevert();
342
+ ownable.renounceOwnership();
343
+ }
344
+
345
+ // =========================================================================
346
+ // Test 12: _msgSender is NOT ERC2771-aware (design documentation)
347
+ // =========================================================================
348
+ /// @notice JBOwnable uses plain Context._msgSender() (returns msg.sender),
349
+ /// NOT ERC2771Context. This test documents that a trusted forwarder
350
+ /// appending a sender address to calldata does NOT affect _checkOwner.
351
+ function test_noERC2771_trustedForwarderHasNoEffect() public {
352
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, alice, 0);
353
+
354
+ // Simulate what a trusted forwarder would do: call with alice's address
355
+ // appended to calldata. Since JBOwnable uses plain Context, this has no effect.
356
+ // The msg.sender is still bob, not alice.
357
+ bytes memory callData = abi.encodeWithSelector(MockOwnable.protectedMethod.selector);
358
+ bytes memory forwardedCallData = abi.encodePacked(callData, alice);
359
+
360
+ vm.prank(bob);
361
+ (bool success,) = address(ownable).call(forwardedCallData);
362
+ assertFalse(success, "Forwarded call should fail - JBOwnable ignores appended sender");
363
+
364
+ // Direct call from alice still works.
365
+ vm.prank(alice);
366
+ ownable.protectedMethod();
367
+ }
368
+ }
@@ -0,0 +1,110 @@
1
+ // SPDX-License-Identifier: UNLICENSED
2
+ pragma solidity ^0.8.26;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+ import {MockOwnable} from "../mocks/MockOwnable.sol";
6
+ import {JBOwnableOverrides} from "../../src/JBOwnableOverrides.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
+ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
13
+
14
+ /// @title L65_BurnLockProtection
15
+ /// @notice Regression test for L-65: Verifies that if a project NFT is burned/invalidated,
16
+ /// owner() returns address(0) and _checkOwner() reverts gracefully instead of
17
+ /// permanently locking the contract with an unrecoverable revert.
18
+ contract L65_BurnLockProtection is Test {
19
+ IJBProjects PROJECTS;
20
+ IJBPermissions PERMISSIONS;
21
+
22
+ address alice = makeAddr("alice");
23
+ address bob = makeAddr("bob");
24
+
25
+ function setUp() public {
26
+ PERMISSIONS = new JBPermissions(address(0));
27
+ PROJECTS = new JBProjects(address(123), address(0), address(0));
28
+ }
29
+
30
+ /// @notice When a project NFT is burned (simulated via mockCallRevert), owner() should
31
+ /// return address(0) instead of reverting — contract degrades to "renounced" state.
32
+ function test_burnedProjectNFT_ownerReturnsZero() public {
33
+ uint256 projectId = PROJECTS.createFor(alice);
34
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, address(0), uint88(projectId));
35
+
36
+ // Verify normal operation first.
37
+ assertEq(ownable.owner(), alice, "Owner should be alice before burn");
38
+
39
+ // Simulate project NFT burn by making ownerOf revert for this projectId.
40
+ vm.mockCallRevert(
41
+ address(PROJECTS), abi.encodeWithSelector(IERC721.ownerOf.selector, projectId), "ERC721: invalid token ID"
42
+ );
43
+
44
+ // After burn, owner() should return address(0) — NOT revert.
45
+ address resolvedOwner = ownable.owner();
46
+ assertEq(resolvedOwner, address(0), "owner() should return address(0) when project NFT is burned");
47
+ }
48
+
49
+ /// @notice When a project NFT is burned, _checkOwner() should revert with the standard
50
+ /// Unauthorized error (not an unrecoverable ownerOf revert), making the contract
51
+ /// behave as if ownership was renounced.
52
+ function test_burnedProjectNFT_checkOwnerRevertsGracefully() public {
53
+ uint256 projectId = PROJECTS.createFor(alice);
54
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, address(0), uint88(projectId));
55
+
56
+ // Alice can call the protected method before burn.
57
+ vm.prank(alice);
58
+ ownable.protectedMethod();
59
+
60
+ // Simulate project NFT burn.
61
+ vm.mockCallRevert(
62
+ address(PROJECTS), abi.encodeWithSelector(IERC721.ownerOf.selector, projectId), "ERC721: invalid token ID"
63
+ );
64
+
65
+ // After burn, nobody can call protected methods — but the revert is graceful
66
+ // (Unauthorized from _requirePermissionFrom, not a raw ownerOf revert).
67
+ vm.prank(alice);
68
+ vm.expectRevert();
69
+ ownable.protectedMethod();
70
+
71
+ vm.prank(bob);
72
+ vm.expectRevert();
73
+ ownable.protectedMethod();
74
+ }
75
+
76
+ /// @notice Address-based ownership is unaffected by the try-catch change.
77
+ function test_addressBasedOwnership_unaffectedByTryCatch() public {
78
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, alice, 0);
79
+
80
+ assertEq(ownable.owner(), alice, "Owner should be alice");
81
+
82
+ vm.prank(alice);
83
+ ownable.protectedMethod();
84
+
85
+ // Transfer to bob.
86
+ vm.prank(alice);
87
+ ownable.transferOwnership(bob);
88
+ assertEq(ownable.owner(), bob, "Owner should be bob after transfer");
89
+
90
+ vm.prank(bob);
91
+ ownable.protectedMethod();
92
+ }
93
+
94
+ /// @notice Normal project-based ownership still works correctly after the fix.
95
+ function test_normalProjectOwnership_stillWorks() public {
96
+ uint256 projectId = PROJECTS.createFor(alice);
97
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, address(0), uint88(projectId));
98
+
99
+ assertEq(ownable.owner(), alice);
100
+
101
+ // Transfer project NFT.
102
+ vm.prank(alice);
103
+ PROJECTS.transferFrom(alice, bob, projectId);
104
+
105
+ assertEq(ownable.owner(), bob, "Owner should follow project NFT transfer");
106
+
107
+ vm.prank(bob);
108
+ ownable.protectedMethod();
109
+ }
110
+ }
@@ -0,0 +1,66 @@
1
+ // SPDX-License-Identifier: UNLICENSED
2
+ pragma solidity ^0.8.26;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+ import {MockOwnable} from "../mocks/MockOwnable.sol";
6
+ import {JBOwnableOverrides} from "../../src/JBOwnableOverrides.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
+ /// @title L66_ZeroAddressValidation
14
+ /// @notice Regression test for L-66: Verifies that deploying with a zero-address PROJECTS
15
+ /// contract and a non-zero projectId reverts at construction time, preventing
16
+ /// permanently broken project-based ownership.
17
+ contract L66_ZeroAddressValidation is Test {
18
+ IJBProjects PROJECTS;
19
+ IJBPermissions PERMISSIONS;
20
+
21
+ address alice = makeAddr("alice");
22
+
23
+ function setUp() public {
24
+ PERMISSIONS = new JBPermissions(address(0));
25
+ PROJECTS = new JBProjects(address(123), address(0), address(0));
26
+ }
27
+
28
+ /// @notice Deploying with projects=address(0) and non-zero projectId must revert.
29
+ function test_zeroProjectsWithProjectId_reverts() public {
30
+ vm.expectRevert(
31
+ abi.encodeWithSelector(JBOwnableOverrides.JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner.selector)
32
+ );
33
+ new MockOwnable(IJBProjects(address(0)), PERMISSIONS, address(0), uint88(1));
34
+ }
35
+
36
+ /// @notice Fuzz: any non-zero projectId with projects=address(0) must revert.
37
+ function testFuzz_zeroProjectsWithAnyProjectId_reverts(uint88 projectId) public {
38
+ vm.assume(projectId != 0);
39
+
40
+ vm.expectRevert(
41
+ abi.encodeWithSelector(JBOwnableOverrides.JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner.selector)
42
+ );
43
+ new MockOwnable(IJBProjects(address(0)), PERMISSIONS, address(0), projectId);
44
+ }
45
+
46
+ /// @notice Deploying with projects=address(0) and projectId=0 (address-based ownership)
47
+ /// should NOT revert for this error — it's valid as long as initialOwner != address(0).
48
+ function test_zeroProjectsWithAddressOwnership_succeeds() public {
49
+ // This is valid: address-based ownership with projects=address(0).
50
+ MockOwnable ownable = new MockOwnable(IJBProjects(address(0)), PERMISSIONS, alice, uint88(0));
51
+ assertEq(ownable.owner(), alice, "Owner should be alice with address-based ownership");
52
+ }
53
+
54
+ /// @notice Normal deployment with valid PROJECTS contract and projectId succeeds.
55
+ function test_validProjectsWithProjectId_succeeds() public {
56
+ uint256 projectId = PROJECTS.createFor(alice);
57
+ MockOwnable ownable = new MockOwnable(PROJECTS, PERMISSIONS, address(0), uint88(projectId));
58
+ assertEq(ownable.owner(), alice, "Owner should be alice via project NFT");
59
+ }
60
+
61
+ /// @notice The existing check for both zero owner and zero projectId is still enforced.
62
+ function test_bothZero_stillReverts() public {
63
+ vm.expectRevert(abi.encodeWithSelector(JBOwnableOverrides.JBOwnableOverrides_InvalidNewOwner.selector));
64
+ new MockOwnable(PROJECTS, PERMISSIONS, address(0), uint88(0));
65
+ }
66
+ }