@bananapus/ownable-v6 0.0.12 → 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 +20 -0
- package/ARCHITECTURE.md +15 -1
- package/AUDIT_INSTRUCTIONS.md +57 -3
- package/CHANGE_LOG.md +8 -2
- package/README.md +31 -0
- package/RISKS.md +10 -4
- package/SKILLS.md +21 -1
- package/STYLE_GUIDE.md +1 -1
- package/USER_JOURNEYS.md +117 -162
- package/package.json +3 -3
- package/src/JBOwnable.sol +2 -2
- package/src/JBOwnableOverrides.sol +2 -2
- package/test/OwnableEdgeCases.t.sol +47 -0
- package/test/mocks/MockOwnableERC2771.sol +37 -0
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
|
package/AUDIT_INSTRUCTIONS.md
CHANGED
|
@@ -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
|
-
- **
|
|
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/
|
|
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
|
-
- **`
|
|
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.
|
|
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
|
|
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
|
-
**
|
|
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
|
-
|
|
11
|
+
**Who can call**: Anyone (deployment is permissionless).
|
|
11
12
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
+
**Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(address(0), PROJECTS.ownerOf(projectId), msg.sender)`
|
|
17
30
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
41
|
+
---
|
|
26
42
|
|
|
27
|
-
|
|
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
|
-
|
|
45
|
+
**Entry point**: `new MyHook(IJBPermissions permissions, IJBProjects projects, address initialOwner, uint88(0))`
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
- `_checkOwner()` validates `msg.sender` against the NFT holder (or permission delegates)
|
|
47
|
+
**Who can call**: Anyone (deployment is permissionless).
|
|
39
48
|
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
**Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(address(0), initialOwner, msg.sender)`
|
|
45
64
|
|
|
46
|
-
|
|
47
|
-
- `jbOwner.
|
|
48
|
-
-
|
|
49
|
-
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
**Parameters**:
|
|
79
|
+
- `newOwner` -- The address to transfer ownership to (must not be `address(0)`)
|
|
67
80
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
`
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
- The
|
|
133
|
-
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
134
|
+
**Parameters**:
|
|
135
|
+
- `permissionId` -- The new permission ID to use for `onlyOwner` access delegation
|
|
159
136
|
|
|
160
|
-
|
|
137
|
+
**State changes**:
|
|
138
|
+
1. `_checkOwner()` validates the caller
|
|
139
|
+
2. `_setPermissionId(permissionId)` writes `jbOwner.permissionId = permissionId`
|
|
161
140
|
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
+
**Events**: `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)` -- emitted as `OwnershipTransferred(oldOwner, address(0), msg.sender)`
|
|
205
171
|
|
|
206
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
184
|
+
**Who can call**: N/A -- this is an emergent behavior, not a direct function call.
|
|
233
185
|
|
|
234
|
-
|
|
235
|
-
- No `msg.sender` can equal `address(0)`, so the check always fails
|
|
186
|
+
**Parameters**: None.
|
|
236
187
|
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
**Events**: None (no transaction occurs on the JBOwnable contract).
|
|
242
196
|
|
|
243
|
-
|
|
244
|
-
-
|
|
245
|
-
-
|
|
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.
|
|
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.
|
|
14
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
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:
|
|
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:
|
|
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
|
+
}
|