@bananapus/ownable-v6 0.0.17 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ADMINISTRATION.md +55 -71
- package/ARCHITECTURE.md +75 -48
- package/AUDIT_INSTRUCTIONS.md +46 -165
- package/CHANGELOG.md +39 -0
- package/README.md +72 -92
- package/RISKS.md +25 -1
- package/SKILLS.md +28 -172
- package/STYLE_GUIDE.md +58 -20
- package/USER_JOURNEYS.md +81 -162
- package/foundry.toml +2 -0
- package/package.json +3 -3
- package/references/operations.md +14 -0
- package/references/runtime.md +19 -0
- package/src/JBOwnableOverrides.sol +4 -0
- package/src/structs/JBOwner.sol +0 -1
- package/test/CodexUnmintedProjectHijack.t.sol +45 -0
- package/CHANGE_LOG.md +0 -235
package/ADMINISTRATION.md
CHANGED
|
@@ -1,95 +1,79 @@
|
|
|
1
1
|
# Administration
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## At A Glance
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
|
8
|
-
|
|
9
|
-
|
|
|
10
|
-
|
|
|
11
|
-
| **Permission Delegate** | Any address granted `JBOwner.permissionId` through `JBPermissions` | The owner (direct or project) calls `JBPermissions.setPermissionsFor(...)` to grant `permissionId` to an operator. That operator then passes the `_checkOwner()` / `_requirePermissionFrom()` check. |
|
|
12
|
-
|
|
13
|
-
Only one of Direct Owner or Project Owner is active at a time, never both. Permission Delegates extend whichever mode is active.
|
|
14
|
-
|
|
15
|
-
## Privileged Functions
|
|
5
|
+
| Item | Details |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Scope | Ownership resolution primitive used by downstream repos |
|
|
8
|
+
| Control posture | Primitive only; control depends on the inheriting contract |
|
|
9
|
+
| Highest-risk actions | Transferring ownership to the wrong address or project and assuming delegated operators survive transfer |
|
|
10
|
+
| Recovery posture | Recovery depends on the inheriting contract and the still-recognized current owner |
|
|
16
11
|
|
|
17
|
-
|
|
12
|
+
## Purpose
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|----------|--------------|---------------|-------|--------------|
|
|
21
|
-
| `renounceOwnership()` | Owner or delegate | `jbOwner.permissionId` | Per-contract | Sets `owner` to `address(0)` and `projectId` to `0`. Permanently disables all `onlyOwner`-guarded functions. Irreversible. |
|
|
22
|
-
| `setPermissionId(uint8)` | Owner or delegate | `jbOwner.permissionId` | Per-contract | Changes which permission ID grants owner-equivalent access via `JBPermissions`. Resets the delegation surface -- previous delegates with the old ID lose access. |
|
|
23
|
-
| `transferOwnership(address)` | Owner or delegate | `jbOwner.permissionId` | Per-contract | Transfers ownership to a new address. Resets `projectId` to `0` and `permissionId` to `0`. The new owner must call `setPermissionId()` to re-enable delegation. |
|
|
24
|
-
| `transferOwnershipToProject(uint256)` | Owner or delegate | `jbOwner.permissionId` | Per-contract | Transfers ownership to a Juicebox project. Resets `owner` to `address(0)` and `permissionId` to `0`. Validates that the project exists (`projectId <= PROJECTS.count()`). |
|
|
14
|
+
`nana-ownable-v6` does not introduce a new admin surface by itself. It defines how ownership is resolved for other repos. The important control question is how a contract's `owner()` is determined and how delegated permission IDs behave across ownership transfers.
|
|
25
15
|
|
|
26
|
-
|
|
16
|
+
## Control Model
|
|
27
17
|
|
|
28
|
-
|
|
18
|
+
- Ownership can be address-based or project-based.
|
|
19
|
+
- Delegated operator checks run through `JBPermissions`.
|
|
20
|
+
- Transfer and renounce semantics are part of the primitive.
|
|
21
|
+
- Permission delegation resets on ownership transfer.
|
|
29
22
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
## Ownership Model
|
|
23
|
+
## Roles
|
|
33
24
|
|
|
34
|
-
|
|
25
|
+
| Role | How Assigned | Scope | Notes |
|
|
26
|
+
| --- | --- | --- | --- |
|
|
27
|
+
| Direct owner | Stored owner address | Per contract | Standard `Ownable`-like control |
|
|
28
|
+
| Project owner | Holder of the referenced project NFT | Per contract | Dynamic ownership resolution |
|
|
29
|
+
| Delegated operator | `JBPermissions` grant with the configured permission ID | Per contract and project | Only if the inheriting contract enables it |
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
JBOwner {
|
|
38
|
-
address owner; // Direct owner address (when projectId == 0)
|
|
39
|
-
uint88 projectId; // JB project whose NFT holder is owner (when != 0)
|
|
40
|
-
uint8 permissionId; // Permission ID for delegation via JBPermissions
|
|
41
|
-
}
|
|
42
|
-
```
|
|
31
|
+
## Privileged Surfaces
|
|
43
32
|
|
|
44
|
-
|
|
33
|
+
The meaningful control surfaces are inherited by downstream contracts:
|
|
45
34
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
35
|
+
- `setPermissionId(...)`
|
|
36
|
+
- `transferOwnership(...)`
|
|
37
|
+
- `transferOwnershipToProject(...)`
|
|
38
|
+
- `renounceOwnership()`
|
|
39
|
+
- `onlyOwner` checks that resolve either the direct owner or the current project NFT holder
|
|
49
40
|
|
|
50
|
-
|
|
41
|
+
## Immutable And One-Way
|
|
51
42
|
|
|
52
|
-
|
|
43
|
+
- Project ownership changes dynamically with project NFT transfers.
|
|
44
|
+
- Delegated permission ID resets on ownership transfer.
|
|
45
|
+
- Renouncing ownership is final unless the inheriting contract adds a separate recovery path.
|
|
53
46
|
|
|
54
|
-
##
|
|
47
|
+
## Operational Notes
|
|
55
48
|
|
|
56
|
-
|
|
49
|
+
- Treat project-based ownership as live routing, not a snapshot.
|
|
50
|
+
- Do not assume an operator permission survives ownership transfer.
|
|
51
|
+
- Treat `setPermissionId(...)` as a real authority change because it rewires which delegated permission bit counts as owner access.
|
|
52
|
+
- Review the inheriting contract, not just this primitive, to understand the full admin surface.
|
|
57
53
|
|
|
58
|
-
|
|
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
|
-
```
|
|
54
|
+
## Machine Notes
|
|
65
55
|
|
|
66
|
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
56
|
+
- Do not conclude authority from this repo alone; follow the inheriting contract's `onlyOwner` surfaces.
|
|
57
|
+
- Treat ownership transfer as potentially changing both the owner identity and the usable delegated permission ID.
|
|
58
|
+
- If the current permission ID is undocumented, inspect `jbOwner.permissionId` before reasoning about delegated owner access.
|
|
59
|
+
- If a downstream repo uses project-based ownership, re-evaluate owner resolution after every project NFT transfer.
|
|
70
60
|
|
|
71
|
-
|
|
61
|
+
## Recovery
|
|
72
62
|
|
|
73
|
-
|
|
63
|
+
- This primitive has no protocol-wide recovery surface.
|
|
64
|
+
- If ownership was transferred to the wrong project or address, recovery depends on the inheriting contract still recognizing the current owner.
|
|
74
65
|
|
|
75
|
-
|
|
76
|
-
|----------|--------|-------------|
|
|
77
|
-
| `PERMISSIONS` (IJBPermissions) | Construction (via `JBPermissioned`) | No -- immutable |
|
|
78
|
-
| `PROJECTS` (IJBProjects) | Construction | No -- immutable |
|
|
66
|
+
## Admin Boundaries
|
|
79
67
|
|
|
80
|
-
|
|
68
|
+
- This repo does not create a new permission namespace.
|
|
69
|
+
- It cannot make an inheriting contract safer than that contract's own privileged functions.
|
|
70
|
+
- It cannot preserve delegated operators across ownership transfer by default.
|
|
81
71
|
|
|
82
|
-
##
|
|
72
|
+
## Source Map
|
|
83
73
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
88
|
-
-
|
|
89
|
-
-
|
|
90
|
-
- **Transfer to `projectId` 0 via `transferOwnershipToProject()`.** Reverts with `JBOwnableOverrides_InvalidNewOwner`.
|
|
91
|
-
- **Transfer to `projectId` exceeding `uint88`.** Reverts with `JBOwnableOverrides_InvalidNewOwner`.
|
|
92
|
-
- **Undo `renounceOwnership()`.** Once ownership is renounced, all `onlyOwner` functions are permanently disabled. There is no recovery mechanism.
|
|
93
|
-
- **Bypass `JBPermissions` for delegation.** Permission delegation is exclusively handled through the external `JBPermissions` contract; `JBOwnable` itself has no operator registry.
|
|
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.
|
|
74
|
+
- `src/JBOwnable.sol`
|
|
75
|
+
- `src/JBOwnableOverrides.sol`
|
|
76
|
+
- `src/structs/JBOwner.sol`
|
|
77
|
+
- `test/OwnableInvariantTests.sol`
|
|
78
|
+
- `test/OwnableEdgeCases.t.sol`
|
|
79
|
+
- `test/regression/BurnLockProtection.t.sol`
|
package/ARCHITECTURE.md
CHANGED
|
@@ -1,63 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Architecture
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`nana-ownable-v6` adapts `Ownable` to the Juicebox model. A contract can be owned by an address or by a Juicebox project NFT, and delegated operators can satisfy `onlyOwner` through `JBPermissions`.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## System Overview
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
src/
|
|
11
|
-
├── JBOwnable.sol — Concrete ownable with constructor
|
|
12
|
-
├── JBOwnableOverrides.sol — Abstract base with onlyOwner modifier logic
|
|
13
|
-
├── interfaces/
|
|
14
|
-
│ └── IJBOwnable.sol — Interface for ownership queries and transfers
|
|
15
|
-
└── structs/
|
|
16
|
-
└── JBOwner.sol — Owner struct: {owner, projectId, permissionId}
|
|
17
|
-
```
|
|
9
|
+
The repo is an ownership primitive, not a policy layer. `JBOwnable` exposes a familiar inheritance surface. `JBOwnableOverrides` implements dynamic owner resolution, ownership transfer, renounce behavior, and delegated permission checks. Ownership can follow the current holder of a Juicebox project NFT instead of being fixed to an address.
|
|
18
10
|
|
|
19
|
-
##
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
JBOwner {
|
|
23
|
-
address owner; — Direct owner address (if projectId == 0)
|
|
24
|
-
uint88 projectId; — JB project ID whose NFT holder is owner (if != 0)
|
|
25
|
-
uint8 permissionId; — Permission ID that grants owner access via JBPermissions
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
Resolution order:
|
|
29
|
-
1. If projectId != 0 → owner = JBProjects.ownerOf(projectId)
|
|
30
|
-
2. If projectId == 0 → owner = JBOwner.owner address
|
|
31
|
-
3. Additional access via JBPermissions.hasPermission(operator, owner, projectId, permissionId)
|
|
32
|
-
```
|
|
11
|
+
## Core Invariants
|
|
33
12
|
|
|
34
|
-
|
|
13
|
+
- Project-owned contracts must resolve the owner dynamically from the current project NFT holder.
|
|
14
|
+
- The delegated permission ID resets on ownership transfer.
|
|
15
|
+
- Pointing ownership at an unminted project can temporarily lock the contract until that project exists.
|
|
16
|
+
- A burned or otherwise unresolvable project NFT effectively renounces ownership.
|
|
17
|
+
- This repo should stay a drop-in primitive, not grow product-specific access rules.
|
|
35
18
|
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
Current owner → transferOwnership(newOwner)
|
|
39
|
-
→ Can transfer to address or project ID
|
|
40
|
-
→ Emits OwnershipTransferred
|
|
19
|
+
## Modules
|
|
41
20
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
21
|
+
| Module | Responsibility | Notes |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| `JBOwnable` | Familiar `onlyOwner` inheritance target | Concrete surface |
|
|
24
|
+
| `JBOwnableOverrides` | Resolution, transfer, renounce, and delegated-permission logic | Core behavior |
|
|
25
|
+
| `JBOwner` | Packed owner state | Shared struct |
|
|
26
|
+
| `IJBOwnable` | Public interface and events | Integration surface |
|
|
46
27
|
|
|
47
|
-
##
|
|
28
|
+
## Trust Boundaries
|
|
48
29
|
|
|
49
|
-
|
|
50
|
-
|
|
30
|
+
- Ownership resolution depends on `JBProjects` and `JBPermissions` from `nana-core-v6`.
|
|
31
|
+
- This repo does not create a new permission namespace.
|
|
32
|
+
- Contracts that inherit from it may still add policy on top, but the resolution semantics here are infrastructure-level.
|
|
51
33
|
|
|
52
|
-
|
|
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.
|
|
34
|
+
## Critical Flows
|
|
54
35
|
|
|
55
|
-
###
|
|
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.
|
|
36
|
+
### Owner Check
|
|
57
37
|
|
|
58
|
-
|
|
59
|
-
|
|
38
|
+
```text
|
|
39
|
+
onlyOwner modifier
|
|
40
|
+
-> load packed owner state
|
|
41
|
+
-> if project-owned, resolve the current project NFT holder
|
|
42
|
+
-> otherwise use the stored owner address
|
|
43
|
+
-> accept either the resolved owner or an operator with the configured JB permission
|
|
44
|
+
```
|
|
60
45
|
|
|
61
|
-
##
|
|
62
|
-
|
|
63
|
-
|
|
46
|
+
## Accounting Model
|
|
47
|
+
|
|
48
|
+
No treasury accounting lives here. The critical state is ownership resolution data and delegated permission ID.
|
|
49
|
+
|
|
50
|
+
## Security Model
|
|
51
|
+
|
|
52
|
+
- Ownership resolution edge cases are more important than surface API shape.
|
|
53
|
+
- Permission delegation is simple conceptually but security-sensitive because it composes with a global permission registry.
|
|
54
|
+
- Unresolvable project ownership is intentionally fail-closed. If `PROJECTS.ownerOf()` cannot resolve, `onlyOwner` should stop working rather than inventing fallback authority.
|
|
55
|
+
|
|
56
|
+
## Safe Change Guide
|
|
57
|
+
|
|
58
|
+
- Be conservative with transfer and renounce semantics.
|
|
59
|
+
- If event emission or transfer behavior changes, inspect deployer wrappers and inheriting repos.
|
|
60
|
+
- If project-based ownership semantics change, re-check unminted-project and unresolvable-project behavior explicitly.
|
|
61
|
+
- Do not make delegated permission IDs sticky across ownership transfers.
|
|
62
|
+
|
|
63
|
+
## Canonical Checks
|
|
64
|
+
|
|
65
|
+
- baseline address-owner and project-owner behavior:
|
|
66
|
+
`test/Ownable.t.sol`
|
|
67
|
+
- transfer, renounce, and hostile-call edge cases:
|
|
68
|
+
`test/OwnableEdgeCases.t.sol`
|
|
69
|
+
`test/OwnableAttacks.t.sol`
|
|
70
|
+
- unminted-project and burn-lock safety:
|
|
71
|
+
`test/CodexUnmintedProjectHijack.t.sol`
|
|
72
|
+
`test/regression/BurnLockProtection.t.sol`
|
|
73
|
+
- ownership-state invariants:
|
|
74
|
+
`test/OwnableInvariantTests.sol`
|
|
75
|
+
|
|
76
|
+
## Source Map
|
|
77
|
+
|
|
78
|
+
- `src/JBOwnable.sol`
|
|
79
|
+
- `src/JBOwnableOverrides.sol`
|
|
80
|
+
- `src/structs/JBOwner.sol`
|
|
81
|
+
- `src/interfaces/IJBOwnable.sol`
|
|
82
|
+
- `test/Ownable.t.sol`
|
|
83
|
+
- `test/OwnableEdgeCases.t.sol`
|
|
84
|
+
- `test/OwnableAttacks.t.sol`
|
|
85
|
+
- `test/CodexUnmintedProjectHijack.t.sol`
|
|
86
|
+
- `test/regression/BurnLockProtection.t.sol`
|
|
87
|
+
- `test/regression/ZeroAddressValidation.t.sol`
|
|
88
|
+
- `test/OwnableInvariantTests.sol`
|
|
89
|
+
- `references/runtime.md`
|
|
90
|
+
- `references/operations.md`
|
package/AUDIT_INSTRUCTIONS.md
CHANGED
|
@@ -1,187 +1,68 @@
|
|
|
1
|
-
# Audit Instructions
|
|
1
|
+
# Audit Instructions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This repo provides ownership helpers that can follow Juicebox project NFTs instead of a fixed EOA. It is a small repo with disproportionate privilege impact.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Audit Objective
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
| via-IR | not enabled |
|
|
13
|
-
| Fuzz runs | 4,096 |
|
|
14
|
-
| Invariant runs | 1,024 (depth 100) |
|
|
15
|
-
|
|
16
|
-
Source: [`foundry.toml`](./foundry.toml)
|
|
7
|
+
Find issues that:
|
|
8
|
+
- let unauthorized actors satisfy owner checks
|
|
9
|
+
- break ownership updates when a project NFT moves, burns, or locks
|
|
10
|
+
- let override logic produce a different owner than the project system intends
|
|
11
|
+
- leave dependent repos with stale or permanently wrong ownership views
|
|
17
12
|
|
|
18
13
|
## Scope
|
|
19
14
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
src/
|
|
23
|
-
src/
|
|
24
|
-
src/structs
|
|
25
|
-
src/interfaces/IJBOwnable.sol # Interface (~47 lines)
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
**Out of scope:** Test files, JB Core contracts (`JBPermissioned`, `IJBPermissions`, `IJBProjects`), OpenZeppelin contracts, forge-std. Assume these dependencies are correct.
|
|
29
|
-
|
|
30
|
-
## Architecture
|
|
31
|
-
|
|
32
|
-
### Ownership Model
|
|
33
|
-
|
|
34
|
-
The `JBOwner` struct packs into a single 256-bit storage slot:
|
|
35
|
-
|
|
36
|
-
```solidity
|
|
37
|
-
struct JBOwner {
|
|
38
|
-
address owner; // 160 bits -- direct owner (used when projectId == 0)
|
|
39
|
-
uint88 projectId; // 88 bits -- JB project whose NFT holder is owner (used when != 0)
|
|
40
|
-
uint8 permissionId; // 8 bits -- JBPermissions ID for delegated access
|
|
41
|
-
}
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
**Resolution rules:**
|
|
45
|
-
1. If `projectId != 0`: owner = `PROJECTS.ownerOf(projectId)` (external call, try-catch wrapped)
|
|
46
|
-
2. If `projectId == 0`: owner = `jbOwner.owner` (storage read)
|
|
47
|
-
3. If the `ownerOf` call reverts (e.g., burned NFT): owner resolves to `address(0)`, effectively renouncing the contract
|
|
48
|
-
|
|
49
|
-
**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`.
|
|
50
|
-
|
|
51
|
-
### Inheritance Chain
|
|
52
|
-
|
|
53
|
-
```
|
|
54
|
-
JBOwnable (concrete)
|
|
55
|
-
extends JBOwnableOverrides (abstract)
|
|
56
|
-
extends Context (OpenZeppelin)
|
|
57
|
-
extends JBPermissioned (nana-core)
|
|
58
|
-
implements IJBOwnable (interface)
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
`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.
|
|
62
|
-
|
|
63
|
-
### Key Functions
|
|
64
|
-
|
|
65
|
-
| Function | Access | What it does |
|
|
66
|
-
|----------|--------|--------------|
|
|
67
|
-
| `owner()` | `public view` | Resolves and returns the current owner address. Makes an external call to `PROJECTS.ownerOf()` when project-owned. |
|
|
68
|
-
| `transferOwnership(address newOwner)` | `onlyOwner` | Transfers ownership to a direct address. Reverts on `address(0)`. Resets `permissionId` to 0. |
|
|
69
|
-
| `transferOwnershipToProject(uint256 projectId)` | `onlyOwner` | Transfers ownership to a JB project. Validates: non-zero, fits `uint88`, project exists (`<= PROJECTS.count()`). Resets `permissionId` to 0. |
|
|
70
|
-
| `renounceOwnership()` | `onlyOwner` | Sets owner to `address(0)` and projectId to 0. Irreversible. |
|
|
71
|
-
| `setPermissionId(uint8 permissionId)` | `onlyOwner` | Sets which JBPermissions ID grants delegated owner access. |
|
|
72
|
-
| `_checkOwner()` | `internal view` | Resolves owner, then calls `_requirePermissionFrom`. Used by `onlyOwner` modifier. |
|
|
73
|
-
| `_transferOwnership(address, uint88)` | `internal` | Core transfer logic. Updates `jbOwner`, resets `permissionId`, calls `_emitTransferEvent`. No access restriction. |
|
|
74
|
-
|
|
75
|
-
## Priority Audit Areas
|
|
76
|
-
|
|
77
|
-
### 1. Ownership Resolution Correctness (Highest Priority)
|
|
78
|
-
|
|
79
|
-
The `owner()` and `_checkOwner()` functions both resolve ownership via the same pattern but are separate implementations (not shared helper). Verify:
|
|
80
|
-
|
|
81
|
-
- **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).
|
|
82
|
-
- **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.)
|
|
83
|
-
- **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)`.
|
|
84
|
-
|
|
85
|
-
### 2. Ownership Transfer State Machine
|
|
86
|
-
|
|
87
|
-
Ownership transitions must be airtight:
|
|
88
|
-
|
|
89
|
-
- **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.
|
|
90
|
-
- **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`.
|
|
91
|
-
- **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.
|
|
92
|
-
- **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.
|
|
93
|
-
|
|
94
|
-
### 3. Permission Delegation Security
|
|
95
|
-
|
|
96
|
-
`_checkOwner()` delegates to `_requirePermissionFrom(resolvedOwner, projectId, permissionId)`. Verify:
|
|
97
|
-
|
|
98
|
-
- **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.
|
|
99
|
-
- **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.
|
|
100
|
-
- **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()`.
|
|
101
|
-
|
|
102
|
-
### 4. Renunciation Edge Cases
|
|
103
|
-
|
|
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).
|
|
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.
|
|
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)`).
|
|
107
|
-
|
|
108
|
-
### 5. Storage Slot Packing
|
|
109
|
-
|
|
110
|
-
The `JBOwner` struct fits in a single 256-bit slot: `address` (160) + `uint88` (88) + `uint8` (8) = 256. Verify:
|
|
111
|
-
- The Solidity compiler lays out the struct as expected (no padding gaps).
|
|
112
|
-
- The `_transferOwnership` function writes the entire struct atomically (`jbOwner = JBOwner({...})`), preventing partial-write inconsistencies.
|
|
113
|
-
|
|
114
|
-
## Invariants to Verify
|
|
115
|
-
|
|
116
|
-
1. **Mutual exclusivity**: At no point can both `jbOwner.owner != address(0)` and `jbOwner.projectId != 0`.
|
|
117
|
-
2. **Transfer resets permission**: After any call to `_transferOwnership`, `jbOwner.permissionId == 0`.
|
|
118
|
-
3. **Renounce is terminal**: After `renounceOwnership()`, `jbOwner.owner == address(0)` AND `jbOwner.projectId == 0` AND all subsequent `_checkOwner()` calls revert.
|
|
119
|
-
4. **owner() consistency**: `owner()` and the resolved owner in `_checkOwner()` always agree.
|
|
120
|
-
5. **No unauthorized transfer**: Only addresses passing `_checkOwner()` can call `transferOwnership`, `transferOwnershipToProject`, `renounceOwnership`, or `setPermissionId`.
|
|
121
|
-
|
|
122
|
-
## Testing Setup
|
|
123
|
-
|
|
124
|
-
```bash
|
|
125
|
-
cd nana-ownable-v6
|
|
126
|
-
npm install
|
|
127
|
-
forge build
|
|
128
|
-
forge test
|
|
129
|
-
|
|
130
|
-
# Run attack scenarios
|
|
131
|
-
forge test --match-contract OwnableAttacks -vvv
|
|
132
|
-
|
|
133
|
-
# Run edge cases
|
|
134
|
-
forge test --match-contract OwnableEdgeCases -vvv
|
|
15
|
+
In scope:
|
|
16
|
+
- `src/JBOwnable.sol`
|
|
17
|
+
- `src/JBOwnableOverrides.sol`
|
|
18
|
+
- `src/interfaces/`
|
|
19
|
+
- `src/structs/`
|
|
135
20
|
|
|
136
|
-
|
|
137
|
-
forge test --match-contract OwnableInvariant -vvv
|
|
21
|
+
## Start Here
|
|
138
22
|
|
|
139
|
-
|
|
140
|
-
|
|
23
|
+
1. `src/JBOwnable.sol`
|
|
24
|
+
2. `src/JBOwnableOverrides.sol`
|
|
141
25
|
|
|
142
|
-
|
|
143
|
-
forge test --match-path test/YourExploit.t.sol -vvv
|
|
144
|
-
```
|
|
26
|
+
## Security Model
|
|
145
27
|
|
|
146
|
-
|
|
28
|
+
These contracts abstract “owner” as a project-based identity. Downstream repos use them to:
|
|
29
|
+
- treat a Juicebox project owner as contract owner
|
|
30
|
+
- apply per-project override rules
|
|
31
|
+
- keep admin power aligned with project NFT ownership instead of a static address
|
|
147
32
|
|
|
148
|
-
|
|
33
|
+
## Roles And Privileges
|
|
149
34
|
|
|
150
|
-
|
|
|
151
|
-
|
|
152
|
-
|
|
|
153
|
-
|
|
|
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. |
|
|
35
|
+
| Role | Powers | How constrained |
|
|
36
|
+
|------|--------|-----------------|
|
|
37
|
+
| Project NFT owner | Become the effective contract owner | Should update automatically with NFT transfers |
|
|
38
|
+
| Override authority | Set alternative owner resolution where allowed | Must not outrank project ownership unexpectedly |
|
|
155
39
|
|
|
156
|
-
##
|
|
40
|
+
## Integration Assumptions
|
|
157
41
|
|
|
158
|
-
|
|
42
|
+
| Dependency | Assumption | What breaks if wrong |
|
|
43
|
+
|------------|------------|----------------------|
|
|
44
|
+
| Juicebox project ownership | NFT ownership reflects intended authority | Downstream admin checks drift from reality |
|
|
159
45
|
|
|
160
|
-
|
|
46
|
+
## Critical Invariants
|
|
161
47
|
|
|
162
|
-
|
|
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. |
|
|
48
|
+
1. Owner resolution is correct
|
|
49
|
+
For any supported mode, `owner()` or equivalent checks must resolve to the intended authority and no one else.
|
|
166
50
|
|
|
167
|
-
|
|
51
|
+
2. Burn and lock behavior is safe
|
|
52
|
+
If project ownership is intentionally burned or locked, the helper must not accidentally reopen control or brick valid admin paths.
|
|
168
53
|
|
|
169
|
-
|
|
54
|
+
3. Override precedence is coherent
|
|
55
|
+
Overrides must not silently supersede project ownership in cases the design does not permit.
|
|
170
56
|
|
|
171
|
-
|
|
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.
|
|
57
|
+
## Attack Surfaces
|
|
176
58
|
|
|
177
|
-
|
|
59
|
+
- owner resolution after project NFT transfer
|
|
60
|
+
- zero-address, burn, and lock states
|
|
61
|
+
- override configuration and precedence
|
|
62
|
+
- downstream assumptions that cache owner state instead of re-reading it
|
|
178
63
|
|
|
179
|
-
|
|
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
|
|
64
|
+
## Verification
|
|
186
65
|
|
|
187
|
-
|
|
66
|
+
- `npm install`
|
|
67
|
+
- `forge build`
|
|
68
|
+
- `forge test`
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Scope
|
|
4
|
+
|
|
5
|
+
This file describes the verified change from `nana-ownable-v5` to the current `nana-ownable-v6` repo.
|
|
6
|
+
|
|
7
|
+
## Current v6 surface
|
|
8
|
+
|
|
9
|
+
- `JBOwnable`
|
|
10
|
+
- `JBOwnableOverrides`
|
|
11
|
+
- `IJBOwnable`
|
|
12
|
+
- `JBOwner`
|
|
13
|
+
|
|
14
|
+
## Summary
|
|
15
|
+
|
|
16
|
+
- Project-backed ownership is more defensive than in v5. The current implementation tolerates invalid or burned project NFTs by handling `ownerOf` failures instead of surfacing them as unchecked behavior.
|
|
17
|
+
- The project-ownership path validates its inputs more strictly before transferring control to a project.
|
|
18
|
+
- The implementation files now compile on the v6 `0.8.28` baseline instead of the old floating `^0.8.23` setup.
|
|
19
|
+
|
|
20
|
+
## Verified deltas
|
|
21
|
+
|
|
22
|
+
- `JBOwnable` now emits ownership-transfer events through `_msgSender()` instead of raw `msg.sender`.
|
|
23
|
+
- The transfer-event path is explicitly documented to revert when the new project does not exist, instead of silently tolerating a bad target.
|
|
24
|
+
- The current imports point at `core-v6`, not `core-v5`.
|
|
25
|
+
|
|
26
|
+
## Breaking ABI changes
|
|
27
|
+
|
|
28
|
+
- There is no meaningful public function migration in `IJBOwnable`; the interface mostly preserved its shape.
|
|
29
|
+
- The important migration is behavioral: project-backed ownership resolution and transfer validation are stricter.
|
|
30
|
+
|
|
31
|
+
## Indexer impact
|
|
32
|
+
|
|
33
|
+
- Event names are stable, but the `caller` field is now emitted through `_msgSender()` semantics in the implementation.
|
|
34
|
+
- Meta-transaction-aware consumers should prefer the v6 behavior over raw-`msg.sender` assumptions.
|
|
35
|
+
|
|
36
|
+
## Migration notes
|
|
37
|
+
|
|
38
|
+
- If you depended on `owner()` reverting for invalid project-backed ownership states, revisit that expectation.
|
|
39
|
+
- Rebuild imports against the v6 core package and the current ownable contracts. This repo is small, but the behavior is intentionally stricter than v5.
|