@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 CHANGED
@@ -1,95 +1,79 @@
1
1
  # Administration
2
2
 
3
- Admin privileges and their scope in nana-ownable-v6.
3
+ ## At A Glance
4
4
 
5
- ## Roles
6
-
7
- | Role | Who | How Access Is Determined |
8
- |------|-----|------------------------|
9
- | **Direct Owner** | An EOA or contract stored in `JBOwner.owner` | Used when `JBOwner.projectId == 0`. Set at construction or via `transferOwnership(address)`. |
10
- | **Project Owner** | The holder of the `JBProjects` ERC-721 for `JBOwner.projectId` | Used when `JBOwner.projectId != 0`. Resolved dynamically via `PROJECTS.ownerOf(projectId)` on every call. |
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
- ### JBOwnableOverrides (abstract base)
12
+ ## Purpose
18
13
 
19
- | Function | Required Role | Permission ID | Scope | What It Does |
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
- ### JBOwnable (concrete contract)
16
+ ## Control Model
27
17
 
28
- `JBOwnable` inherits all functions above and adds no additional privileged functions. It provides the `onlyOwner` modifier for use by inheriting contracts.
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
- Any contract that inherits `JBOwnable` and applies the `onlyOwner` modifier to its own functions effectively creates additional privileged functions gated by the same ownership and permission model described here.
31
-
32
- ## Ownership Model
23
+ ## Roles
33
24
 
34
- JBOwnable bridges Juicebox project ownership to the OpenZeppelin `Ownable` pattern through the `JBOwner` struct:
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
- **Resolution logic** (in `_checkOwner()` and `owner()`):
33
+ The meaningful control surfaces are inherited by downstream contracts:
45
34
 
46
- 1. If `projectId != 0`, the owner is `PROJECTS.ownerOf(projectId)` -- resolved dynamically on every call. If `ownerOf()` reverts (e.g., hypothetical NFT burn), the resolved owner becomes `address(0)`, effectively renouncing the contract.
47
- 2. If `projectId == 0`, the owner is `JBOwner.owner` directly.
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`.
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
- **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.
41
+ ## Immutable And One-Way
51
42
 
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.
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
- ## Usage Pattern
47
+ ## Operational Notes
55
48
 
56
- Contracts inherit from `JBOwnable` to bridge Juicebox project ownership into the standard `onlyOwner` modifier pattern. The typical usage:
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
- ```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
- ```
54
+ ## Machine Notes
65
55
 
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`)
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
- **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`.
61
+ ## Recovery
72
62
 
73
- ## Immutable Configuration
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
- | Property | Set At | Can Change? |
76
- |----------|--------|-------------|
77
- | `PERMISSIONS` (IJBPermissions) | Construction (via `JBPermissioned`) | No -- immutable |
78
- | `PROJECTS` (IJBProjects) | Construction | No -- immutable |
66
+ ## Admin Boundaries
79
67
 
80
- The `JBPermissions` and `JBProjects` contract references are baked in at deploy time and cannot be changed. If either contract is upgraded or replaced, the `JBOwnable` instance must be redeployed.
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
- ## Admin Boundaries
72
+ ## Source Map
83
73
 
84
- What admins **cannot** do:
85
-
86
- - **Change the `PERMISSIONS` or `PROJECTS` contracts.** These are immutable references set at construction.
87
- - **Set both `owner` and `projectId` simultaneously.** The `_transferOwnership` internal function reverts if both are non-zero.
88
- - **Transfer to `address(0)` via `transferOwnership()`.** This reverts with `JBOwnableOverrides_InvalidNewOwner`. Use `renounceOwnership()` instead.
89
- - **Transfer to a non-existent project.** `transferOwnershipToProject()` checks `projectId <= PROJECTS.count()` and reverts if the project does not exist.
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
- # nana-ownable-v6 — Architecture
1
+ # Architecture
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. 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.
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
- ## Contract Map
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
- ## Ownership Model
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
- ## Key Operations
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
- ### Ownership Transfer
37
- ```
38
- Current owner → transferOwnership(newOwner)
39
- → Can transfer to address or project ID
40
- → Emits OwnershipTransferred
19
+ ## Modules
41
20
 
42
- Current owner renounceOwnership()
43
- Sets owner to address(0), projectId to 0
44
- Permanently disables owner-only functions
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
- ## Design Decisions
28
+ ## Trust Boundaries
48
29
 
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.
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
- ### `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.
34
+ ## Critical Flows
54
35
 
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.
36
+ ### Owner Check
57
37
 
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.
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
- ## Dependencies
62
- - `@bananapus/core-v6` — JBPermissioned, IJBProjects, IJBPermissions
63
- - `@openzeppelin/contracts` Context
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`
@@ -1,187 +1,68 @@
1
- # Audit Instructions -- nana-ownable-v6
1
+ # Audit Instructions
2
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.
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
- ## Compiler and Version Info
5
+ ## Audit Objective
6
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)
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
- **In scope -- all Solidity in `src/`:**
21
- ```
22
- src/JBOwnable.sol # Concrete implementation with onlyOwner modifier (~76 lines)
23
- src/JBOwnableOverrides.sol # Abstract base with all ownership logic (~244 lines)
24
- src/structs/JBOwner.sol # Owner struct: {owner, projectId, permissionId} (~15 lines)
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
- # Run invariant tests
137
- forge test --match-contract OwnableInvariant -vvv
21
+ ## Start Here
138
22
 
139
- # Run regression tests
140
- forge test --match-path test/regression/ -vvv
23
+ 1. `src/JBOwnable.sol`
24
+ 2. `src/JBOwnableOverrides.sol`
141
25
 
142
- # Write a PoC (create test/YourExploit.t.sol)
143
- forge test --match-path test/YourExploit.t.sol -vvv
144
- ```
26
+ ## Security Model
145
27
 
146
- ## Error Reference
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
- All custom errors are defined in `src/JBOwnableOverrides.sol`.
33
+ ## Roles And Privileges
149
34
 
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. |
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
- ## Previous Audit Findings
40
+ ## Integration Assumptions
157
41
 
158
- A Nemesis audit (Feynman + State Inconsistency methodology) was conducted on 2026-03-17. Full results are in `.audit/findings/nemesis-verified.md`.
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
- **Result: 0 Critical | 0 High | 0 Medium | 2 Low (informational)**
46
+ ## Critical Invariants
161
47
 
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. |
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
- No other formal audit with finding IDs has been conducted.
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
- ## How to Report Findings
54
+ 3. Override precedence is coherent
55
+ Overrides must not silently supersede project ownership in cases the design does not permit.
170
56
 
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.
57
+ ## Attack Surfaces
176
58
 
177
- For each finding:
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
- 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
64
+ ## Verification
186
65
 
187
- Go break it.
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.