@bananapus/ownable-v6 0.0.11 → 0.0.13

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
@@ -51,6 +51,25 @@ JBOwner {
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
 
54
+ ## Usage Pattern
55
+
56
+ Contracts inherit from `JBOwnable` to bridge Juicebox project ownership into the standard `onlyOwner` modifier pattern. The typical usage:
57
+
58
+ ```solidity
59
+ contract MyHook is JBOwnable {
60
+ function adjustTiers(...) external onlyOwner {
61
+ // Only the resolved owner (or a permission delegate) can call this
62
+ }
63
+ }
64
+ ```
65
+
66
+ The `onlyOwner` modifier calls `_checkOwner()`, which resolves the current owner (via project NFT or direct address) and checks `_requirePermissionFrom()`. This means every `onlyOwner` function automatically supports:
67
+ - Direct ownership (EOA or contract)
68
+ - Project-based ownership (holder of the project NFT)
69
+ - Permission delegation (via the configured `permissionId` through `JBPermissions`)
70
+
71
+ **Practical example:** `JB721TiersHook` inherits `JBOwnable`. During deployment, ownership is transferred to the project via `transferOwnershipToProject(projectId)`. The project NFT holder then becomes the hook's owner, and they can delegate specific hook permissions to operators via `JBPermissions`.
72
+
54
73
  ## Immutable Configuration
55
74
 
56
75
  | Property | Set At | Can Change? |
@@ -73,3 +92,4 @@ What admins **cannot** do:
73
92
  - **Undo `renounceOwnership()`.** Once ownership is renounced, all `onlyOwner` functions are permanently disabled. There is no recovery mechanism.
74
93
  - **Bypass `JBPermissions` for delegation.** Permission delegation is exclusively handled through the external `JBPermissions` contract; `JBOwnable` itself has no operator registry.
75
94
  - **Prevent project NFT transfers from changing ownership.** When owned by a project, whoever holds the `JBProjects` ERC-721 is the owner. There is no veto or lock mechanism within `JBOwnable`.
95
+ - **Project ownership resolution can fail.** When owned by a project (`projectId != 0`), `owner()` calls `PROJECTS.ownerOf(projectId)`. If this call reverts (e.g., the project NFT is held by a contract that rejects ERC-721 queries), the resolved owner becomes `address(0)`, effectively and permanently renouncing the contract. This is an edge case but has no recovery path.
package/ARCHITECTURE.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- Juicebox-aware ownership module. Extends OpenZeppelin's Ownable pattern to support ownership by either a Juicebox project (via ERC-721) or a direct address, with permission delegation through JBPermissions.
5
+ Juicebox-aware ownership module. Extends OpenZeppelin's Ownable pattern to support ownership by either a Juicebox project (via ERC-721) or a direct address, with permission delegation through JBPermissions. The primary use case is contracts like `JB721TiersHook` that inherit `JBOwnable` so they can be owned by a Juicebox project rather than just an EOA -- ownership automatically follows the project's ERC-721 token without requiring manual transfers when the project changes hands.
6
6
 
7
7
  ## Contract Map
8
8
 
@@ -44,6 +44,20 @@ Current owner → renounceOwnership()
44
44
  → Permanently disables owner-only functions
45
45
  ```
46
46
 
47
+ ## Design Decisions
48
+
49
+ ### Project-as-owner instead of plain OpenZeppelin Ownable
50
+ OpenZeppelin's `Ownable` binds ownership to a single address. In Juicebox, project ownership is represented by an ERC-721 (`JBProjects`), and the owner of that NFT can change over time. `JBOwnable` resolves ownership dynamically via `PROJECTS.ownerOf(projectId)`, so any contract owned by a project automatically tracks whoever holds the project NFT. This avoids the need to manually call `transferOwnership` on every peripheral contract when a project changes hands.
51
+
52
+ ### `permissionId` in the owner struct
53
+ The `JBOwner` struct includes a `uint8 permissionId` that the owner can configure via `setPermissionId()`. This lets the owner delegate access to specific addresses through `JBPermissions` without transferring ownership itself. For example, a project owner can grant a multisig or automation contract the ability to call `onlyOwner` functions on a hook without giving up project ownership. The permission ID is reset to 0 on every ownership transfer to prevent stale permission grants from carrying over to new owners.
54
+
55
+ ### Abstract base with concrete modifier
56
+ `JBOwnableOverrides` is abstract and omits the `onlyOwner` modifier. The concrete `JBOwnable` adds it. This split exists because some inheriting contracts (like hooks deployed before a project NFT is minted) need to customize `_emitTransferEvent` -- the abstract base lets them override the event emission while reusing all ownership resolution and transfer logic.
57
+
58
+ ### Struct packing
59
+ `JBOwner` packs `address owner` (160 bits), `uint88 projectId`, and `uint8 permissionId` into a single 256-bit storage slot. This means all ownership reads and writes cost one `SLOAD`/`SSTORE`, which matters because `_checkOwner` runs on every guarded call.
60
+
47
61
  ## Dependencies
48
62
  - `@bananapus/core-v6` — JBPermissioned, IJBProjects, IJBPermissions
49
63
  - `@openzeppelin/contracts` — Context
@@ -2,6 +2,19 @@
2
2
 
3
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
4
 
5
+ ## Compiler and Version Info
6
+
7
+ | Setting | Value |
8
+ |---------|-------|
9
+ | Solidity version | ^0.8.26 |
10
+ | EVM target | cancun |
11
+ | Optimizer | enabled, 200 runs |
12
+ | via-IR | not enabled |
13
+ | Fuzz runs | 4,096 |
14
+ | Invariant runs | 1,024 (depth 100) |
15
+
16
+ Source: [`foundry.toml`](./foundry.toml)
17
+
5
18
  ## Scope
6
19
 
7
20
  **In scope -- all Solidity in `src/`:**
@@ -89,7 +102,7 @@ Ownership transitions must be airtight:
89
102
  ### 4. Renunciation Edge Cases
90
103
 
91
104
  - **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.
105
+ - **Implicit renunciation via unreachable `ownerOf`.** JBProjects V6 has no burn function, so `PROJECTS.ownerOf()` cannot revert for a valid project ID under normal conditions. The try-catch in `owner()` and `_checkOwner()` is a defensive measure against hypothetical future changes to `JBProjects` or unexpected ERC-721 behavior. If `ownerOf` ever did revert, `owner()` would return `address(0)` and `_checkOwner()` would revert for all callers -- the contract would be effectively renounced without anyone calling `renounceOwnership()`. Verify this state is consistent and cannot be escaped.
93
106
  - **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
107
 
95
108
  ### 5. Storage Slot Packing
@@ -126,8 +139,49 @@ forge test --match-contract OwnableInvariant -vvv
126
139
  # Run regression tests
127
140
  forge test --match-path test/regression/ -vvv
128
141
 
129
- # Write a PoC
130
- forge test --match-path test/audit/ExploitPoC.t.sol -vvv
142
+ # Write a PoC (create test/YourExploit.t.sol)
143
+ forge test --match-path test/YourExploit.t.sol -vvv
131
144
  ```
132
145
 
146
+ ## Error Reference
147
+
148
+ All custom errors are defined in `src/JBOwnableOverrides.sol`.
149
+
150
+ | Error | Trigger Condition |
151
+ |-------|-------------------|
152
+ | `JBOwnableOverrides_InvalidNewOwner()` | (1) Constructor called with both `initialOwner == address(0)` and `initialProjectIdOwner == 0`. (2) `transferOwnership()` called with `newOwner == address(0)`. (3) `transferOwnershipToProject()` called with `projectId == 0` or `projectId > type(uint88).max`. (4) `_transferOwnership(address, uint88)` called with both `newOwner != address(0)` and `projectId != 0`. |
153
+ | `JBOwnableOverrides_ProjectDoesNotExist()` | `transferOwnershipToProject()` called with a `projectId` greater than `PROJECTS.count()` (the project has not been minted yet). |
154
+ | `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | Constructor called with `initialProjectIdOwner != 0` and `address(projects) == address(0)`. Prevents deploying with project-based ownership when no `JBProjects` contract is provided. |
155
+
156
+ ## Previous Audit Findings
157
+
158
+ A Nemesis audit (Feynman + State Inconsistency methodology) was conducted on 2026-03-17. Full results are in `.audit/findings/nemesis-verified.md`.
159
+
160
+ **Result: 0 Critical | 0 High | 0 Medium | 2 Low (informational)**
161
+
162
+ | ID | Severity | Summary |
163
+ |----|----------|---------|
164
+ | NM-001 | LOW | Constructor lacks explicit project existence check -- deploys with a non-existent `initialProjectIdOwner` revert with an opaque ERC-721 error instead of `JBOwnableOverrides_ProjectDoesNotExist()`. Developer experience only, no security impact. |
165
+ | NM-002 | LOW | `_emitTransferEvent` in `JBOwnable` does not use try-catch for `PROJECTS.ownerOf()`, unlike `owner()` and `_checkOwner()`. This is a deliberate design tradeoff: write-path failures should revert (preventing incorrect event data), while read-path failures degrade gracefully. |
166
+
167
+ No other formal audit with finding IDs has been conducted.
168
+
169
+ ## How to Report Findings
170
+
171
+ **Severity guide:**
172
+ - **CRITICAL**: Direct fund loss, permanent DoS, or broken core invariant. Exploitable with no preconditions.
173
+ - **HIGH**: Conditional fund loss, privilege escalation, or broken invariant. Requires specific but realistic setup.
174
+ - **MEDIUM**: Value leakage, griefing with cost to attacker, incorrect accounting, degraded functionality.
175
+ - **LOW**: Informational, cosmetic, edge-case-only with no material impact.
176
+
177
+ For each finding:
178
+
179
+ 1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
180
+ 2. **Affected contract(s)** -- exact file path and line numbers
181
+ 3. **Description** -- what's wrong, in plain language
182
+ 4. **Trigger sequence** -- step-by-step, minimal steps to reproduce
183
+ 5. **Impact** -- what an attacker gains, what a user loses (with numbers if possible)
184
+ 6. **Proof** -- code trace showing the exact execution path, or a Foundry test
185
+ 7. **Fix** -- minimal code change that resolves the issue
186
+
133
187
  Go break it.
package/CHANGE_LOG.md CHANGED
@@ -2,13 +2,19 @@
2
2
 
3
3
  This document describes all changes between `nana-ownable` (v5) and `nana-ownable-v6` (v6).
4
4
 
5
+ ## Summary
6
+
7
+ - **Defensive `try-catch` on all `PROJECTS.ownerOf()` calls**: `owner()` now returns `address(0)` instead of reverting for burned/invalid project NFTs — changes observable behavior for callers.
8
+ - **New safety validations**: `transferOwnershipToProject()` checks project existence; constructor rejects zero-address `PROJECTS` with project-based ownership.
9
+ - **Solidity version pinned**: Changed from floating `^0.8.23` to exact `^0.8.26`.
10
+
5
11
  ---
6
12
 
7
13
  ## 1. Breaking Changes
8
14
 
9
15
  ### Solidity Version Pinned
10
16
 
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.
17
+ 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
18
 
13
19
  **Affected files:** `JBOwnable.sol`, `JBOwnableOverrides.sol`
14
20
 
@@ -216,7 +222,7 @@ All internal function calls in v6 use named arguments (e.g., `_transferOwnership
216
222
 
217
223
  | v5 | v6 | Change Type |
218
224
  |---|---|---|
219
- | `pragma solidity ^0.8.23` | `pragma solidity 0.8.26` | Pinned compiler version |
225
+ | `pragma solidity ^0.8.23` | `pragma solidity ^0.8.26` | Pinned compiler version |
220
226
  | `@bananapus/core-v5` imports | `@bananapus/core-v6` imports | Dependency upgrade |
221
227
  | `PROJECTS.ownerOf()` called directly | `try PROJECTS.ownerOf() catch` in `owner()`, `_checkOwner()`, `_transferOwnership()` | Defensive error handling |
222
228
  | `transferOwnershipToProject()` -- no existence check | Reverts with `JBOwnableOverrides_ProjectDoesNotExist()` if `projectId > PROJECTS.count()` | New validation |
package/README.md CHANGED
@@ -14,6 +14,31 @@ Forked from [`jbx-protocol/juice-ownable`](https://github.com/jbx-protocol/juice
14
14
 
15
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
+ ## Repository Layout
18
+
19
+ ```
20
+ src/
21
+ ├── JBOwnable.sol -- Concrete implementation (inherit this)
22
+ ├── JBOwnableOverrides.sol -- Abstract base with all ownership logic
23
+ ├── interfaces/
24
+ │ └── IJBOwnable.sol -- Interface for ownership queries, transfers, and events
25
+ └── structs/
26
+ └── JBOwner.sol -- Packed struct: owner (160 bits) + projectId (88 bits) + permissionId (8 bits)
27
+
28
+ test/
29
+ ├── Ownable.t.sol -- Core ownership tests
30
+ ├── OwnableAttacks.t.sol -- Attack vector tests
31
+ ├── OwnableEdgeCases.t.sol -- Edge case coverage
32
+ ├── OwnableInvariantTests.sol -- Invariant/fuzz tests
33
+ ├── handlers/
34
+ │ └── OwnableHandler.sol -- Invariant test handler
35
+ ├── mocks/
36
+ │ └── MockOwnable.sol -- Mock contract for testing
37
+ └── regression/
38
+ ├── BurnLockProtection.t.sol -- Regression: burned NFT lockout
39
+ └── ZeroAddressValidation.t.sol -- Regression: zero-address edge cases
40
+ ```
41
+
17
42
  ## Architecture
18
43
 
19
44
  ```
@@ -78,6 +103,12 @@ When `_checkOwner()` is called (by the `onlyOwner` modifier), it:
78
103
  3. Those addresses can now call `onlyOwner` functions.
79
104
  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.
80
105
 
106
+ ## Risks
107
+
108
+ - **Burned project NFT permanently locks the contract.** If ownership is tied to a project and the project NFT is burned, `owner()` returns `address(0)` (the `ownerOf` call reverts and the try-catch falls through). No one can call `onlyOwner` functions, and there is no recovery path -- this is effectively permanent renunciation. JBProjects V6 does not expose a public burn function, but a project NFT could still be burned via a custom token wrapper or a future upgrade.
109
+ - **Silent loss of delegated access on ownership transfer.** The `permissionId` resets to 0 on every ownership transfer (both `transferOwnership` and `transferOwnershipToProject`). Any operators previously granted the old `permissionId` via `JBPermissions` lose their `onlyOwner` access without notification. The new owner must call `setPermissionId` and re-grant permissions to restore delegated access. Off-chain monitoring of the `PermissionIdChanged` event is recommended.
110
+ - **Permission ID collisions with other JBPermissions grants.** The `permissionId` set on a `JBOwnable` contract is checked via the same `JBPermissions` registry used by the rest of the protocol. If the chosen `permissionId` overlaps with an ID already granted to operators for other purposes (e.g., a terminal permission or a hook permission), those operators will also pass the `onlyOwner` check -- gaining unintended owner-level access to the `JBOwnable` contract. Choose a `permissionId` that is not used by any other permission in the project's permission set.
111
+
81
112
  ## Install
82
113
 
83
114
  ```bash
package/RISKS.md CHANGED
@@ -9,13 +9,19 @@
9
9
  ## 2. Known Risks
10
10
 
11
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
12
  - **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.
13
+ - **`renounceOwnership` permanently disables all owner-gated functions.** Defined directly in `JBOwnableOverrides` (not inherited from OpenZeppelin's `Ownable` -- the contract does not inherit from `Ownable`). Once called, `owner()` returns `address(0)` and all `onlyOwner` / `_checkOwner()` calls revert permanently. There is no recovery mechanism. This applies whether ownership is direct (address) or project-based (project NFT holder).
16
14
 
17
- ## 3. Invariants to Verify
15
+ ## 3. Accepted Behaviors
16
+
17
+ - **Permission ID reset on transfer.** `permissionId` resets to 0 on ownership transfer, which temporarily locks out delegated operators. This is intentional -- it prevents permission clashes for new owners.
18
+ - **Burned/invalid project NFT.** If the project NFT were burned or `ownerOf` reverted, the contract would be effectively renounced (owner resolves to `address(0)`). Defensive try-catch in `owner()` and `_checkOwner()` handles this gracefully. JBProjects V6 has no burn function, so this scenario cannot occur in practice.
19
+ - **`transferOwnershipToProject` rejects non-existent projects.** The function checks `projectId > PROJECTS.count()` and reverts, preventing transfers to non-existent projects.
20
+
21
+ ## 4. Invariants to Verify
18
22
 
19
23
  - Ownership is always exactly one of: direct address OR project NFT holder (never both, never neither unless renounced).
20
24
  - `_checkOwner()` reverts for all callers when the owner resolves to `address(0)`.
21
25
  - `permissionId` is correctly reset to 0 on every ownership transfer.
26
+ - After `renounceOwnership()`, `jbOwner()` returns `(address(0), 0, 0)` and no address can pass `_checkOwner()`.
27
+ - `transferOwnershipToProject(projectId)` reverts for all `projectId > PROJECTS.count()` at call time.
package/SKILLS.md CHANGED
@@ -21,6 +21,10 @@ JBOwnable
21
21
  └── IJBOwnable (interface)
22
22
  ```
23
23
 
24
+ ## Deployed Addresses
25
+
26
+ Library contract -- deployed as part of inheriting contracts (e.g., `JB721TiersHook`, `JBBuybackHook`). No standalone deployment.
27
+
24
28
  ## Key Functions
25
29
 
26
30
  ### Public / External
@@ -71,6 +75,22 @@ JBOwnable
71
75
  | `JBOwnableOverrides_ProjectDoesNotExist()` | `transferOwnershipToProject(id)` where `id > PROJECTS.count()`. |
72
76
  | `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | Constructor receives a non-zero `initialProjectIdOwner` with `projects` set to `address(0)`. |
73
77
 
78
+ ## IJBOwnable Interface
79
+
80
+ Defined in `src/interfaces/IJBOwnable.sol`. Pragma `^0.8.0`.
81
+
82
+ | Method | Signature | Returns |
83
+ |--------|-----------|---------|
84
+ | `PROJECTS()` | `function PROJECTS() external view` | `IJBProjects` |
85
+ | `jbOwner()` | `function jbOwner() external view` | `(address owner, uint88 projectId, uint8 permissionId)` |
86
+ | `owner()` | `function owner() external view` | `address` |
87
+ | `renounceOwnership()` | `function renounceOwnership() external` | -- |
88
+ | `setPermissionId(uint8)` | `function setPermissionId(uint8 permissionId) external` | -- |
89
+ | `transferOwnership(address)` | `function transferOwnership(address newOwner) external` | -- |
90
+ | `transferOwnershipToProject(uint256)` | `function transferOwnershipToProject(uint256 projectId) external` | -- |
91
+
92
+ Events: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)`, `PermissionIdChanged(uint8 newId, address caller)`.
93
+
74
94
  ## Integration Points
75
95
 
76
96
  | Dependency | Import | Used For |
@@ -91,7 +111,7 @@ JBOwnable
91
111
  - **`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
112
  - **`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
113
  - **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.
114
+ - **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. If you need meta-tx support, override `_msgSender()` and `_msgData()` with `ERC2771Context` in your inheriting contract.
95
115
 
96
116
  ## Example: Inherit JBOwnable
97
117
 
package/STYLE_GUIDE.md CHANGED
@@ -21,7 +21,7 @@ One contract/interface/struct/enum per file. Name the file after the type it con
21
21
 
22
22
  ```solidity
23
23
  // Contracts — pin to exact version
24
- pragma solidity 0.8.26;
24
+ pragma solidity ^0.8.26;
25
25
 
26
26
  // Interfaces, structs, enums — caret for forward compatibility
27
27
  pragma solidity ^0.8.0;
package/USER_JOURNEYS.md CHANGED
@@ -1,245 +1,200 @@
1
1
  # User Journeys -- nana-ownable-v6
2
2
 
3
- Concrete end-to-end flows through the JBOwnable system. Each journey traces the exact function calls, state changes, and external interactions.
3
+ Concrete end-to-end flows through the JBOwnable system. Each journey traces the exact function calls, state changes, events, and edge cases.
4
+
5
+ ---
4
6
 
5
7
  ## Journey 1: Deploy a Project-Owned Contract
6
8
 
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
+ **Entry point**: `new MyHook(IJBPermissions permissions, IJBProjects projects, address(0), uint88 projectId)`
9
10
 
10
- ### Precondition
11
+ **Who can call**: Anyone (deployment is permissionless).
11
12
 
12
- A Juicebox project exists with ID `projectId`. The `JBProjects` and `JBPermissions` contracts are deployed.
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
13
18
 
14
- ### Steps
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)`
15
28
 
16
- 1. **Developer deploys a contract inheriting `JBOwnable`**
29
+ **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(address(0), PROJECTS.ownerOf(projectId), msg.sender)`
17
30
 
18
- ```solidity
19
- new MyHook(permissions, projects, address(0), projectId)
20
- ```
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.
21
35
 
22
- - `initialOwner = address(0)` because ownership is project-based
23
- - `initialProjectIdOwner = projectId`
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
24
40
 
25
- 2. **Constructor execution in `JBOwnableOverrides`**
41
+ ---
26
42
 
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)`
43
+ ## Journey 1b: Deploy an Address-Owned Contract
34
44
 
35
- 3. **Ownership is now live**
45
+ **Entry point**: `new MyHook(IJBPermissions permissions, IJBProjects projects, address initialOwner, uint88(0))`
36
46
 
37
- - `owner()` calls `PROJECTS.ownerOf(projectId)` and returns the current NFT holder
38
- - `_checkOwner()` validates `msg.sender` against the NFT holder (or permission delegates)
47
+ **Who can call**: Anyone (deployment is permissionless).
39
48
 
40
- ### Result
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
41
54
 
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.
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)`
43
62
 
44
- ### What to verify
63
+ **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(address(0), initialOwner, msg.sender)`
45
64
 
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).
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).
50
69
 
51
70
  ---
52
71
 
53
72
  ## Journey 2: Transfer Ownership to a Different Address
54
73
 
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
74
+ **Entry point**: `JBOwnableOverrides.transferOwnership(address newOwner)`
63
75
 
64
- 1. **Owner calls `transferOwnership(newOwner)`**
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`.
65
77
 
66
- - `newOwner` must not be `address(0)` (reverts with `JBOwnableOverrides_InvalidNewOwner`)
78
+ **Parameters**:
79
+ - `newOwner` -- The address to transfer ownership to (must not be `address(0)`)
67
80
 
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`)
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)
77
86
  - Overwrites `jbOwner = JBOwner({owner: newOwner, projectId: 0, permissionId: 0})`
78
87
  - Calls `_emitTransferEvent(oldOwner, newOwner, 0)`
79
88
 
80
- 4. **`_emitTransferEvent` in `JBOwnable`**
81
-
82
- - Since `newProjectId == 0`: emits `OwnershipTransferred(oldOwner, newOwner, msg.sender)`
89
+ **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(oldOwner, newOwner, msg.sender)`
83
90
 
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.
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
94
96
 
95
97
  ---
96
98
 
97
99
  ## Journey 3: Transfer Ownership to a Juicebox Project
98
100
 
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
101
+ **Entry point**: `JBOwnableOverrides.transferOwnershipToProject(uint256 projectId)`
107
102
 
108
- 1. **Owner calls `transferOwnershipToProject(projectId)`**
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`.
109
104
 
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**
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)
117
107
 
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:
118
113
  - Records `oldOwner` (resolved from current `jbOwner`)
119
114
  - Overwrites `jbOwner = JBOwner({owner: address(0), projectId: uint88(projectId), permissionId: 0})`
120
115
  - Calls `_emitTransferEvent(oldOwner, address(0), uint88(projectId))`
121
116
 
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.
117
+ **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(oldOwner, PROJECTS.ownerOf(projectId), msg.sender)`
129
118
 
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.
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.
135
125
 
136
126
  ---
137
127
 
138
128
  ## Journey 4: Delegate Access via Permission ID
139
129
 
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)`
130
+ **Entry point**: `JBOwnableOverrides.setPermissionId(uint8 permissionId)`
154
131
 
155
- 2. **Owner grants the permission to operators via JBPermissions**
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`.
156
133
 
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
134
+ **Parameters**:
135
+ - `permissionId` -- The new permission ID to use for `onlyOwner` access delegation
159
136
 
160
- 3. **Operator calls an `onlyOwner` function**
137
+ **State changes**:
138
+ 1. `_checkOwner()` validates the caller
139
+ 2. `_setPermissionId(permissionId)` writes `jbOwner.permissionId = permissionId`
161
140
 
162
- - `_checkOwner()` resolves the owner and calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`
163
- - `JBPermissioned._requirePermissionFrom` checks `JBPermissions.hasPermission(msg.sender, resolvedOwner, projectId, permissionId)` -- passes
141
+ **Events**: `PermissionIdChanged(uint8 newId, address caller)` -- emitted as `PermissionIdChanged(permissionId, msg.sender)`
164
142
 
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
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.
170
146
 
147
+ **Edge cases**:
171
148
  - `permissionId == 0` effectively disables delegation (permission ID 0 cannot be set in `JBPermissions`). Only the owner (or ROOT holders) can call `onlyOwner` functions.
172
149
  - 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`.
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)`.
174
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.
175
152
 
176
153
  ---
177
154
 
178
155
  ## Journey 5: Renounce Ownership
179
156
 
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
157
+ **Entry point**: `JBOwnableOverrides.renounceOwnership()`
188
158
 
189
- 1. **Owner calls `renounceOwnership()`**
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`.
190
160
 
191
- - `_checkOwner()` validates the caller
192
-
193
- 2. **`_transferOwnership(address(0), 0)` executes**
161
+ **Parameters**: None.
194
162
 
163
+ **State changes**:
164
+ 1. `_checkOwner()` validates the caller
165
+ 2. `_transferOwnership(address(0), 0)` executes:
195
166
  - Records `oldOwner` (resolved from current `jbOwner`)
196
167
  - Overwrites `jbOwner = JBOwner({owner: address(0), projectId: 0, permissionId: 0})`
197
168
  - 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
169
 
204
- ### What to verify
170
+ **Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(oldOwner, address(0), msg.sender)`
205
171
 
206
- - After renouncing, `transferOwnership`, `transferOwnershipToProject`, `setPermissionId`, and `renounceOwnership` all revert.
172
+ **Edge cases**:
173
+ - After renouncing, `transferOwnership`, `transferOwnershipToProject`, `setPermissionId`, and `renounceOwnership` all revert
207
174
  - 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)`.
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)`
210
177
 
211
178
  ---
212
179
 
213
180
  ## Journey 6: Implicit Renouncement via Project NFT Burn
214
181
 
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
182
+ **Actor**: None (system behavior).
231
183
 
232
- 3. **`_checkOwner()` catches the revert and resolves owner to `address(0)`**
184
+ **Who can call**: N/A -- this is an emergent behavior, not a direct function call.
233
185
 
234
- - `_requirePermissionFrom(address(0), projectId, permissionId)` is called
235
- - No `msg.sender` can equal `address(0)`, so the check always fails
186
+ **Parameters**: None.
236
187
 
237
- ### Result
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).
238
189
 
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.
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)
240
194
 
241
- ### What to verify
195
+ **Events**: None (no transaction occurs on the JBOwnable contract).
242
196
 
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).
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/ownable-v6",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
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.17",
14
- "@bananapus/permission-ids-v6": "^0.0.10",
13
+ "@bananapus/core-v6": "^0.0.26",
14
+ "@bananapus/permission-ids-v6": "^0.0.12",
15
15
  "@openzeppelin/contracts": "^5.6.1"
16
16
  },
17
17
  "scripts": {
package/src/JBOwnable.sol CHANGED
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Juicebox variation on OpenZeppelin Ownable
3
- pragma solidity 0.8.26;
3
+ pragma solidity ^0.8.26;
4
4
 
5
5
  import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
6
6
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -73,7 +73,7 @@ contract JBOwnable is JBOwnableOverrides {
73
73
  emit OwnershipTransferred({
74
74
  previousOwner: previousOwner,
75
75
  newOwner: newProjectId == 0 ? newOwner : PROJECTS.ownerOf(newProjectId),
76
- caller: msg.sender
76
+ caller: _msgSender()
77
77
  });
78
78
  }
79
79
  }
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Juicebox variation on OpenZeppelin Ownable
3
- pragma solidity 0.8.26;
3
+ pragma solidity ^0.8.26;
4
4
 
5
5
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
6
6
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -206,7 +206,7 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
206
206
  /// @param permissionId The permission ID to use for `onlyOwner`.
207
207
  function _setPermissionId(uint8 permissionId) internal virtual {
208
208
  jbOwner.permissionId = permissionId;
209
- emit PermissionIdChanged({newId: permissionId, caller: msg.sender});
209
+ emit PermissionIdChanged({newId: permissionId, caller: _msgSender()});
210
210
  }
211
211
 
212
212
  /// @notice Helper to allow for drop-in replacement of OpenZeppelin `Ownable`.
@@ -3,6 +3,7 @@ pragma solidity ^0.8.26;
3
3
 
4
4
  import {Test} from "forge-std/Test.sol";
5
5
  import {MockOwnable} from "./mocks/MockOwnable.sol";
6
+ import {MockOwnableERC2771} from "./mocks/MockOwnableERC2771.sol";
6
7
  import {JBOwnableOverrides} from "../src/JBOwnableOverrides.sol";
7
8
  import {IJBOwnable} from "../src/interfaces/IJBOwnable.sol";
8
9
 
@@ -371,4 +372,50 @@ contract OwnableEdgeCases is Test {
371
372
  vm.prank(alice);
372
373
  ownable.protectedMethod();
373
374
  }
375
+
376
+ // =========================================================================
377
+ // Test 13: OwnershipTransferred event uses _msgSender() (L-27 fix)
378
+ // =========================================================================
379
+ /// @notice When a subclass overrides _msgSender() (e.g., for ERC-2771),
380
+ /// the OwnershipTransferred event's caller field should reflect the
381
+ /// forwarded sender, not msg.sender.
382
+ function test_ownershipTransferredEvent_usesOverriddenMsgSender() public {
383
+ address forwarder = makeAddr("forwarder");
384
+ MockOwnableERC2771 ownable = new MockOwnableERC2771(projects, permissions, alice, 0, forwarder);
385
+
386
+ // Expect event with caller=alice (the forwarded sender), not forwarder.
387
+ vm.expectEmit(true, true, false, true);
388
+ emit IJBOwnable.OwnershipTransferred(alice, bob, alice);
389
+
390
+ // Forwarder calls transferOwnership with alice's address appended (ERC-2771 style).
391
+ bytes memory callData = abi.encodeWithSelector(IJBOwnable.transferOwnership.selector, bob);
392
+ bytes memory forwardedCallData = abi.encodePacked(callData, alice);
393
+
394
+ vm.prank(forwarder);
395
+ (bool success,) = address(ownable).call(forwardedCallData);
396
+ assertTrue(success, "Forwarded transferOwnership should succeed");
397
+ }
398
+
399
+ // =========================================================================
400
+ // Test 14: PermissionIdChanged event uses _msgSender() (L-27 fix)
401
+ // =========================================================================
402
+ /// @notice When a subclass overrides _msgSender() (e.g., for ERC-2771),
403
+ /// the PermissionIdChanged event's caller field should reflect the
404
+ /// forwarded sender, not msg.sender.
405
+ function test_permissionIdChangedEvent_usesOverriddenMsgSender() public {
406
+ address forwarder = makeAddr("forwarder");
407
+ MockOwnableERC2771 ownable = new MockOwnableERC2771(projects, permissions, alice, 0, forwarder);
408
+
409
+ // Expect event with caller=alice (the forwarded sender), not forwarder.
410
+ vm.expectEmit(true, true, false, true);
411
+ emit IJBOwnable.PermissionIdChanged(42, alice);
412
+
413
+ // Forwarder calls setPermissionId with alice's address appended.
414
+ bytes memory callData = abi.encodeWithSelector(IJBOwnable.setPermissionId.selector, uint8(42));
415
+ bytes memory forwardedCallData = abi.encodePacked(callData, alice);
416
+
417
+ vm.prank(forwarder);
418
+ (bool success,) = address(ownable).call(forwardedCallData);
419
+ assertTrue(success, "Forwarded setPermissionId should succeed");
420
+ }
374
421
  }
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: UNLICENSED
2
+ pragma solidity ^0.8.26;
3
+
4
+ import {JBOwnable} from "../../src/JBOwnable.sol";
5
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
6
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
7
+
8
+ /// @notice Mock that overrides _msgSender() to simulate ERC-2771 meta-transaction behavior.
9
+ /// @dev When called by the trusted forwarder, the last 20 bytes of calldata are treated as the real sender.
10
+ contract MockOwnableERC2771 is JBOwnable {
11
+ event ProtectedMethodCalled();
12
+
13
+ address public immutable TRUSTED_FORWARDER;
14
+
15
+ constructor(
16
+ IJBProjects projects,
17
+ IJBPermissions permissions,
18
+ address initialOwner,
19
+ uint88 initialProjectIdOwner,
20
+ address trustedForwarder
21
+ )
22
+ JBOwnable(permissions, projects, initialOwner, initialProjectIdOwner)
23
+ {
24
+ TRUSTED_FORWARDER = trustedForwarder;
25
+ }
26
+
27
+ function protectedMethod() external onlyOwner {
28
+ emit ProtectedMethodCalled();
29
+ }
30
+
31
+ function _msgSender() internal view override returns (address) {
32
+ if (msg.sender == TRUSTED_FORWARDER && msg.data.length >= 20) {
33
+ return address(bytes20(msg.data[msg.data.length - 20:]));
34
+ }
35
+ return msg.sender;
36
+ }
37
+ }