@bananapus/ownable-v6 0.0.18 → 0.0.20

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
@@ -11,14 +11,14 @@
11
11
 
12
12
  ## Purpose
13
13
 
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.
14
+ `nana-ownable-v6` does not add a new admin surface by itself. It defines how ownership is resolved for other repos. The important question is how a contract's `owner()` is determined and how delegated permission IDs behave across ownership transfers.
15
15
 
16
16
  ## Control Model
17
17
 
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.
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
+ - delegated permission resets on ownership transfer
22
22
 
23
23
  ## Roles
24
24
 
@@ -40,34 +40,34 @@ The meaningful control surfaces are inherited by downstream contracts:
40
40
 
41
41
  ## Immutable And One-Way
42
42
 
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.
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
46
46
 
47
47
  ## Operational Notes
48
48
 
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.
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
53
53
 
54
54
  ## Machine Notes
55
55
 
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.
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
60
60
 
61
61
  ## Recovery
62
62
 
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.
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
65
65
 
66
66
  ## Admin Boundaries
67
67
 
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.
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
71
71
 
72
72
  ## Source Map
73
73
 
package/ARCHITECTURE.md CHANGED
@@ -6,15 +6,17 @@
6
6
 
7
7
  ## System Overview
8
8
 
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.
9
+ This repo is an ownership primitive, not a policy layer. `JBOwnable` gives downstream repos a familiar inheritance surface. `JBOwnableOverrides` implements dynamic owner resolution, ownership transfer, renounce behavior, and delegated permission checks.
10
+
11
+ Ownership can follow the current holder of a Juicebox project NFT instead of staying fixed to one address.
10
12
 
11
13
  ## Core Invariants
12
14
 
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.
15
+ - project-owned contracts must resolve the owner dynamically from the current project NFT holder
16
+ - the delegated permission ID resets on ownership transfer
17
+ - pointing ownership at an unminted project can temporarily lock the contract until that project exists
18
+ - an invalid or otherwise unresolvable project NFT effectively renounces ownership
19
+ - this repo should stay a drop-in primitive, not grow product-specific access rules
18
20
 
19
21
  ## Modules
20
22
 
@@ -27,9 +29,9 @@ The repo is an ownership primitive, not a policy layer. `JBOwnable` exposes a fa
27
29
 
28
30
  ## Trust Boundaries
29
31
 
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.
32
+ - ownership resolution depends on `JBProjects` and `JBPermissions` from `nana-core-v6`
33
+ - this repo does not create a new permission namespace
34
+ - inheriting contracts may add policy on top, but the resolution semantics here are infrastructure-level
33
35
 
34
36
  ## Critical Flows
35
37
 
@@ -45,20 +47,20 @@ onlyOwner modifier
45
47
 
46
48
  ## Accounting Model
47
49
 
48
- No treasury accounting lives here. The critical state is ownership resolution data and delegated permission ID.
50
+ No treasury accounting lives here. The important state is ownership resolution data and delegated permission ID.
49
51
 
50
52
  ## Security Model
51
53
 
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.
54
+ - ownership resolution edge cases matter more than surface API shape
55
+ - permission delegation is simple but security-sensitive because it composes with a global permission registry
56
+ - unresolvable project ownership is intentionally fail-closed
55
57
 
56
58
  ## Safe Change Guide
57
59
 
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.
60
+ - be conservative with transfer and renounce semantics
61
+ - if event emission or transfer behavior changes, inspect deployer wrappers and inheriting repos
62
+ - if project-based ownership semantics change, re-check unminted-project and unresolvable-project behavior explicitly
63
+ - do not make delegated permission IDs sticky across ownership transfers
62
64
 
63
65
  ## Canonical Checks
64
66
 
@@ -1,10 +1,11 @@
1
1
  # Audit Instructions
2
2
 
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.
3
+ This repo provides ownership helpers that can follow Juicebox project NFTs instead of a fixed EOA. It is a small repo with outsized privilege impact.
4
4
 
5
5
  ## Audit Objective
6
6
 
7
7
  Find issues that:
8
+
8
9
  - let unauthorized actors satisfy owner checks
9
10
  - break ownership updates when a project NFT moves, burns, or locks
10
11
  - let override logic produce a different owner than the project system intends
@@ -13,6 +14,7 @@ Find issues that:
13
14
  ## Scope
14
15
 
15
16
  In scope:
17
+
16
18
  - `src/JBOwnable.sol`
17
19
  - `src/JBOwnableOverrides.sol`
18
20
  - `src/interfaces/`
@@ -25,7 +27,8 @@ In scope:
25
27
 
26
28
  ## Security Model
27
29
 
28
- These contracts abstract owner as a project-based identity. Downstream repos use them to:
30
+ These contracts abstract "owner" as a project-based identity. Downstream repos use them to:
31
+
29
32
  - treat a Juicebox project owner as contract owner
30
33
  - apply per-project override rules
31
34
  - keep admin power aligned with project NFT ownership instead of a static address
@@ -45,14 +48,12 @@ These contracts abstract “owner” as a project-based identity. Downstream rep
45
48
 
46
49
  ## Critical Invariants
47
50
 
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.
50
-
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.
53
-
54
- 3. Override precedence is coherent
55
- Overrides must not silently supersede project ownership in cases the design does not permit.
51
+ 1. Owner resolution is correct.
52
+ For any supported mode, `owner()` and owner checks must resolve to the intended authority and no one else.
53
+ 2. Burn and lock behavior is safe.
54
+ If project ownership is intentionally burned or locked, the helper must not accidentally reopen control or brick valid admin paths.
55
+ 3. Override precedence is coherent.
56
+ Overrides must not silently supersede project ownership in cases the design does not permit.
56
57
 
57
58
  ## Attack Surfaces
58
59
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Juicebox Ownable
2
2
 
3
- `@bananapus/ownable-v6` is an ownership helper for contracts that should be controlled by a Juicebox project rather than a fixed wallet. It keeps the familiar `Ownable` shape while letting ownership follow a project NFT and optional delegated permissions.
3
+ `@bananapus/ownable-v6` is an ownership helper for contracts that should be controlled by a Juicebox project instead of a fixed wallet. It keeps the familiar `Ownable` shape while letting ownership follow a project NFT and optional delegated permissions.
4
4
 
5
5
  Architecture: [ARCHITECTURE.md](./ARCHITECTURE.md)
6
6
  User journeys: [USER_JOURNEYS.md](./USER_JOURNEYS.md)
@@ -11,32 +11,32 @@ Audit instructions: [AUDIT_INSTRUCTIONS.md](./AUDIT_INSTRUCTIONS.md)
11
11
 
12
12
  ## Overview
13
13
 
14
- This package extends the standard ownership model in three useful ways:
14
+ This package extends the standard ownership model in three ways:
15
15
 
16
16
  - ownership can point to a Juicebox project ID instead of an address
17
- - `owner()` resolves dynamically to the current holder of that project NFT when the referenced project remains readable
18
- - delegated operators can satisfy `onlyOwner` through a configurable `JBPermissions` permission ID
17
+ - `owner()` can resolve dynamically to the current holder of that project NFT
18
+ - delegated operators can satisfy `onlyOwner` through a configured `JBPermissions` permission ID
19
19
 
20
- For contracts that are already conceptually "owned by the project," this avoids manual ownership transfers when the project NFT changes hands.
20
+ For contracts that are already meant to be owned by a project, this avoids manual ownership transfers when the project NFT changes hands.
21
21
 
22
- Use this repo when ownership should follow a Juicebox project. Do not use it if plain single-address ownership is good enough; standard `Ownable` is simpler.
22
+ Use this repo when ownership should follow a Juicebox project. Do not use it if plain single-address ownership is enough. Standard `Ownable` is simpler.
23
23
 
24
- If your issue is in project ownership itself, start in `nana-core-v6` and `JBProjects`. This repo starts mattering when another contract wants its own admin surface to follow that project ownership.
24
+ If the issue is in project ownership itself, start in `nana-core-v6` and `JBProjects`. This repo matters when another contract wants its admin surface to follow that project ownership.
25
25
 
26
26
  ## Key Contracts
27
27
 
28
28
  | Contract | Role |
29
29
  | --- | --- |
30
- | `JBOwnable` | Concrete contract to inherit when you want Juicebox-aware ownership with the standard `onlyOwner` interface. |
31
- | `JBOwnableOverrides` | Abstract base that holds the owner-resolution and permission-checking logic. |
30
+ | `JBOwnable` | Concrete contract to inherit when you want Juicebox-aware ownership with a standard `onlyOwner` interface. |
31
+ | `JBOwnableOverrides` | Abstract base that holds owner resolution and delegated-permission logic. |
32
32
  | `IJBOwnable` | Interface for queries, transfers, permission ID changes, and events. |
33
33
 
34
34
  ## Mental Model
35
35
 
36
- This package is a thin ownership adapter:
36
+ This package is a small ownership adapter:
37
37
 
38
38
  1. resolve who the effective owner is
39
- 2. optionally delegate `onlyOwner` through a permission ID
39
+ 2. optionally allow a delegated permission to satisfy `onlyOwner`
40
40
  3. preserve an `Ownable`-like interface for downstream contracts
41
41
 
42
42
  ## Read These Files First
@@ -47,16 +47,16 @@ This package is a thin ownership adapter:
47
47
 
48
48
  ## Integration Traps
49
49
 
50
- - ownership may resolve to a project NFT holder rather than a fixed address, so caching `owner()` off-chain can become stale
51
- - `owner()` can resolve to `address(0)` if the referenced project NFT is burned, invalid, or otherwise unreadable, which effectively renounces the contract
50
+ - ownership may resolve to a project NFT holder instead of a fixed address, so caching `owner()` off-chain can go stale
51
+ - `owner()` can resolve to `address(0)` if the referenced project NFT is invalid or unreadable, which effectively renounces the contract
52
52
  - delegated operator access depends on a chosen permission ID, not on a generic admin role
53
53
  - ownership transfer and permission-ID updates are part of the security model, not just convenience helpers
54
54
 
55
55
  ## Where State Lives
56
56
 
57
- - effective ownership configuration lives in `JBOwnableOverrides`
58
- - downstream contract state still lives in the inheriting contract, not in this package
59
- - project ownership truth lives in `nana-core-v6` when the owner target is a Juicebox project
57
+ - effective ownership configuration: `JBOwnableOverrides`
58
+ - downstream contract state: the inheriting contract
59
+ - project ownership truth: `nana-core-v6` when the owner target is a Juicebox project
60
60
 
61
61
  ## High-Signal Tests
62
62
 
@@ -94,9 +94,9 @@ test/
94
94
  ## Risks And Notes
95
95
 
96
96
  - if ownership is tied to a project NFT and that NFT becomes unreachable, the contract is effectively locked
97
- - delegated access depends on a chosen permission ID, so collisions with other permission schemes are an operational risk
98
- - permission IDs reset on ownership transfer, which is safer by default but easy to miss if an integration expects long-lived operator access
99
- - transferring ownership to a project validates that the project exists, but later project-NFT invalidation can still collapse effective ownership to `address(0)`
97
+ - delegated access depends on a chosen permission ID, so bad permission selection is an operational risk
98
+ - permission IDs reset on ownership transfer, which is safer by default but easy to miss
99
+ - transferring ownership to a project validates that the project exists at transfer time, but later project invalidation can still collapse effective ownership to `address(0)`
100
100
 
101
101
  ## For AI Agents
102
102
 
package/RISKS.md CHANGED
@@ -1,51 +1,51 @@
1
1
  # Juicebox Ownable Risk Register
2
2
 
3
- This file focuses on the ownership-model risks in `JBOwnable`: dynamic ownership through project NFTs, delegated owner authority, and the mismatch between EOA-style expectations and Juicebox project control.
3
+ This file covers the ownership-model risks in `JBOwnable`: dynamic ownership through project NFTs, delegated owner authority, and mismatches with standard `Ownable` expectations.
4
4
 
5
- ## How to use this file
5
+ ## How To Use This File
6
6
 
7
- - Read `Priority risks` first; most failures here are authority-model misunderstandings rather than arithmetic bugs.
8
- - Use the detailed sections to understand what changes when ownership follows a project instead of a fixed address.
9
- - Treat `Invariants to Verify` as the minimal proof that owner resolution stays coherent.
7
+ - Read `Priority risks` first. Most failures here come from authority-model mistakes, not arithmetic bugs.
8
+ - Use the later sections to understand what changes when ownership follows a project instead of a fixed address.
9
+ - Treat `Invariants to verify` as the minimum proof that owner resolution stays coherent.
10
10
 
11
- ## Priority risks
11
+ ## Priority Risks
12
12
 
13
13
  | Priority | Risk | Why it matters | Primary controls |
14
14
  |----------|------|----------------|------------------|
15
- | P1 | Misunderstanding dynamic owner resolution | Ownership can move when the project NFT moves or when permissions change, surprising integrators that expect static `Ownable` semantics. | Clear docs, careful integration review, and explicit tests around ownership transfer paths. |
16
- | P1 | Over-broad delegated owner permissions | `JBPermissions` can broaden who effectively acts as owner; sloppy configuration expands blast radius fast. | Permission hygiene and explicit review of delegated owner grants. |
17
- | P2 | Interoperability assumptions with standard `Ownable` tooling | Some tooling assumes `owner()` maps to one address with no external permission system behind it. | Integration testing with downstream tooling and documentation of semantic differences. |
18
-
15
+ | P1 | Misunderstanding dynamic owner resolution | Ownership can move when the project NFT moves or when permissions change, which breaks static `Ownable` assumptions. | Clear docs, careful integration review, and explicit tests around transfer paths. |
16
+ | P1 | Over-broad delegated owner permissions | `JBPermissions` can broaden who effectively acts as owner. Bad configuration expands blast radius quickly. | Permission hygiene and explicit review of delegated grants. |
17
+ | P2 | Tooling assumptions about standard `Ownable` | Some tooling assumes `owner()` maps to one address with no external permission system behind it. | Integration testing and clear documentation of the semantic differences. |
19
18
 
20
19
  ## 1. Trust Assumptions
21
20
 
22
- - **JBPermissions.** Permission checks delegate to JBPermissions contract. A bug in JBPermissions affects all JBOwnable contracts.
23
- - **JBProjects ERC-721.** When owned by a project, ownership follows the ERC-721 token. Whoever holds the project NFT has owner access.
24
- - **Permission Delegation.** Anyone granted the configured `permissionId` via JBPermissions gets owner-equivalent access for the scoped function.
25
- - **Deployment inputs.** If `initialProjectIdOwner != 0`, the constructor assumes `PROJECTS` is non-zero and that deployers understand whether that project already exists yet.
21
+ - **`JBPermissions` works correctly.** A bug there affects every `JBOwnable` contract that relies on delegated owner access.
22
+ - **`JBProjects` ownership is the source of truth.** When a contract is project-owned, whoever holds the project NFT has owner access.
23
+ - **Delegated permission means owner-equivalent access.** Anyone granted the configured `permissionId` through `JBPermissions` can satisfy owner checks for the scoped contract.
24
+ - **Deployment inputs are intentional.** If `initialProjectIdOwner != 0`, deployers must understand whether that project already exists.
26
25
 
27
26
  ## 2. Known Risks
28
27
 
29
- - **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.
30
- - **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.
31
- - **`renounceOwnership` permanently disables all owner-gated functions.** Defined directly in `JBOwnableOverrides` (not inherited from OpenZeppelin's `Ownable` -- the contract does not inherit from `Ownable`). Once called, `owner()` returns `address(0)` and all `onlyOwner` / `_checkOwner()` calls revert permanently. There is no recovery mechanism. This applies whether ownership is direct (address) or project-based (project NFT holder).
32
- - **Constructor pre-binding can intentionally lock the contract.** If a deployer sets `initialProjectIdOwner` to a future project ID, `owner()` resolves to `address(0)` until that project NFT exists. This is a supported deployment pattern, but it means the contract can appear renounced during the gap.
33
- - **`PROJECTS == address(0)` is fatal for project-owned mode.** The constructor defends against this with `JBOwnableOverrides_ZeroAddressProjectsWithProjectOwner`, but wrappers that abstract deployment should still treat project-owned initialization as a high-signal configuration surface.
28
+ - **Project NFT transfer changes contract ownership.** Anyone who acquires the project NFT gains owner access to contracts using that project-owned mode.
29
+ - **Two ownership modes can confuse integrations.** Setting both `newOwner` and `projectId` is disallowed, but integrators still need to check which mode is active.
30
+ - **`renounceOwnership` is final.** Once called, `owner()` resolves to `address(0)` and owner-gated functions stop working permanently unless a downstream contract adds its own recovery path.
31
+ - **Constructor pre-binding can intentionally lock the contract.** If a deployer points ownership at a future project ID, `owner()` resolves to `address(0)` until that project exists.
32
+ - **`PROJECTS == address(0)` breaks project-owned mode.** The constructor defends against this, but wrappers should still treat it as a high-signal deployment surface.
33
+ - **Unminted project ID ownership.** Contracts using `JBOwnableOverrides` can be configured with an `initialProjectIdOwner` that references a project ID not yet minted. The first account to mint that sequential project ID will become the effective owner of the contract. Deployers must ensure the referenced project ID is already minted, or deploy the ownable contract and the project in the same transaction to prevent front-running.
34
34
 
35
35
  ## 3. Accepted Behaviors
36
36
 
37
- - **Permission ID reset on transfer.** `permissionId` resets to 0 on ownership transfer, which temporarily locks out delegated operators. This is intentional -- it prevents permission clashes for new owners.
38
- - **`permissionId = 0` means direct-owner-only mode.** `setPermissionId(0)` is valid and leaves owner access resting only with the resolved owner address or project owner. This is not an error state; it is the explicit way to disable delegated owner operators.
39
- - **Burned/invalid project NFT.** If the project NFT were burned or `ownerOf` reverted, the contract would be effectively renounced (owner resolves to `address(0)`). Defensive try-catch in `owner()` and `_checkOwner()` handles this gracefully. JBProjects V6 has no burn function, so this scenario cannot occur in practice.
40
- - **`transferOwnershipToProject` rejects non-existent projects.** The function checks `projectId > PROJECTS.count()` and reverts, preventing transfers to non-existent projects.
41
- - **Constructor pre-binding to a future project ID.** The constructor can intentionally store a project-based owner for a not-yet-minted project ID. This is accepted for deployment flows that also control project-ID reservation or mint sequencing. If integrators do not control who mints that future project first, the first minter of the project NFT becomes owner of the contract.
42
- - **Transfer events can temporarily show `address(0)` as the new owner.** When ownership is transferred to an unminted future project ID, `_emitTransferEvent` intentionally emits `address(0)` until the project NFT exists and ownership can resolve dynamically.
43
-
44
- ## 4. Invariants to Verify
45
-
46
- - Ownership is always exactly one of: direct address OR project NFT holder (never both, never neither unless renounced).
47
- - `_checkOwner()` reverts for all callers when the owner resolves to `address(0)`.
48
- - `permissionId` is correctly reset to 0 on every ownership transfer.
49
- - After `renounceOwnership()`, `jbOwner()` returns `(address(0), 0, 0)` and no address can pass `_checkOwner()`.
50
- - `transferOwnershipToProject(projectId)` reverts for all `projectId > PROJECTS.count()` at call time.
51
- - `initialProjectIdOwner != 0` with `PROJECTS == address(0)` always reverts during construction.
37
+ - **Permission ID resets on transfer.** `permissionId` resets to `0` on ownership transfer so old delegated operators do not automatically retain power.
38
+ - **`permissionId = 0` means direct-owner-only mode.** This is a valid configuration, not an error state.
39
+ - **Invalid project ownership resolves fail-closed.** If `ownerOf` cannot resolve, the contract is effectively renounced until ownership becomes readable again.
40
+ - **`transferOwnershipToProject` rejects non-existent projects.** The function checks existence at transfer time.
41
+ - **Constructor pre-binding to a future project ID is supported.** This is useful in controlled deployment flows, but dangerous if the deployer does not control mint sequencing.
42
+ - **Transfer events can temporarily show `address(0)`.** When ownership points to an unminted future project, the transfer event shows `address(0)` until ownership can resolve dynamically.
43
+
44
+ ## 4. Invariants To Verify
45
+
46
+ - ownership is always exactly one of: direct address or project NFT holder
47
+ - `_checkOwner()` reverts for all callers when the owner resolves to `address(0)`
48
+ - `permissionId` resets to `0` on every ownership transfer
49
+ - after `renounceOwnership()`, `jbOwner()` returns `(address(0), 0, 0)` and no address can pass `_checkOwner()`
50
+ - `transferOwnershipToProject(projectId)` reverts for all `projectId > PROJECTS.count()` at call time
51
+ - `initialProjectIdOwner != 0` with `PROJECTS == address(0)` always reverts during construction
package/SKILLS.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  ## Use This File For
4
4
 
5
- - Use this file when the task involves project-based ownership, delegated `onlyOwner` permissions, or how ownership should follow a Juicebox project NFT instead of a fixed wallet.
6
- - Start here, then decide whether the question is about owner resolution, permission delegation, or ownership transfer semantics. Those surfaces are intentionally compact but security-sensitive.
5
+ - Use this file when the task involves project-based ownership, delegated `onlyOwner` permissions, or ownership that should follow a Juicebox project NFT instead of a fixed wallet.
6
+ - Start here, then decide whether the question is about owner resolution, permission delegation, or ownership transfer semantics.
7
7
 
8
8
  ## Read This Next
9
9
 
@@ -25,7 +25,7 @@
25
25
 
26
26
  ## Purpose
27
27
 
28
- Ownership adapter for contracts that should follow Juicebox project ownership instead of a fixed address, with optional delegated permission IDs layered on top of the familiar `Ownable` pattern.
28
+ Ownership adapter for contracts that should follow Juicebox project ownership instead of a fixed address, with optional delegated permission IDs on top of the familiar `Ownable` pattern.
29
29
 
30
30
  ## Reference Files
31
31
 
@@ -36,6 +36,6 @@ Ownership adapter for contracts that should follow Juicebox project ownership in
36
36
 
37
37
  - Start in [`src/JBOwnableOverrides.sol`](./src/JBOwnableOverrides.sol) when the question is about who the effective owner is or why `onlyOwner` passed or failed.
38
38
  - Treat ownership transfer and delegated permission resets as security-sensitive.
39
- - Project-based ownership can intentionally become unusable if it points at an unminted or invalid project. Treat that as a deployment invariant, not a runtime surprise.
39
+ - Project-based ownership can intentionally become unusable if it points at an unminted or invalid project.
40
40
  - Unminted or unexpectedly transferred project NFTs can change the effective owner surface. Check the project lifecycle, not just this adapter.
41
- - When a bug looks like project ownership itself, confirm whether the real source is upstream in `nana-core-v6` rather than this adapter layer.
41
+ - When a bug looks like project ownership itself, confirm whether the real source is upstream in `nana-core-v6` rather than this adapter.
package/USER_JOURNEYS.md CHANGED
@@ -2,9 +2,7 @@
2
2
 
3
3
  ## Repo Purpose
4
4
 
5
- This repo adapts `Ownable`-style control to Juicebox project ownership and project-scoped operator permissions.
6
- It is an ownership adapter. It does not replace the underlying ownership or permission registries in
7
- [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md).
5
+ This repo adapts `Ownable`-style control to Juicebox project ownership and project-scoped operator permissions. It is an ownership adapter. It does not replace the underlying ownership or permission registries in [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md).
8
6
 
9
7
  ## Primary Actors
10
8
 
@@ -31,7 +29,7 @@ It is an ownership adapter. It does not replace the underlying ownership or perm
31
29
  **Main Flow**
32
30
  1. Inherit `JBOwnable` or `JBOwnableOverrides`.
33
31
  2. Initialize ownership with the relevant project ID and `JBProjects` reference.
34
- 3. Let `owner()` resolve through the current project NFT holder rather than a fixed address.
32
+ 3. Let `owner()` resolve through the current project NFT holder instead of a fixed address.
35
33
 
36
34
  **Failure Modes**
37
35
  - the contract assumes ordinary `Ownable` transfer semantics after adopting project-based ownership
@@ -59,7 +57,7 @@ It is an ownership adapter. It does not replace the underlying ownership or perm
59
57
  **Failure Modes**
60
58
  - teams grant a broader permission than intended
61
59
  - downstream reviewers forget that `onlyOwner` may resolve through permissions instead of direct ownership
62
- - operators retain stale permissions after governance changes
60
+ - operators keep stale permissions after governance changes
63
61
 
64
62
  **Postconditions**
65
63
  - the chosen operator can satisfy `onlyOwner` without receiving direct ownership of the project or contract
@@ -99,11 +97,11 @@ It is an ownership adapter. It does not replace the underlying ownership or perm
99
97
  **Main Flow**
100
98
  1. Use `transferOwnership(...)` for an address owner or `transferOwnershipToProject(...)` for a project owner.
101
99
  2. Re-establish delegated permission policy if the new owner still wants operators.
102
- 3. Renounce or burn only when permanent admin loss is intentional.
100
+ 3. Renounce only when permanent admin loss is intentional.
103
101
 
104
102
  **Failure Modes**
105
103
  - ownership is burned even though the downstream contract still needs administration
106
- - teams forget that permission-ID delegation resets across ownership changes
104
+ - teams forget that delegated permissions reset across ownership changes
107
105
 
108
106
  **Postconditions**
109
107
  - control moves to the chosen address or project, or is intentionally removed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/ownable-v6",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -134,6 +134,17 @@ abstract contract JBOwnableOverrides is Context, JBPermissioned, IJBOwnable {
134
134
  }
135
135
  }
136
136
 
137
+ // When permissionId is 0 (direct-owner-only mode), bypass the permission system entirely.
138
+ // This ensures ROOT operators cannot act as owner when delegation is disabled.
139
+ if (ownerInfo.permissionId == 0) {
140
+ if (_msgSender() != resolvedOwner) {
141
+ revert JBPermissioned.JBPermissioned_Unauthorized({
142
+ account: resolvedOwner, sender: _msgSender(), projectId: ownerInfo.projectId, permissionId: 0
143
+ });
144
+ }
145
+ return;
146
+ }
147
+
137
148
  _requirePermissionFrom({
138
149
  account: resolvedOwner, projectId: ownerInfo.projectId, permissionId: ownerInfo.permissionId
139
150
  });
@@ -0,0 +1,87 @@
1
+ // SPDX-License-Identifier: UNLICENSED
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {MockOwnable} from "../mocks/MockOwnable.sol";
7
+
8
+ import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
9
+ import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
10
+ import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
11
+ import {JBPermissionsData} from "@bananapus/core-v6/src/structs/JBPermissionsData.sol";
12
+
13
+ contract RootPermissionBypassesPermissionIdZeroTest is Test {
14
+ JBProjects internal projects;
15
+ JBPermissions internal permissions;
16
+
17
+ address internal alice = makeAddr("alice");
18
+ address internal operator = makeAddr("operator");
19
+
20
+ function setUp() public {
21
+ permissions = new JBPermissions(address(0));
22
+ projects = new JBProjects(address(this), address(0), address(0));
23
+ }
24
+
25
+ /// @notice After M-39 fix: ROOT operator is rejected when permissionId=0 (direct-owner-only mode).
26
+ function test_rootPermissionRejectedWhenPermissionIdIsZero() public {
27
+ uint256 projectId = projects.createFor(alice);
28
+ MockOwnable ownable = new MockOwnable(projects, permissions, address(0), uint88(projectId));
29
+
30
+ // Grant ROOT permission (id=1) to operator.
31
+ uint8[] memory permissionIds = new uint8[](1);
32
+ permissionIds[0] = 1;
33
+
34
+ vm.prank(alice);
35
+ permissions.setPermissionsFor(
36
+ alice, JBPermissionsData({operator: operator, projectId: uint56(projectId), permissionIds: permissionIds})
37
+ );
38
+
39
+ (, uint88 storedProjectId, uint8 permissionId) = ownable.jbOwner();
40
+ assertEq(storedProjectId, projectId);
41
+ assertEq(permissionId, 0, "expected direct-owner-only mode");
42
+
43
+ // Operator should be rejected when permissionId=0.
44
+ vm.prank(operator);
45
+ vm.expectRevert(
46
+ abi.encodeWithSelector(JBPermissioned.JBPermissioned_Unauthorized.selector, alice, operator, projectId, 0)
47
+ );
48
+ ownable.protectedMethod();
49
+ }
50
+
51
+ /// @notice Direct owner still works when permissionId=0.
52
+ function test_directOwnerStillWorksWithPermissionIdZero() public {
53
+ uint256 projectId = projects.createFor(alice);
54
+ MockOwnable ownable = new MockOwnable(projects, permissions, address(0), uint88(projectId));
55
+
56
+ (, uint88 storedProjectId, uint8 permissionId) = ownable.jbOwner();
57
+ assertEq(storedProjectId, projectId);
58
+ assertEq(permissionId, 0);
59
+
60
+ // Alice (project owner) should still be able to call the protected method.
61
+ vm.prank(alice);
62
+ ownable.protectedMethod();
63
+ }
64
+
65
+ /// @notice Non-zero permissionId still delegates correctly via the permission system.
66
+ function test_delegatedOperatorWorksWhenPermissionIdNonZero() public {
67
+ uint256 projectId = projects.createFor(alice);
68
+ MockOwnable ownable = new MockOwnable(projects, permissions, address(0), uint88(projectId));
69
+
70
+ // Set permissionId to 42 (non-zero = delegation enabled).
71
+ vm.prank(alice);
72
+ ownable.setPermissionId(42);
73
+
74
+ // Grant permission 42 to operator.
75
+ uint8[] memory permissionIds = new uint8[](1);
76
+ permissionIds[0] = 42;
77
+
78
+ vm.prank(alice);
79
+ permissions.setPermissionsFor(
80
+ alice, JBPermissionsData({operator: operator, projectId: uint56(projectId), permissionIds: permissionIds})
81
+ );
82
+
83
+ // Operator should succeed with matching permissionId.
84
+ vm.prank(operator);
85
+ ownable.protectedMethod();
86
+ }
87
+ }