@bananapus/ownable-v6 0.0.9 → 0.0.10
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 +1 -1
- package/AUDIT_INSTRUCTIONS.md +133 -0
- package/CHANGE_LOG.md +221 -0
- package/RISKS.md +15 -17
- package/USER_JOURNEYS.md +245 -0
- package/package.json +3 -3
- package/src/JBOwnable.sol +4 -1
- package/src/JBOwnableOverrides.sol +5 -1
- package/test/OwnableEdgeCases.t.sol +1 -1
package/ADMINISTRATION.md
CHANGED
|
@@ -47,7 +47,7 @@ JBOwner {
|
|
|
47
47
|
2. If `projectId == 0`, the owner is `JBOwner.owner` directly.
|
|
48
48
|
3. In both cases, `_checkOwner()` calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`, which passes if `msg.sender` is the resolved owner OR has the configured `permissionId` granted through `JBPermissions`.
|
|
49
49
|
|
|
50
|
-
**Permission delegation** uses the nana-core `JBPermissions` contract. The owner calls `JBPermissions.setPermissionsFor(...)` to grant `permissionId` to an operator address. That operator can then call any `onlyOwner` function on this contract. The ROOT permission (ID
|
|
50
|
+
**Permission delegation** uses the nana-core `JBPermissions` contract. The owner calls `JBPermissions.setPermissionsFor(...)` to grant `permissionId` to an operator address. That operator can then call any `onlyOwner` function on this contract. The ROOT permission (ID 1) in `JBPermissions` grants all permission IDs, including whatever `permissionId` is configured here.
|
|
51
51
|
|
|
52
52
|
**Ownership transfer resets `permissionId` to 0.** This prevents the previous owner's delegates from retaining access after a transfer. The new owner must explicitly call `setPermissionId()` to configure delegation.
|
|
53
53
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Audit Instructions -- nana-ownable-v6
|
|
2
|
+
|
|
3
|
+
You are auditing a Juicebox-aware ownership module that extends OpenZeppelin's Ownable pattern. A contract inheriting `JBOwnable` can be owned by a Juicebox project (via its ERC-721 NFT) or a direct address, with delegated access through `JBPermissions`. This is a foundational access control primitive used by hooks and extensions across the Juicebox V6 ecosystem. Read [RISKS.md](./RISKS.md) first -- it documents all known risks and trust assumptions. Then come back here.
|
|
4
|
+
|
|
5
|
+
## Scope
|
|
6
|
+
|
|
7
|
+
**In scope -- all Solidity in `src/`:**
|
|
8
|
+
```
|
|
9
|
+
src/JBOwnable.sol # Concrete implementation with onlyOwner modifier (~76 lines)
|
|
10
|
+
src/JBOwnableOverrides.sol # Abstract base with all ownership logic (~244 lines)
|
|
11
|
+
src/structs/JBOwner.sol # Owner struct: {owner, projectId, permissionId} (~15 lines)
|
|
12
|
+
src/interfaces/IJBOwnable.sol # Interface (~47 lines)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**Out of scope:** Test files, JB Core contracts (`JBPermissioned`, `IJBPermissions`, `IJBProjects`), OpenZeppelin contracts, forge-std. Assume these dependencies are correct.
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
### Ownership Model
|
|
20
|
+
|
|
21
|
+
The `JBOwner` struct packs into a single 256-bit storage slot:
|
|
22
|
+
|
|
23
|
+
```solidity
|
|
24
|
+
struct JBOwner {
|
|
25
|
+
address owner; // 160 bits -- direct owner (used when projectId == 0)
|
|
26
|
+
uint88 projectId; // 88 bits -- JB project whose NFT holder is owner (used when != 0)
|
|
27
|
+
uint8 permissionId; // 8 bits -- JBPermissions ID for delegated access
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Resolution rules:**
|
|
32
|
+
1. If `projectId != 0`: owner = `PROJECTS.ownerOf(projectId)` (external call, try-catch wrapped)
|
|
33
|
+
2. If `projectId == 0`: owner = `jbOwner.owner` (storage read)
|
|
34
|
+
3. If the `ownerOf` call reverts (e.g., burned NFT): owner resolves to `address(0)`, effectively renouncing the contract
|
|
35
|
+
|
|
36
|
+
**Delegated access:** `_checkOwner()` calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)` from `JBPermissioned`. This passes if `msg.sender == resolvedOwner` OR if `msg.sender` has the configured `permissionId` (or ROOT) in `JBPermissions`.
|
|
37
|
+
|
|
38
|
+
### Inheritance Chain
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
JBOwnable (concrete)
|
|
42
|
+
extends JBOwnableOverrides (abstract)
|
|
43
|
+
extends Context (OpenZeppelin)
|
|
44
|
+
extends JBPermissioned (nana-core)
|
|
45
|
+
implements IJBOwnable (interface)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`JBOwnable` adds the `onlyOwner` modifier and implements `_emitTransferEvent`. `JBOwnableOverrides` contains all state management, transfer logic, and the `_checkOwner` function. Contracts that need custom `_emitTransferEvent` behavior inherit `JBOwnableOverrides` directly.
|
|
49
|
+
|
|
50
|
+
### Key Functions
|
|
51
|
+
|
|
52
|
+
| Function | Access | What it does |
|
|
53
|
+
|----------|--------|--------------|
|
|
54
|
+
| `owner()` | `public view` | Resolves and returns the current owner address. Makes an external call to `PROJECTS.ownerOf()` when project-owned. |
|
|
55
|
+
| `transferOwnership(address newOwner)` | `onlyOwner` | Transfers ownership to a direct address. Reverts on `address(0)`. Resets `permissionId` to 0. |
|
|
56
|
+
| `transferOwnershipToProject(uint256 projectId)` | `onlyOwner` | Transfers ownership to a JB project. Validates: non-zero, fits `uint88`, project exists (`<= PROJECTS.count()`). Resets `permissionId` to 0. |
|
|
57
|
+
| `renounceOwnership()` | `onlyOwner` | Sets owner to `address(0)` and projectId to 0. Irreversible. |
|
|
58
|
+
| `setPermissionId(uint8 permissionId)` | `onlyOwner` | Sets which JBPermissions ID grants delegated owner access. |
|
|
59
|
+
| `_checkOwner()` | `internal view` | Resolves owner, then calls `_requirePermissionFrom`. Used by `onlyOwner` modifier. |
|
|
60
|
+
| `_transferOwnership(address, uint88)` | `internal` | Core transfer logic. Updates `jbOwner`, resets `permissionId`, calls `_emitTransferEvent`. No access restriction. |
|
|
61
|
+
|
|
62
|
+
## Priority Audit Areas
|
|
63
|
+
|
|
64
|
+
### 1. Ownership Resolution Correctness (Highest Priority)
|
|
65
|
+
|
|
66
|
+
The `owner()` and `_checkOwner()` functions both resolve ownership via the same pattern but are separate implementations (not shared helper). Verify:
|
|
67
|
+
|
|
68
|
+
- **Consistency between `owner()` and `_checkOwner()`.** Both use try-catch on `PROJECTS.ownerOf()` and fall back to `address(0)` on revert. Verify they always agree on who the owner is. A divergence could allow someone to pass `_checkOwner()` while `owner()` returns a different address (or vice versa).
|
|
69
|
+
- **Try-catch scope.** The try-catch catches ALL reverts from `PROJECTS.ownerOf()`, including out-of-gas. Could an attacker force an OOG in the external call to make `_checkOwner()` resolve to `address(0)`, then bypass access control? (This would require `_requirePermissionFrom(address(0), ...)` to pass, which should not be possible for any non-zero `msg.sender` unless they have ROOT permission for the project.)
|
|
70
|
+
- **Zero-address as resolved owner.** When the resolved owner is `address(0)` (burned NFT or renounced), `_requirePermissionFrom(address(0), projectId, permissionId)` should revert for any caller. Verify this holds in `JBPermissioned` -- specifically that `msg.sender != address(0)` and no permission is granted to `address(0)`.
|
|
71
|
+
|
|
72
|
+
### 2. Ownership Transfer State Machine
|
|
73
|
+
|
|
74
|
+
Ownership transitions must be airtight:
|
|
75
|
+
|
|
76
|
+
- **Mutual exclusivity.** `_transferOwnership(address newOwner, uint88 projectId)` reverts if both are non-zero. Verify there is no code path that sets both `jbOwner.owner` and `jbOwner.projectId` to non-zero values simultaneously.
|
|
77
|
+
- **Permission reset.** Every ownership transfer resets `permissionId` to 0. Verify this happens in `_transferOwnership` (it does -- the entire `JBOwner` struct is overwritten). Verify there is no path to transfer ownership without going through `_transferOwnership`.
|
|
78
|
+
- **Constructor validation.** The constructor rejects `(address(0), 0)` as initial owner (both zero). It also rejects `(non-zero projectId, address(0) projects)`. Verify these are the only two invalid initial states.
|
|
79
|
+
- **Project existence check.** `transferOwnershipToProject` checks `projectId <= PROJECTS.count()`. Verify this prevents transferring to a non-existent project. Note: `PROJECTS.count()` returns the total number of projects ever created, and project IDs are sequential starting from 1.
|
|
80
|
+
|
|
81
|
+
### 3. Permission Delegation Security
|
|
82
|
+
|
|
83
|
+
`_checkOwner()` delegates to `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`. Verify:
|
|
84
|
+
|
|
85
|
+
- **When `permissionId == 0`.** Permission ID 0 is forbidden in `JBPermissions` (cannot be set). This means when `permissionId` is 0 (the default after any transfer), ONLY the resolved owner or ROOT holders can pass `_checkOwner()`. Delegated access is effectively disabled until `setPermissionId()` is called.
|
|
86
|
+
- **ROOT override.** ROOT (permission ID 1) always grants access via `_requirePermissionFrom`. An address with ROOT for the relevant project can act as owner of any `JBOwnable` contract owned by that project, regardless of the configured `permissionId`. Verify this is intentional and documented.
|
|
87
|
+
- **Wildcard project ID.** Permissions granted with `projectId = 0` in `JBPermissions` apply to all projects. Verify that a wildcard ROOT grant (which `JBPermissions` should reject) cannot be used to bypass `_checkOwner()`.
|
|
88
|
+
|
|
89
|
+
### 4. Renunciation Edge Cases
|
|
90
|
+
|
|
91
|
+
- **Renouncing when project-owned.** If `projectId != 0` and the NFT holder calls `renounceOwnership()`, both `owner` and `projectId` are set to 0. Verify the NFT holder can still call `renounceOwnership()` (passes `_checkOwner` with the project-resolved owner).
|
|
92
|
+
- **Renouncing after NFT burn.** If the project NFT is burned (hypothetically -- JBProjects V6 has no burn), `owner()` returns `address(0)`, making `_checkOwner()` revert for everyone. The contract is effectively renounced without anyone calling `renounceOwnership()`. Verify this state is consistent and cannot be escaped.
|
|
93
|
+
- **Double renounce.** After renouncing, calling `renounceOwnership()` again should revert because `_checkOwner()` will fail (resolved owner is `address(0)` and `msg.sender` cannot be `address(0)`).
|
|
94
|
+
|
|
95
|
+
### 5. Storage Slot Packing
|
|
96
|
+
|
|
97
|
+
The `JBOwner` struct fits in a single 256-bit slot: `address` (160) + `uint88` (88) + `uint8` (8) = 256. Verify:
|
|
98
|
+
- The Solidity compiler lays out the struct as expected (no padding gaps).
|
|
99
|
+
- The `_transferOwnership` function writes the entire struct atomically (`jbOwner = JBOwner({...})`), preventing partial-write inconsistencies.
|
|
100
|
+
|
|
101
|
+
## Invariants to Verify
|
|
102
|
+
|
|
103
|
+
1. **Mutual exclusivity**: At no point can both `jbOwner.owner != address(0)` and `jbOwner.projectId != 0`.
|
|
104
|
+
2. **Transfer resets permission**: After any call to `_transferOwnership`, `jbOwner.permissionId == 0`.
|
|
105
|
+
3. **Renounce is terminal**: After `renounceOwnership()`, `jbOwner.owner == address(0)` AND `jbOwner.projectId == 0` AND all subsequent `_checkOwner()` calls revert.
|
|
106
|
+
4. **owner() consistency**: `owner()` and the resolved owner in `_checkOwner()` always agree.
|
|
107
|
+
5. **No unauthorized transfer**: Only addresses passing `_checkOwner()` can call `transferOwnership`, `transferOwnershipToProject`, `renounceOwnership`, or `setPermissionId`.
|
|
108
|
+
|
|
109
|
+
## Testing Setup
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
cd nana-ownable-v6
|
|
113
|
+
npm install
|
|
114
|
+
forge build
|
|
115
|
+
forge test
|
|
116
|
+
|
|
117
|
+
# Run attack scenarios
|
|
118
|
+
forge test --match-contract OwnableAttacks -vvv
|
|
119
|
+
|
|
120
|
+
# Run edge cases
|
|
121
|
+
forge test --match-contract OwnableEdgeCases -vvv
|
|
122
|
+
|
|
123
|
+
# Run invariant tests
|
|
124
|
+
forge test --match-contract OwnableInvariant -vvv
|
|
125
|
+
|
|
126
|
+
# Run regression tests
|
|
127
|
+
forge test --match-path test/regression/ -vvv
|
|
128
|
+
|
|
129
|
+
# Write a PoC
|
|
130
|
+
forge test --match-path test/audit/ExploitPoC.t.sol -vvv
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Go break it.
|
package/CHANGE_LOG.md
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# nana-ownable-v6 Changelog (v5 → v6)
|
|
2
|
+
|
|
3
|
+
This document describes all changes between `nana-ownable` (v5) and `nana-ownable-v6` (v6).
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Breaking Changes
|
|
8
|
+
|
|
9
|
+
### Solidity Version Pinned
|
|
10
|
+
|
|
11
|
+
All contracts changed from `pragma solidity ^0.8.23` (floating) to `pragma solidity 0.8.26` (pinned). This means v6 can only compile with Solidity 0.8.26 exactly.
|
|
12
|
+
|
|
13
|
+
**Affected files:** `JBOwnable.sol`, `JBOwnableOverrides.sol`
|
|
14
|
+
|
|
15
|
+
### Import Paths Updated
|
|
16
|
+
|
|
17
|
+
All imports reference `@bananapus/core-v6` instead of `@bananapus/core-v5`. Any project depending on `nana-ownable` must also migrate to `nana-core-v6`.
|
|
18
|
+
|
|
19
|
+
### `owner()` Returns `address(0)` Instead of Reverting for Invalid Projects
|
|
20
|
+
|
|
21
|
+
In v5, if a project NFT was burned or otherwise invalid, `owner()` would revert because `PROJECTS.ownerOf()` reverts for nonexistent tokens.
|
|
22
|
+
|
|
23
|
+
In v6, `owner()` wraps the call in `try-catch` and returns `address(0)` if `ownerOf()` reverts. This changes the observable behavior: callers that previously relied on a revert to detect invalid project ownership will now receive `address(0)` instead.
|
|
24
|
+
|
|
25
|
+
### `_checkOwner()` Behavior Change for Invalid Projects
|
|
26
|
+
|
|
27
|
+
In v5, `_checkOwner()` would revert with the ERC-721 "nonexistent token" error if the owning project NFT was burned.
|
|
28
|
+
|
|
29
|
+
In v6, it resolves the owner to `address(0)` via `try-catch` and then passes `address(0)` to `_requirePermissionFrom`. This still causes a revert (no one can authenticate as `address(0)`), but the revert reason changes from an ERC-721 error to a permissions error.
|
|
30
|
+
|
|
31
|
+
### `transferOwnershipToProject()` Now Validates Project Existence
|
|
32
|
+
|
|
33
|
+
In v6, `transferOwnershipToProject()` checks `projectId > PROJECTS.count()` and reverts with `JBOwnableOverrides_ProjectDoesNotExist()` if the project has not been created yet. In v5, no such check existed, so ownership could be transferred to a nonexistent project ID.
|
|
34
|
+
|
|
35
|
+
### Constructor Validates `PROJECTS` Address for Project-Based Ownership
|
|
36
|
+
|
|
37
|
+
In v6, the constructor reverts with `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` if `initialProjectIdOwner != 0` and `address(projects) == address(0)`. In v5, this combination was silently accepted, which would permanently break ownership resolution since `ownerOf()` calls on `address(0)` would always revert.
|
|
38
|
+
|
|
39
|
+
### `IJBOwnable.jbOwner()` Return Name Change
|
|
40
|
+
|
|
41
|
+
The second return value of `jbOwner()` was renamed from `projectOwner` (v5) to `projectId` (v6) in the interface. The type (`uint88`) and position are unchanged, so the ABI is identical, but code referencing the named return will need updating.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## 2. New Features
|
|
46
|
+
|
|
47
|
+
### Defensive `try-catch` on All `PROJECTS.ownerOf()` Calls
|
|
48
|
+
|
|
49
|
+
Every call to `PROJECTS.ownerOf()` in v6 is wrapped in a `try-catch` block. If the call reverts (e.g., burned NFT, broken ERC-721 implementation), the resolved owner falls back to `address(0)`. This applies to:
|
|
50
|
+
|
|
51
|
+
- `owner()` -- returns `address(0)` instead of reverting.
|
|
52
|
+
- `_checkOwner()` -- resolves to `address(0)`, causing a permissions revert.
|
|
53
|
+
- `_transferOwnership(address, uint88)` -- resolves old owner to `address(0)` for the transfer event.
|
|
54
|
+
|
|
55
|
+
### Project Existence Validation on `transferOwnershipToProject()`
|
|
56
|
+
|
|
57
|
+
`transferOwnershipToProject()` now calls `PROJECTS.count()` to verify the target project exists before transferring ownership, preventing accidental loss of contract control by transferring to a nonexistent project.
|
|
58
|
+
|
|
59
|
+
### Constructor Guard Against Zero-Address `PROJECTS` with Project Ownership
|
|
60
|
+
|
|
61
|
+
A new constructor check prevents deploying with `projects == address(0)` when `initialProjectIdOwner != 0`, which would make ownership irrecoverable.
|
|
62
|
+
|
|
63
|
+
### Comprehensive NatSpec Documentation on `IJBOwnable`
|
|
64
|
+
|
|
65
|
+
The v6 interface adds full NatSpec documentation for all events, functions, parameters, and return values. The v5 interface had no NatSpec comments at all.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## 3. Event Changes
|
|
70
|
+
|
|
71
|
+
No event signatures changed. Both versions define the same two events with identical parameters and indexing:
|
|
72
|
+
|
|
73
|
+
- `OwnershipTransferred(address indexed previousOwner, address indexed newOwner, address caller)`
|
|
74
|
+
- `PermissionIdChanged(uint8 newId, address caller)`
|
|
75
|
+
|
|
76
|
+
The only difference is the addition of NatSpec documentation on both events in v6's `IJBOwnable.sol`, and the declaration order was swapped (v5: `PermissionIdChanged` first, then `OwnershipTransferred`; v6: `OwnershipTransferred` first, then `PermissionIdChanged`).
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 4. Error Changes
|
|
81
|
+
|
|
82
|
+
### New Errors
|
|
83
|
+
|
|
84
|
+
| Error | Contract | Description |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `JBOwnableOverrides_ProjectDoesNotExist()` | `JBOwnableOverrides` | Reverts in `transferOwnershipToProject()` if `projectId > PROJECTS.count()`. |
|
|
87
|
+
| `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | `JBOwnableOverrides` | Reverts in constructor if `initialProjectIdOwner != 0` and `address(projects) == address(0)`. |
|
|
88
|
+
|
|
89
|
+
### Unchanged Errors
|
|
90
|
+
|
|
91
|
+
| Error | Status |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `JBOwnableOverrides_InvalidNewOwner()` | Unchanged -- still used for zero-address owner, zero project ID, and dual-set owner+project scenarios. |
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## 5. Struct Changes
|
|
98
|
+
|
|
99
|
+
### `JBOwner` (unchanged)
|
|
100
|
+
|
|
101
|
+
The struct itself is identical in both versions:
|
|
102
|
+
|
|
103
|
+
```solidity
|
|
104
|
+
struct JBOwner {
|
|
105
|
+
address owner;
|
|
106
|
+
uint88 projectId;
|
|
107
|
+
uint8 permissionId;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The only difference is the addition of a `// forge-lint: disable-next-line(pascal-case-struct)` comment in v6.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 6. Implementation Changes (Non-Interface)
|
|
116
|
+
|
|
117
|
+
### `JBOwnableOverrides._checkOwner()` -- Refactored Owner Resolution
|
|
118
|
+
|
|
119
|
+
**v5:**
|
|
120
|
+
```solidity
|
|
121
|
+
function _checkOwner() internal view virtual {
|
|
122
|
+
JBOwner memory ownerInfo = jbOwner;
|
|
123
|
+
_requirePermissionFrom({
|
|
124
|
+
account: ownerInfo.projectId == 0 ? ownerInfo.owner : PROJECTS.ownerOf(ownerInfo.projectId),
|
|
125
|
+
projectId: ownerInfo.projectId,
|
|
126
|
+
permissionId: ownerInfo.permissionId
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**v6:**
|
|
132
|
+
```solidity
|
|
133
|
+
function _checkOwner() internal view virtual {
|
|
134
|
+
JBOwner memory ownerInfo = jbOwner;
|
|
135
|
+
address resolvedOwner;
|
|
136
|
+
if (ownerInfo.projectId == 0) {
|
|
137
|
+
resolvedOwner = ownerInfo.owner;
|
|
138
|
+
} else {
|
|
139
|
+
try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
|
|
140
|
+
resolvedOwner = projectOwner;
|
|
141
|
+
} catch {
|
|
142
|
+
resolvedOwner = address(0);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
_requirePermissionFrom({
|
|
146
|
+
account: resolvedOwner, projectId: ownerInfo.projectId, permissionId: ownerInfo.permissionId
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The ternary expression is replaced with an explicit `if/else` block and `try-catch` for defensive error handling.
|
|
152
|
+
|
|
153
|
+
### `JBOwnableOverrides.owner()` -- Try-Catch on Project Lookup
|
|
154
|
+
|
|
155
|
+
**v5:**
|
|
156
|
+
```solidity
|
|
157
|
+
return PROJECTS.ownerOf(ownerInfo.projectId);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**v6:**
|
|
161
|
+
```solidity
|
|
162
|
+
try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
|
|
163
|
+
return projectOwner;
|
|
164
|
+
} catch {
|
|
165
|
+
return address(0);
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `JBOwnableOverrides._transferOwnership(address, uint88)` -- Try-Catch on Old Owner Lookup
|
|
170
|
+
|
|
171
|
+
**v5:**
|
|
172
|
+
```solidity
|
|
173
|
+
address oldOwner = ownerInfo.projectId == 0 ? ownerInfo.owner : PROJECTS.ownerOf(ownerInfo.projectId);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**v6:**
|
|
177
|
+
```solidity
|
|
178
|
+
address oldOwner;
|
|
179
|
+
if (ownerInfo.projectId == 0) {
|
|
180
|
+
oldOwner = ownerInfo.owner;
|
|
181
|
+
} else {
|
|
182
|
+
try PROJECTS.ownerOf(ownerInfo.projectId) returns (address projectOwner) {
|
|
183
|
+
oldOwner = projectOwner;
|
|
184
|
+
} catch {
|
|
185
|
+
oldOwner = address(0);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Named Arguments Used Throughout
|
|
191
|
+
|
|
192
|
+
All internal function calls in v6 use named arguments (e.g., `_transferOwnership({newOwner: initialOwner, projectId: initialProjectIdOwner})`) instead of positional arguments. This is a style-only change with no behavioral impact.
|
|
193
|
+
|
|
194
|
+
### NatSpec Improvements
|
|
195
|
+
|
|
196
|
+
- `JBOwnable._emitTransferEvent()`: Incomplete comment in v5 (`"some contracts will try to deploy contracts for a project before"`) is completed in v6 (`"some contracts need to deploy contracts for a project before the project's NFT has been minted, so the transfer event resolves the project's current owner at emission time."`).
|
|
197
|
+
- `JBOwnableOverrides._emitTransferEvent()`: Added `@param` tags for `previousOwner`, `newOwner`, and `newProjectId`.
|
|
198
|
+
- `renounceOwnership()`, `setPermissionId()`: Changed `@notice This can only be called by the current owner.` to `@dev`.
|
|
199
|
+
- `transferOwnership()`, `transferOwnershipToProject()`: Added documentation about `permissionId` being reset to 0 on transfer.
|
|
200
|
+
- Constructor `@param initialOwner`: Fixed typo `intialProjectIdOwner` to `initialProjectIdOwner`.
|
|
201
|
+
|
|
202
|
+
### Comment/Formatting Fixes
|
|
203
|
+
|
|
204
|
+
- Fixed malformed section header comment in v5 (`custom errors --------------------------//b`) to properly formatted (`custom errors ------------------------- //`).
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## 7. Migration Table
|
|
209
|
+
|
|
210
|
+
| v5 | v6 | Change Type |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| `pragma solidity ^0.8.23` | `pragma solidity 0.8.26` | Pinned compiler version |
|
|
213
|
+
| `@bananapus/core-v5` imports | `@bananapus/core-v6` imports | Dependency upgrade |
|
|
214
|
+
| `PROJECTS.ownerOf()` called directly | `try PROJECTS.ownerOf() catch` in `owner()`, `_checkOwner()`, `_transferOwnership()` | Defensive error handling |
|
|
215
|
+
| `transferOwnershipToProject()` -- no existence check | Reverts with `JBOwnableOverrides_ProjectDoesNotExist()` if `projectId > PROJECTS.count()` | New validation |
|
|
216
|
+
| Constructor allows `projects == address(0)` with `initialProjectIdOwner != 0` | Reverts with `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner()` | New validation |
|
|
217
|
+
| `IJBOwnable.jbOwner()` returns `(address owner, uint88 projectOwner, uint8 permissionId)` | Returns `(address owner, uint88 projectId, uint8 permissionId)` | Return name change (ABI compatible) |
|
|
218
|
+
| `IJBOwnable` -- no NatSpec | Full NatSpec on all events, functions, params, and returns | Documentation |
|
|
219
|
+
| Positional arguments in internal calls | Named arguments throughout | Style only |
|
|
220
|
+
| 1 custom error | 3 custom errors (`+ ProjectDoesNotExist`, `+ ZeroAddressProjectsWithProjectOwner`) | New errors |
|
|
221
|
+
| `JBOwner` struct | Identical (added forge-lint comment only) | No change |
|
package/RISKS.md
CHANGED
|
@@ -1,23 +1,21 @@
|
|
|
1
|
-
# nana-ownable-v6
|
|
1
|
+
# RISKS.md -- nana-ownable-v6
|
|
2
2
|
|
|
3
|
-
## Trust Assumptions
|
|
3
|
+
## 1. Trust Assumptions
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
- **JBPermissions.** Permission checks delegate to JBPermissions contract. A bug in JBPermissions affects all JBOwnable contracts.
|
|
6
|
+
- **JBProjects ERC-721.** When owned by a project, ownership follows the ERC-721 token. Whoever holds the project NFT has owner access.
|
|
7
|
+
- **Permission Delegation.** Anyone granted the configured `permissionId` via JBPermissions gets owner-equivalent access for the scoped function.
|
|
8
8
|
|
|
9
|
-
## Known Risks
|
|
9
|
+
## 2. Known Risks
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
| Zero address project | Setting `projectId = 0` with `owner = address(0)` permanently locks ownership | Validate before calling |
|
|
11
|
+
- **Project NFT transfer = ownership transfer.** If ownership is tied to a project ID, anyone who acquires the project NFT (via transfer, marketplace purchase, or social engineering) gains full owner access to all contracts using that JBOwnable instance. Project NFT holders must treat the NFT as a high-value key.
|
|
12
|
+
- **Permission ID reset on transfer.** `permissionId` resets to 0 on ownership transfer, which could temporarily lock out delegated operators. By design -- prevents permission clashes for new owners.
|
|
13
|
+
- **Burned/invalid project NFT.** If the project NFT is burned or `ownerOf` reverts, the contract is effectively renounced (owner resolves to `address(0)`). Defensive try-catch in `owner()` and `_checkOwner()`. JBProjects V6 has no burn function, so this is a defensive measure.
|
|
14
|
+
- **Dual ownership ambiguity.** Setting both `newOwner` and `projectId` to non-zero reverts, but the two-mode design could confuse integrators about which mode is active. `jbOwner()` exposes both fields for inspection.
|
|
15
|
+
- **`transferOwnershipToProject` with future project.** Checks `projectId > PROJECTS.count()` to prevent transferring to non-existent projects.
|
|
17
16
|
|
|
18
|
-
##
|
|
17
|
+
## 3. Invariants to Verify
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
| Permission delegates | `onlyOwner` functions via JBPermissions | Per-contract, per-permissionId |
|
|
19
|
+
- Ownership is always exactly one of: direct address OR project NFT holder (never both, never neither unless renounced).
|
|
20
|
+
- `_checkOwner()` reverts for all callers when the owner resolves to `address(0)`.
|
|
21
|
+
- `permissionId` is correctly reset to 0 on every ownership transfer.
|
package/USER_JOURNEYS.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# User Journeys -- nana-ownable-v6
|
|
2
|
+
|
|
3
|
+
Concrete end-to-end flows through the JBOwnable system. Each journey traces the exact function calls, state changes, and external interactions.
|
|
4
|
+
|
|
5
|
+
## Journey 1: Deploy a Project-Owned Contract
|
|
6
|
+
|
|
7
|
+
**Actor:** Protocol developer deploying a hook or extension that should be owned by a Juicebox project.
|
|
8
|
+
**Goal:** Create a contract where the project NFT holder has owner access.
|
|
9
|
+
|
|
10
|
+
### Precondition
|
|
11
|
+
|
|
12
|
+
A Juicebox project exists with ID `projectId`. The `JBProjects` and `JBPermissions` contracts are deployed.
|
|
13
|
+
|
|
14
|
+
### Steps
|
|
15
|
+
|
|
16
|
+
1. **Developer deploys a contract inheriting `JBOwnable`**
|
|
17
|
+
|
|
18
|
+
```solidity
|
|
19
|
+
new MyHook(permissions, projects, address(0), projectId)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- `initialOwner = address(0)` because ownership is project-based
|
|
23
|
+
- `initialProjectIdOwner = projectId`
|
|
24
|
+
|
|
25
|
+
2. **Constructor execution in `JBOwnableOverrides`**
|
|
26
|
+
|
|
27
|
+
- Stores `PROJECTS = projects` (immutable)
|
|
28
|
+
- Validates: `initialProjectIdOwner != 0` AND `address(projects) != address(0)` -- passes
|
|
29
|
+
- Validates: not both zero (passes because `initialProjectIdOwner != 0`)
|
|
30
|
+
- Calls `_transferOwnership(address(0), projectId)`:
|
|
31
|
+
- Sets `jbOwner = JBOwner({owner: address(0), projectId: projectId, permissionId: 0})`
|
|
32
|
+
- Calls `_emitTransferEvent(address(0), address(0), projectId)`
|
|
33
|
+
- In `JBOwnable._emitTransferEvent`: emits `OwnershipTransferred(address(0), PROJECTS.ownerOf(projectId), msg.sender)`
|
|
34
|
+
|
|
35
|
+
3. **Ownership is now live**
|
|
36
|
+
|
|
37
|
+
- `owner()` calls `PROJECTS.ownerOf(projectId)` and returns the current NFT holder
|
|
38
|
+
- `_checkOwner()` validates `msg.sender` against the NFT holder (or permission delegates)
|
|
39
|
+
|
|
40
|
+
### Result
|
|
41
|
+
|
|
42
|
+
The contract is owned by whichever address holds the project NFT. If the NFT is transferred, ownership automatically follows -- no on-chain update to the JBOwnable contract is needed.
|
|
43
|
+
|
|
44
|
+
### What to verify
|
|
45
|
+
|
|
46
|
+
- `jbOwner.owner == address(0)` and `jbOwner.projectId == projectId` after construction.
|
|
47
|
+
- `jbOwner.permissionId == 0` (no delegated access until explicitly configured).
|
|
48
|
+
- `owner()` returns the current NFT holder, not a cached value.
|
|
49
|
+
- If the project does not exist (ID > `PROJECTS.count()`), the constructor still succeeds -- the existence check is only enforced in `transferOwnershipToProject`, not the constructor. Verify whether this is safe (the deployer presumably knows the project exists).
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Journey 2: Transfer Ownership to a Different Address
|
|
54
|
+
|
|
55
|
+
**Actor:** Current owner (direct address or project NFT holder).
|
|
56
|
+
**Goal:** Transfer ownership from the current owner to a new direct address.
|
|
57
|
+
|
|
58
|
+
### Precondition
|
|
59
|
+
|
|
60
|
+
The caller is the current owner or has the configured `permissionId` (or ROOT) via `JBPermissions`.
|
|
61
|
+
|
|
62
|
+
### Steps
|
|
63
|
+
|
|
64
|
+
1. **Owner calls `transferOwnership(newOwner)`**
|
|
65
|
+
|
|
66
|
+
- `newOwner` must not be `address(0)` (reverts with `JBOwnableOverrides_InvalidNewOwner`)
|
|
67
|
+
|
|
68
|
+
2. **`_checkOwner()` validates the caller**
|
|
69
|
+
|
|
70
|
+
- Resolves the current owner (via `PROJECTS.ownerOf()` if project-owned, or `jbOwner.owner` if address-owned)
|
|
71
|
+
- Calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`
|
|
72
|
+
- Passes if `msg.sender == resolvedOwner` OR `msg.sender` has the required permission
|
|
73
|
+
|
|
74
|
+
3. **`_transferOwnership(newOwner, 0)` executes the transfer**
|
|
75
|
+
|
|
76
|
+
- Records `oldOwner` (resolved from current `jbOwner`)
|
|
77
|
+
- Overwrites `jbOwner = JBOwner({owner: newOwner, projectId: 0, permissionId: 0})`
|
|
78
|
+
- Calls `_emitTransferEvent(oldOwner, newOwner, 0)`
|
|
79
|
+
|
|
80
|
+
4. **`_emitTransferEvent` in `JBOwnable`**
|
|
81
|
+
|
|
82
|
+
- Since `newProjectId == 0`: emits `OwnershipTransferred(oldOwner, newOwner, msg.sender)`
|
|
83
|
+
|
|
84
|
+
### Result
|
|
85
|
+
|
|
86
|
+
`jbOwner.owner == newOwner`, `jbOwner.projectId == 0`, `jbOwner.permissionId == 0`. The new owner must call `setPermissionId()` to re-enable delegated access.
|
|
87
|
+
|
|
88
|
+
### What to verify
|
|
89
|
+
|
|
90
|
+
- If the contract was previously project-owned, `projectId` is now 0 (project ownership is cleared).
|
|
91
|
+
- `permissionId` is reset to 0, revoking all previously delegated permissions.
|
|
92
|
+
- The previous owner (or their delegates) can no longer call `onlyOwner` functions.
|
|
93
|
+
- `newOwner` can immediately call `onlyOwner` functions without any additional setup.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Journey 3: Transfer Ownership to a Juicebox Project
|
|
98
|
+
|
|
99
|
+
**Actor:** Current owner (direct address or project NFT holder).
|
|
100
|
+
**Goal:** Transfer ownership from the current owner to a Juicebox project, so the NFT holder becomes the new owner.
|
|
101
|
+
|
|
102
|
+
### Precondition
|
|
103
|
+
|
|
104
|
+
The target project exists (ID <= `PROJECTS.count()`). The caller is the current owner or has adequate permissions.
|
|
105
|
+
|
|
106
|
+
### Steps
|
|
107
|
+
|
|
108
|
+
1. **Owner calls `transferOwnershipToProject(projectId)`**
|
|
109
|
+
|
|
110
|
+
- Validates: `projectId != 0` (reverts with `JBOwnableOverrides_InvalidNewOwner`)
|
|
111
|
+
- Validates: `projectId <= type(uint88).max` (reverts with `JBOwnableOverrides_InvalidNewOwner`)
|
|
112
|
+
- Validates: `projectId <= PROJECTS.count()` (reverts with `JBOwnableOverrides_ProjectDoesNotExist`)
|
|
113
|
+
|
|
114
|
+
2. **`_checkOwner()` validates the caller** (same as Journey 2, Step 2)
|
|
115
|
+
|
|
116
|
+
3. **`_transferOwnership(address(0), uint88(projectId))` executes the transfer**
|
|
117
|
+
|
|
118
|
+
- Records `oldOwner` (resolved from current `jbOwner`)
|
|
119
|
+
- Overwrites `jbOwner = JBOwner({owner: address(0), projectId: uint88(projectId), permissionId: 0})`
|
|
120
|
+
- Calls `_emitTransferEvent(oldOwner, address(0), uint88(projectId))`
|
|
121
|
+
|
|
122
|
+
4. **`_emitTransferEvent` in `JBOwnable`**
|
|
123
|
+
|
|
124
|
+
- Since `newProjectId != 0`: emits `OwnershipTransferred(oldOwner, PROJECTS.ownerOf(projectId), msg.sender)`
|
|
125
|
+
|
|
126
|
+
### Result
|
|
127
|
+
|
|
128
|
+
`jbOwner.owner == address(0)`, `jbOwner.projectId == projectId`, `jbOwner.permissionId == 0`. The project NFT holder is now the owner. Ownership dynamically follows NFT transfers.
|
|
129
|
+
|
|
130
|
+
### What to verify
|
|
131
|
+
|
|
132
|
+
- The project existence check (`projectId <= PROJECTS.count()`) prevents transferring to a nonexistent project.
|
|
133
|
+
- The `uint88` cast does not truncate (the preceding `type(uint88).max` check ensures this).
|
|
134
|
+
- If the project NFT is subsequently burned (hypothetically), `owner()` returns `address(0)` and the contract is effectively renounced.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Journey 4: Delegate Access via Permission ID
|
|
139
|
+
|
|
140
|
+
**Actor:** Current owner.
|
|
141
|
+
**Goal:** Allow additional addresses to call `onlyOwner` functions through the JBPermissions system.
|
|
142
|
+
|
|
143
|
+
### Precondition
|
|
144
|
+
|
|
145
|
+
The contract has an owner. The owner wants to delegate access to one or more operators.
|
|
146
|
+
|
|
147
|
+
### Steps
|
|
148
|
+
|
|
149
|
+
1. **Owner calls `setPermissionId(permissionId)`**
|
|
150
|
+
|
|
151
|
+
- `_checkOwner()` validates the caller
|
|
152
|
+
- `_setPermissionId(permissionId)` writes `jbOwner.permissionId = permissionId`
|
|
153
|
+
- Emits `PermissionIdChanged(permissionId, msg.sender)`
|
|
154
|
+
|
|
155
|
+
2. **Owner grants the permission to operators via JBPermissions**
|
|
156
|
+
|
|
157
|
+
- `permissions.setPermissionsFor(account, JBPermissionsData({operator: operatorAddress, projectId: projectId, permissionIds: [permissionId]}))`
|
|
158
|
+
- This is an external call on the JBPermissions contract, not on the JBOwnable contract
|
|
159
|
+
|
|
160
|
+
3. **Operator calls an `onlyOwner` function**
|
|
161
|
+
|
|
162
|
+
- `_checkOwner()` resolves the owner and calls `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`
|
|
163
|
+
- `JBPermissioned._requirePermissionFrom` checks `JBPermissions.hasPermission(msg.sender, resolvedOwner, projectId, permissionId)` -- passes
|
|
164
|
+
|
|
165
|
+
### Result
|
|
166
|
+
|
|
167
|
+
The operator can call any function protected by `onlyOwner` on this contract. The permission is scoped to the owner's account and project ID.
|
|
168
|
+
|
|
169
|
+
### What to verify
|
|
170
|
+
|
|
171
|
+
- `permissionId == 0` effectively disables delegation (permission ID 0 cannot be set in `JBPermissions`). Only the owner (or ROOT holders) can call `onlyOwner` functions.
|
|
172
|
+
- If the owner transfers ownership, `permissionId` resets to 0. The new owner must re-configure delegation.
|
|
173
|
+
- ROOT (permission ID 1) always grants access regardless of the configured `permissionId`. This is a feature of `JBPermissioned`, not specific to `JBOwnable`.
|
|
174
|
+
- The operator's access is not stored on the JBOwnable contract -- it lives in JBPermissions. Changing the `permissionId` on JBOwnable instantly changes which JBPermissions grants are recognized.
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Journey 5: Renounce Ownership
|
|
179
|
+
|
|
180
|
+
**Actor:** Current owner.
|
|
181
|
+
**Goal:** Permanently give up ownership, making `onlyOwner` functions uncallable.
|
|
182
|
+
|
|
183
|
+
### Precondition
|
|
184
|
+
|
|
185
|
+
The caller is the current owner and understands this action is irreversible.
|
|
186
|
+
|
|
187
|
+
### Steps
|
|
188
|
+
|
|
189
|
+
1. **Owner calls `renounceOwnership()`**
|
|
190
|
+
|
|
191
|
+
- `_checkOwner()` validates the caller
|
|
192
|
+
|
|
193
|
+
2. **`_transferOwnership(address(0), 0)` executes**
|
|
194
|
+
|
|
195
|
+
- Records `oldOwner` (resolved from current `jbOwner`)
|
|
196
|
+
- Overwrites `jbOwner = JBOwner({owner: address(0), projectId: 0, permissionId: 0})`
|
|
197
|
+
- Calls `_emitTransferEvent(oldOwner, address(0), 0)`
|
|
198
|
+
- Emits `OwnershipTransferred(oldOwner, address(0), msg.sender)`
|
|
199
|
+
|
|
200
|
+
### Result
|
|
201
|
+
|
|
202
|
+
`jbOwner` is zeroed out. `owner()` returns `address(0)`. All future calls to `_checkOwner()` revert because `_requirePermissionFrom(address(0), 0, 0)` fails for any `msg.sender` (no address equals `address(0)`, and no permission can satisfy the check against a zero-address account).
|
|
203
|
+
|
|
204
|
+
### What to verify
|
|
205
|
+
|
|
206
|
+
- After renouncing, `transferOwnership`, `transferOwnershipToProject`, `setPermissionId`, and `renounceOwnership` all revert.
|
|
207
|
+
- There is no recovery mechanism. No admin backdoor. No timelock. Renouncement is permanent.
|
|
208
|
+
- A second call to `renounceOwnership()` also reverts (because `_checkOwner()` fails).
|
|
209
|
+
- Even ROOT holders cannot act as owner after renouncement, because `_requirePermissionFrom(address(0), 0, 0)` does not recognize ROOT as a valid bypass when the account is `address(0)`.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Journey 6: Implicit Renouncement via Project NFT Burn
|
|
214
|
+
|
|
215
|
+
**Actor:** None (system behavior).
|
|
216
|
+
**Goal:** Understand what happens when the project NFT underlying a project-owned contract ceases to exist.
|
|
217
|
+
|
|
218
|
+
### Precondition
|
|
219
|
+
|
|
220
|
+
The contract is project-owned (`jbOwner.projectId != 0`). The project NFT is burned or otherwise invalidated (note: JBProjects V6 has no burn function, so this is a defensive scenario).
|
|
221
|
+
|
|
222
|
+
### Steps
|
|
223
|
+
|
|
224
|
+
1. **`PROJECTS.ownerOf(projectId)` starts reverting**
|
|
225
|
+
|
|
226
|
+
- The ERC-721 `ownerOf` function reverts for burned tokens
|
|
227
|
+
|
|
228
|
+
2. **`owner()` catches the revert and returns `address(0)`**
|
|
229
|
+
|
|
230
|
+
- The try-catch in `owner()` returns `address(0)` when `ownerOf` reverts
|
|
231
|
+
|
|
232
|
+
3. **`_checkOwner()` catches the revert and resolves owner to `address(0)`**
|
|
233
|
+
|
|
234
|
+
- `_requirePermissionFrom(address(0), projectId, permissionId)` is called
|
|
235
|
+
- No `msg.sender` can equal `address(0)`, so the check always fails
|
|
236
|
+
|
|
237
|
+
### Result
|
|
238
|
+
|
|
239
|
+
The contract is effectively renounced without anyone calling `renounceOwnership()`. All `onlyOwner` functions permanently revert. The `jbOwner` struct still contains the old `projectId`, but it has no practical effect.
|
|
240
|
+
|
|
241
|
+
### What to verify
|
|
242
|
+
|
|
243
|
+
- There is no way to "revive" ownership after the NFT is burned. Even re-minting an NFT with the same ID (if possible) would restore ownership.
|
|
244
|
+
- The `jbOwner` struct is NOT cleared in this scenario -- it still shows the old `projectId`. Only the resolved owner is `address(0)`.
|
|
245
|
+
- This behavior is consistent between `owner()` and `_checkOwner()` (both use the same try-catch pattern).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/ownable-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
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.17",
|
|
14
|
+
"@bananapus/permission-ids-v6": "^0.0.10",
|
|
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
|
|
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";
|
|
@@ -58,6 +58,9 @@ contract JBOwnable is JBOwnableOverrides {
|
|
|
58
58
|
/// @notice Either `newOwner` or `newProjectId` is non-zero or both are zero. But they can never both be non-zero.
|
|
59
59
|
/// @dev This function exists because some contracts need to deploy contracts for a project before the project's NFT
|
|
60
60
|
/// has been minted, so the transfer event resolves the project's current owner at emission time.
|
|
61
|
+
/// @dev Unlike `_transferOwnership` (which uses try-catch to resolve the *old* owner in case its project NFT was
|
|
62
|
+
/// burned), this function intentionally lets `PROJECTS.ownerOf(newProjectId)` revert if the new project doesn't
|
|
63
|
+
/// exist. A revert here is desirable — it prevents transferring ownership to a non-existent project.
|
|
61
64
|
function _emitTransferEvent(
|
|
62
65
|
address previousOwner,
|
|
63
66
|
address newOwner,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// Juicebox variation on OpenZeppelin Ownable
|
|
3
|
-
pragma solidity
|
|
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";
|
|
@@ -75,6 +75,10 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
|
|
|
75
75
|
revert JBOwnableOverrides_InvalidNewOwner();
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// No explicit project existence check here — if `initialProjectIdOwner` refers to an unminted project,
|
|
79
|
+
// `owner()` will resolve via `PROJECTS.ownerOf()`, which reverts for non-existent tokens. The try-catch
|
|
80
|
+
// in `owner()` treats this as renounced (returns address(0)), effectively locking the contract until
|
|
81
|
+
// the project is minted. This is acceptable because deployers control the constructor arguments.
|
|
78
82
|
_transferOwnership({newOwner: initialOwner, projectId: initialProjectIdOwner});
|
|
79
83
|
}
|
|
80
84
|
|
|
@@ -238,8 +238,8 @@ contract OwnableEdgeCases is Test {
|
|
|
238
238
|
permIds[0] = 42;
|
|
239
239
|
vm.prank(alice);
|
|
240
240
|
permissions.setPermissionsFor(
|
|
241
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
242
241
|
alice,
|
|
242
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
243
243
|
JBPermissionsData({operator: charlie, projectId: uint56(projectId), permissionIds: permIds})
|
|
244
244
|
);
|
|
245
245
|
|