@bananapus/omnichain-deployers-v6 0.0.28 → 0.0.29

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/RISKS.md CHANGED
@@ -1,81 +1,92 @@
1
1
  # Omnichain Deployers Risk Register
2
2
 
3
- This file focuses on the risks in the deployer layer that launches Juicebox projects across chains while composing 721 hooks, suckers, and optional custom data hooks.
3
+ This file covers the risks in the deployer layer that launches Juicebox projects across chains while composing 721 hooks, suckers, and optional custom data hooks.
4
4
 
5
- ## How to use this file
5
+ ## How To Use This File
6
6
 
7
- - Read `Priority risks` first; they capture the highest-blast-radius deployment and cash-out assumptions.
8
- - Use the detailed sections for access control, integration, and reentrancy analysis.
9
- - Treat `Invariants to Verify` as required checks for any new omnichain deployment flow.
7
+ - Read `Priority risks` first. They capture the highest-blast-radius deployment and cash-out assumptions.
8
+ - Treat `Invariants to verify` as required checks for any new omnichain deployment flow.
10
9
 
11
- ## Priority risks
10
+ ## Priority Risks
12
11
 
13
12
  | Priority | Risk | Why it matters | Primary controls |
14
13
  |----------|------|----------------|------------------|
15
14
  | P0 | Registry-trusted sucker bypass | This deployer gives suckers privileged cash-out behavior based on registry answers. A bad registry entry can affect many projects. | Registry allowlists, deployment verification, and explicit registry scrutiny. |
16
15
  | P1 | Cross-chain deployment drift | Omnichain assumptions fail if chain-specific wiring, peers, or composed hooks do not match. | Deterministic deploy ordering, parity checks, and post-deploy peer verification. |
17
- | P1 | Data-hook composition mistakes | The deployer wraps or forwards custom data hooks; a bad composition can alter pay or cash-out semantics unexpectedly. | Integration tests for wrapped hooks and careful review of forwarding logic. |
18
-
16
+ | P1 | Data-hook composition mistakes | The deployer wraps or forwards custom data hooks. A bad composition can alter pay or cash-out semantics unexpectedly. | Integration tests and careful forwarding review. |
19
17
 
20
18
  ## 1. Trust Assumptions
21
19
 
22
- - **Trusted forwarder.** ERC-2771 `_msgSender()` is trusted to append the real sender. A compromised forwarder can impersonate any address for `deploySuckersFor`, `launchProjectFor`, `queueRulesetsOf`, and `launchRulesetsFor`.
23
- - **Sucker registry.** `SUCKER_REGISTRY.isSuckerOf()` is the sole gatekeeper for 0% cashout tax and mint permission. A compromised or malicious registry lets any address bypass cashout taxes and mint tokens freely.
24
- - **Controller trust.** The deployer passes an arbitrary `IJBController controller` parameter. `_validateController` checks `controller.DIRECTORY().controllerOf(projectId)` (a reflexive lookup through the controller's own directory reference), but during `launchProjectFor` the project does not yet exist, so no pre-launch controller validation is possible. The post-launch safeguard is the explicit project-ID match check on `controller.launchProjectFor(...)`.
25
- - **Extra data hooks.** Arbitrary `IJBRulesetDataHook` addresses from ruleset metadata are stored and called directly from the deployer's pay/cash-out wrapper paths. A malicious hook can return arbitrary weight, cashout tax rate, or hook specifications, and can also consume all available gas or revert.
20
+ - **Trusted forwarder is trusted.**
21
+ - **Sucker registry answers are trusted.**
22
+ - **Controller trust matters.**
23
+ - **Extra data hooks are trusted code.**
26
24
 
27
- ## 2. Economic / Manipulation Risks
25
+ ## 2. Economic Risks
28
26
 
29
- - **Sucker cashout bypass.** Any address registered as a sucker for a project gets 0% cashout tax rate and full reclaim. If a malicious sucker is registered (via compromised `SUCKER_REGISTRY`), it can drain the project's surplus.
30
- - **Weight manipulation via extra data hook.** `beforePayRecordedWith` forwards to the extra data hook, which can return any `weight`. A malicious hook can inflate token minting or set weight=0 to block minting.
31
- - **721 hook amount splitting.** The deployer computes `projectAmount = context.amount.value - totalSplitAmount`. The 721 hook's returned weight (already adjusted for splits via `JB721TiersHookLib.calculateWeight`) is used directly -- no proportional scaling is applied. If the 721 hook returns a `totalSplitAmount >= context.amount.value`, `projectAmount` is set to 0 and weight becomes 0 -- no tokens are minted for the payment.
32
- - **Cross-chain sender dependence in sucker deployment salts.** `deploySuckersFor` salts deployments with `keccak256(abi.encode(userSalt, _msgSender()))`. This prevents replay collisions, but it also means the same logical project deployed by different operators on different chains will not get matching deterministic sucker addresses.
27
+ - **Sucker cashout bypass exists for registered suckers.**
28
+ - **Extra data hooks can manipulate weight or cash-out behavior.**
29
+ - **721 hook amount splitting can zero out project amount in edge cases.**
30
+ - **Cross-chain sender dependence affects deterministic sucker salts.**
33
31
 
34
32
  ## 3. Access Control
35
33
 
36
- - **Wildcard MAP_SUCKER_TOKEN permission.** Constructor grants `SUCKER_REGISTRY` the `MAP_SUCKER_TOKEN` permission with `projectId=0` (wildcard). This grants the registry token-mapping rights across all projects ever deployed through this deployer.
37
- - **Permission checks on `launchRulesetsFor`.** Requires both `LAUNCH_RULESETS` and `SET_TERMINALS` from the project owner. If an operator has one but not the other, the call reverts. No combined permission ID exists.
38
- - **No permission check on `launchProjectFor`.** Anyone can call it because a new project is being created. The `owner` parameter receives the project NFT -- verify frontends do not allow this to be set to unexpected addresses.
34
+ - **Wildcard `MAP_SUCKER_TOKEN` permission is broad.**
35
+ - **`launchRulesetsFor` requires combined permissions.**
36
+ - **`launchProjectFor` is intentionally permissionless for new projects.**
39
37
 
40
38
  ## 4. DoS Vectors
41
39
 
42
- - **Ruleset ID collision.** `_setup721` stores hook configs at `block.timestamp + i`. If `latestRulesetIdOf >= block.timestamp` (rulesets already queued this block), `queueRulesetsOf` reverts with `RulesetIdsUnpredictable`. An attacker who queues rulesets in the same block as the legitimate owner can front-run and block their queue attempt. Gas impact: `queueRulesetsOf` costs ~200-400k gas per ruleset queued. The collision only occurs when two transactions queue rulesets in the same block for the same project — race condition window is one block (~12 seconds on L1, 2 seconds on L2).
43
- - **External hook revert.** `beforePayRecordedWith` and `beforeCashOutRecordedWith` call external hooks without try-catch. A reverting hook blocks all payments or cashouts for that project/ruleset. For cash-outs, if the 721 hook reverts, the custom hook is never reached (the revert propagates before it). Gas impact: the direct call into the extra data hook has no gas limit, so a gas-griefing hook can consume the entire transaction gas. The 721 hook call is similarly unbounded.
44
- - **721 hook deployment revert.** `HOOK_DEPLOYER.deployHookFor` is called without try-catch. A failing deployment blocks the entire project launch.
45
- - **Unexpected safe NFT receipt reverts, but non-safe transfers can still strand assets.** `onERC721Received` only accepts `PROJECTS` NFTs. Safe transfers of any other ERC-721 revert instead of being accepted. The narrower residual risk is `transferFrom` or other non-safe delivery into the deployer, which bypasses the receiver hook and has no generalized rescue path.
40
+ - **Ruleset ID collision can block queueing.**
41
+ - **External hook reverts can block pay or cash-out flows.**
42
+ - **721 hook deployment revert blocks launch.**
43
+ - **Non-safe NFT transfers can still strand assets.**
46
44
 
47
45
  ## 5. Reentrancy Surface
48
46
 
49
- - **`launchProjectFor` external call chain.** The function makes external calls to: (1) `_deploy721Hook()` via `HOOK_DEPLOYER.deployHookFor()` (deploys 721 hook clone), (2) `controller.launchProjectFor()` (creates project, deploys rulesets), (3) `JBOwnable(hook).transferOwnershipToProject()` (transfers hook ownership to the new project), (4) `SUCKER_REGISTRY.deploySuckersFor()` (deploys suckers if configured), (5) `PROJECTS.transferFrom()` (transfers the project NFT to the owner). None of these calls are try-catch wrapped — a revert in any of them fails the entire launch. Reentrancy from the controller callback during project creation could call back into `launchProjectFor`, but the new project would get a different ID (monotonically incrementing), so state corruption is not possible.
50
- - **`beforePayRecordedWith` delegates to external hooks.** Calls `IJBRulesetDataHook(tiered721Hook).beforePayRecordedWith(context)` (not try-caught) and optionally calls the extra data hook directly. The 721 hook and extra hook can execute arbitrary code. At callback time, no deployer state has been modified (the deployer is stateless during payments — it only routes). Reentrancy through the pay path processes as an independent payment.
51
- - **`beforeCashOutRecordedWith` delegates to external hooks.** Same pattern as pay: calls the 721 hook (not try-caught), then optionally the extra data hook. Sucker check via `SUCKER_REGISTRY.isSuckerOf` is a view call. No deployer state is modified during cashouts.
52
- - **No `ReentrancyGuard`.** Safe because the deployer is effectively stateless during pay/cashout operations — it reads `_tiered721HookOf` and `_extraDataHookOf` mappings but never writes them outside of deployment functions.
47
+ - **`launchProjectFor` makes several external calls in sequence.**
48
+ - **`beforePayRecordedWith` delegates to external hooks.**
49
+ - **`beforeCashOutRecordedWith` delegates to external hooks.**
50
+ - **There is no `ReentrancyGuard`.** The deployer relies on being effectively stateless during pay and cash-out operations.
53
51
 
54
52
  ## 6. Integration Risks
55
53
 
56
- - **Hook config keyed by predicted rulesetId.** Configs stored at `block.timestamp + i` must match the actual rulesetId assigned by the controller. If the controller assigns different IDs (e.g., due to approval hook delays), the stored configs become unreachable -- payments/cashouts fall through to default behavior (no 721 handling, no extra hook).
57
- - **Carried-forward 721 hook on queue.** When `tiers.length == 0`, `queueRulesetsOf` carries forward the hook from a previous ruleset. The source is selected by first checking `latestQueuedOf(projectId)` — if the queued ruleset's approval status is `Approved` or `Empty` and it has a stored hook config, that config is used. Otherwise it falls back to `currentOf(projectId)`. If neither has a hook deployed through this deployer, the mapping is empty and the call reverts with `JBOmnichainDeployer_InvalidHook`. The `useDataHookForCashOut` flag is also preserved from whichever source ruleset is selected.
58
- - **ERC721Receiver restriction.** `onERC721Received` only accepts from `PROJECTS`. Non-project NFTs sent via `safeTransferFrom` revert cleanly; non-safe transfers can still become stuck because the deployer has no generalized rescue function.
59
- - **Empty simplified launch config reverts.** The convenience overload that derives a default 721 config from `rulesetConfigurations[0]` reverts with `JBOmnichainDeployer_NoRulesetConfigurations()` when called with an empty ruleset array.
60
- - **Cross-reference: sucker registration.** The deployer grants `MAP_SUCKER_TOKEN` to `SUCKER_REGISTRY` with `projectId=0` (wildcard). This means the registry can map tokens for ALL projects deployed through this deployer. See [nana-suckers-v6 RISKS.md](../nana-suckers-v6/RISKS.md) for the full sucker lifecycle risks.
61
- - **Cross-reference: core reentrancy.** The deployer delegates to `JBController` and `JBMultiTerminal` for all fund operations. See [nana-core-v6 RISKS.md](../nana-core-v6/RISKS.md) section 3 for the reentrancy surface of these contracts.
54
+ - **Hook config is keyed by predicted ruleset ID.**
55
+ - **Carried-forward 721 hook behavior on queue depends on prior ruleset state.**
56
+ - **ERC721Receiver restrictions are narrow but non-safe transfers can still strand assets.**
57
+ - **Empty simplified launch config reverts.**
62
58
 
63
- ## 7. Invariants to Verify
59
+ ## 7. Invariants To Verify
64
60
 
65
- - For any project launched through this deployer, `DIRECTORY.controllerOf(projectId)` matches the controller used during launch.
66
- - `_tiered721HookOf[projectId][rulesetId]` is non-zero for every rulesetId created through this deployer.
67
- - Sucker cashouts always receive 0% tax rate (no path where `isSuckerOf` returns true but tax > 0).
68
- - `beforePayRecordedWith` uses the 721 hook's weight directly (already split-adjusted by `JB721TiersHookLib.calculateWeight`), so no additional scaling is applied.
69
- - Self-reference prevention: `rulesetConfigurations[i].metadata.dataHook` cannot be `address(this)` after `_setup721`.
70
- - Project NFT ownership: after `_launchProjectFor`, the project NFT is owned by `owner`, not the deployer.
71
- - `deploySuckersFor` uses the same `_msgSender()` on every chain when deterministic cross-chain peer symmetry is expected.
61
+ - launched projects point at the intended controller
62
+ - stored 721 hook config exists for every ruleset created through this deployer
63
+ - sucker cashouts always get the intended zero-tax path
64
+ - self-reference prevention holds after setup
65
+ - the project NFT ends owned by the intended owner
72
66
 
73
67
  ## 8. Accepted Behaviors
74
68
 
75
- ### 8.1 Controller validation skipped during `launchProjectFor` (by design)
69
+ ### 8.1 Controller validation is skipped during initial launch
70
+
71
+ Pre-launch controller validation is impossible because the project does not yet exist. The accepted safeguard is the post-launch project ID match check.
72
+
73
+ ### 8.2 Registered suckers receive 0% cashout tax
74
+
75
+ This is intentional and shares the same trust boundary as the sucker registry.
76
+
77
+ ## 9. Accepted Security Risks
78
+
79
+ Documented risks that were reviewed and accepted.
80
+
81
+ ### Configuration Risks
82
+
83
+ **Unvalidated extra data hooks can brick live flows.** *(Minor)*
84
+ Extra data hooks provided by the project owner in `_setup721` configuration can fail and brick live pay/cashout flows. Accepted because this is self-inflicted misconfiguration — only the project owner can set these hooks.
76
85
 
77
- `_validateController` checks `controller.DIRECTORY().controllerOf(projectId)` to verify the provided controller matches the project's registered controller. During `launchProjectFor`, the project does not yet exist, so no directory entry exists and no pre-launch validation is possible. The accepted safeguard is the explicit `ProjectIdMismatch` check immediately after `controller.launchProjectFor()` returns. This is accepted because: (1) the project is created atomically within the same transaction, (2) the caller provides the controller address, so they choose their own trust boundary, and (3) validating against a non-existent project would always fail, making the check useless.
86
+ **Missing hook721 alias check enables double invocation.** *(Minor)*
87
+ If the project owner configures the 721 hook as both the primary hook and as an extra data hook, it could be invoked twice. Accepted because this is self-inflicted misconfiguration — the deployer correctly processes each hook independently.
78
88
 
79
- ### 8.2 Suckers receive 0% cashout tax (shared with revnet-core)
89
+ ### Cross-Chain Deployment
80
90
 
81
- `beforeCashOutRecordedWith` returns `cashOutTaxRate = 0` for addresses registered in `SUCKER_REGISTRY`. This is the same trust model as REVDeployer. The security boundary is the sucker registry — only addresses deployed through authorized deployers receive this privilege. See [revnet-core-v6 RISKS.md](../revnet-core-v6/RISKS.md) section 4 for the full sucker bypass analysis.
91
+ **`_msgSender()` in deployment salt breaks cross-chain determinism.** *(Minor)*
92
+ `deploySuckersFor` includes `_msgSender()` in the CREATE2 salt, which means the same deployment from different callers produces different addresses across chains. Accepted because this is intentional replay protection — prevents frontrunning of cross-chain deployments.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/omnichain-deployers-v6",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,12 +17,12 @@
17
17
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-omnichain-deployers-v6'"
18
18
  },
19
19
  "dependencies": {
20
- "@bananapus/721-hook-v6": "^0.0.35",
21
- "@bananapus/buyback-hook-v6": "^0.0.27",
22
- "@bananapus/core-v6": "^0.0.34",
23
- "@bananapus/ownable-v6": "^0.0.17",
24
- "@bananapus/permission-ids-v6": "^0.0.17",
25
- "@bananapus/suckers-v6": "^0.0.25",
20
+ "@bananapus/721-hook-v6": "^0.0.38",
21
+ "@bananapus/buyback-hook-v6": "^0.0.30",
22
+ "@bananapus/core-v6": "^0.0.36",
23
+ "@bananapus/ownable-v6": "^0.0.20",
24
+ "@bananapus/permission-ids-v6": "^0.0.19",
25
+ "@bananapus/suckers-v6": "^0.0.28",
26
26
  "@openzeppelin/contracts": "^5.6.1",
27
27
  "@uniswap/v4-core": "^1.0.2"
28
28
  },
@@ -8,6 +8,7 @@ import {CoreDeployment, CoreDeploymentLib} from "@bananapus/core-v6/script/helpe
8
8
  import {Hook721Deployment, Hook721DeploymentLib} from "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
9
9
  import {SuckerDeployment, SuckerDeploymentLib} from "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
10
10
 
11
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
11
12
  import {JBOmnichainDeployer} from "src/JBOmnichainDeployer.sol";
12
13
 
13
14
  contract Deploy is Script, Sphinx {
@@ -61,7 +62,12 @@ contract Deploy is Script, Sphinx {
61
62
  salt: NANA_OMNICHAIN_DEPLOYER_SALT,
62
63
  creationCode: type(JBOmnichainDeployer).creationCode,
63
64
  arguments: abi.encode(
64
- suckers.registry, hook.hook_deployer, core.permissions, core.projects, core.trustedForwarder
65
+ suckers.registry,
66
+ hook.hook_deployer,
67
+ core.permissions,
68
+ core.projects,
69
+ core.directory,
70
+ core.trustedForwarder
65
71
  ),
66
72
  deployer: safeAddress()
67
73
  })) {
@@ -70,6 +76,7 @@ contract Deploy is Script, Sphinx {
70
76
  hookDeployer: hook.hook_deployer,
71
77
  permissions: core.permissions,
72
78
  projects: core.projects,
79
+ directory: IJBDirectory(address(core.directory)),
73
80
  trustedForwarder: core.trustedForwarder
74
81
  });
75
82
  }
@@ -6,6 +6,7 @@ import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB
6
6
  import {JBApprovalStatus} from "@bananapus/core-v6/src/enums/JBApprovalStatus.sol";
7
7
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
8
8
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
9
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
9
10
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
10
11
  import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
11
12
  import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
@@ -79,6 +80,10 @@ contract JBOmnichainDeployer is
79
80
  /// @notice Deploys and tracks suckers for projects.
80
81
  IJBSuckerRegistry public immutable SUCKER_REGISTRY;
81
82
 
83
+ /// @notice The directory used to validate controllers. Stored as immutable to prevent a user-provided
84
+ /// controller from returning a fake directory that confirms itself.
85
+ IJBDirectory public immutable DIRECTORY;
86
+
82
87
  //*********************************************************************//
83
88
  // -------------------- internal stored properties ------------------- //
84
89
  //*********************************************************************//
@@ -101,12 +106,14 @@ contract JBOmnichainDeployer is
101
106
  /// @param hookDeployer The deployer to use for project's tiered ERC-721 hooks.
102
107
  /// @param permissions The permissions to use for the contract.
103
108
  /// @param projects The projects to use for the contract.
109
+ /// @param directory The directory used to validate controllers against a trusted source.
104
110
  /// @param trustedForwarder The trusted forwarder for the ERC2771Context.
105
111
  constructor(
106
112
  IJBSuckerRegistry suckerRegistry,
107
113
  IJB721TiersHookDeployer hookDeployer,
108
114
  IJBPermissions permissions,
109
115
  IJBProjects projects,
116
+ IJBDirectory directory,
110
117
  address trustedForwarder
111
118
  )
112
119
  JBPermissioned(permissions)
@@ -115,6 +122,7 @@ contract JBOmnichainDeployer is
115
122
  PROJECTS = projects;
116
123
  SUCKER_REGISTRY = suckerRegistry;
117
124
  HOOK_DEPLOYER = hookDeployer;
125
+ DIRECTORY = directory;
118
126
 
119
127
  // Give the sucker registry permission to map tokens for all revnets.
120
128
  uint8[] memory permissionIds = new uint8[](1);
@@ -715,8 +723,9 @@ contract JBOmnichainDeployer is
715
723
  });
716
724
  }
717
725
 
718
- // Transfer ownership of the project to the owner.
719
- PROJECTS.transferFrom({from: address(this), to: owner, tokenId: projectId});
726
+ // Transfer ownership of the project to the owner. Uses safeTransferFrom so contract receivers
727
+ // get an onERC721Received callback.
728
+ PROJECTS.safeTransferFrom({from: address(this), to: owner, tokenId: projectId});
720
729
  }
721
730
 
722
731
  /// @notice Internal implementation of `launchRulesetsFor`.
@@ -920,13 +929,12 @@ contract JBOmnichainDeployer is
920
929
  }
921
930
 
922
931
  /// @notice Validates that the provided controller matches the project's controller in the directory.
923
- /// @dev The reflexive lookup (controller.DIRECTORY().controllerOf()) is intentional it confirms the
924
- /// caller-provided controller is the one the directory recognizes for this project, preventing a
925
- /// malicious controller from being passed in.
932
+ /// @dev Uses the immutable DIRECTORY instead of querying the controller, preventing a malicious
933
+ /// controller from returning a fake directory that confirms itself.
926
934
  /// @param projectId The ID of the project to validate the controller for.
927
935
  /// @param controller The controller to validate.
928
936
  function _validateController(uint256 projectId, IJBController controller) internal view {
929
- address current = address(controller.DIRECTORY().controllerOf(projectId));
937
+ address current = address(DIRECTORY.controllerOf(projectId));
930
938
  // Allow address(0) for fresh projects that haven't launched rulesets yet.
931
939
  if (current != address(0) && current != address(controller)) {
932
940
  revert JBOmnichainDeployer_ControllerMismatch();
@@ -42,6 +42,7 @@ contract TestJBOmnichainDeployer is Test {
42
42
  IJBProjects projects = IJBProjects(makeAddr("projects"));
43
43
  IJBSuckerRegistry suckerRegistry = IJBSuckerRegistry(makeAddr("suckerRegistry"));
44
44
  IJB721TiersHookDeployer hookDeployer = IJB721TiersHookDeployer(makeAddr("hookDeployer"));
45
+ IJBDirectory directory = IJBDirectory(makeAddr("directory"));
45
46
 
46
47
  address projectOwner = makeAddr("projectOwner");
47
48
  address sucker = makeAddr("sucker");
@@ -63,6 +64,7 @@ contract TestJBOmnichainDeployer is Test {
63
64
  hookDeployer,
64
65
  permissions,
65
66
  projects,
67
+ directory,
66
68
  address(0) // trustedForwarder
67
69
  );
68
70
 
@@ -331,11 +333,7 @@ contract TestJBOmnichainDeployer is Test {
331
333
 
332
334
  function test_launchRulesetsFor_simplified_usesDefaultCurrency() public {
333
335
  IJBController controller = IJBController(makeAddr("controller"));
334
- IJBDirectory directory = IJBDirectory(makeAddr("directory"));
335
336
 
336
- vm.mockCall(
337
- address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
338
- );
339
337
  vm.mockCall(
340
338
  address(directory),
341
339
  abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
@@ -369,7 +367,6 @@ contract TestJBOmnichainDeployer is Test {
369
367
  function test_queueRulesetsOf_simplified_carriesForwardHook() public {
370
368
  // First launch a project so there's a hook to carry forward.
371
369
  IJBController controller = IJBController(makeAddr("controller"));
372
- IJBDirectory directory = IJBDirectory(makeAddr("directory"));
373
370
  IJBRulesets rulesets = IJBRulesets(makeAddr("rulesets"));
374
371
 
375
372
  vm.mockCall(address(projects), abi.encodeWithSelector(IJBProjects.count.selector), abi.encode(uint256(41)));
@@ -391,9 +388,6 @@ contract TestJBOmnichainDeployer is Test {
391
388
  deployer.launchProjectFor(projectOwner, "test", configs, terminals, "", _emptySuckerConfig(), controller);
392
389
 
393
390
  // Now set up mocks for queueRulesetsOf.
394
- vm.mockCall(
395
- address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
396
- );
397
391
  vm.mockCall(
398
392
  address(directory),
399
393
  abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
@@ -462,7 +456,6 @@ contract TestJBOmnichainDeployer is Test {
462
456
  function test_queueRulesetsOf_carryForward_preservesCashOutFlag() public {
463
457
  // --- Step 1: Launch a project with useDataHookForCashOut = true ---
464
458
  IJBController controller = IJBController(makeAddr("controller"));
465
- IJBDirectory directory = IJBDirectory(makeAddr("directory"));
466
459
  IJBRulesets rulesets = IJBRulesets(makeAddr("rulesets"));
467
460
 
468
461
  vm.mockCall(address(projects), abi.encodeWithSelector(IJBProjects.count.selector), abi.encode(uint256(41)));
@@ -495,9 +488,6 @@ contract TestJBOmnichainDeployer is Test {
495
488
  assertTrue(initialCashOutFlag, "initial launch should store useDataHookForCashOut = true");
496
489
 
497
490
  // --- Step 2: Queue a new ruleset with no tiers (carry-forward) ---
498
- vm.mockCall(
499
- address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
500
- );
501
491
  vm.mockCall(
502
492
  address(directory),
503
493
  abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
@@ -126,6 +126,7 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
126
126
  IJB721TiersHookDeployer(mockHookDeployerAddr),
127
127
  IJBPermissions(address(jbPermissions())),
128
128
  IJBProjects(address(jbProjects())),
129
+ IJBDirectory(address(jbDirectory())),
129
130
  trustedForwarder()
130
131
  );
131
132
 
@@ -4,6 +4,7 @@ pragma solidity 0.8.28;
4
4
  import {Test} from "forge-std/Test.sol";
5
5
 
6
6
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
7
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
8
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
8
9
  import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
9
10
  import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
@@ -97,6 +98,7 @@ contract OmnichainDeployerAttacks is Test {
97
98
  IJBProjects projects = IJBProjects(makeAddr("projects"));
98
99
  IJBSuckerRegistry suckerRegistry = IJBSuckerRegistry(makeAddr("suckerRegistry"));
99
100
  IJB721TiersHookDeployer hookDeployer = IJB721TiersHookDeployer(makeAddr("hookDeployer"));
101
+ IJBDirectory directory = IJBDirectory(makeAddr("directory"));
100
102
 
101
103
  address projectOwner = makeAddr("projectOwner");
102
104
  address sucker = makeAddr("sucker");
@@ -119,7 +121,7 @@ contract OmnichainDeployerAttacks is Test {
119
121
  address(permissions), abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode()
120
122
  );
121
123
 
122
- deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, address(0));
124
+ deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, directory, address(0));
123
125
 
124
126
  // Default mocks.
125
127
  vm.mockCall(
@@ -113,6 +113,7 @@ contract OmnichainDeployerEdgeCases is Test {
113
113
  IJBProjects projects = IJBProjects(makeAddr("projects"));
114
114
  IJBSuckerRegistry suckerRegistry = IJBSuckerRegistry(makeAddr("suckerRegistry"));
115
115
  IJB721TiersHookDeployer hookDeployer = IJB721TiersHookDeployer(makeAddr("hookDeployer"));
116
+ IJBDirectory directory = IJBDirectory(makeAddr("directory"));
116
117
 
117
118
  address projectOwner = makeAddr("projectOwner");
118
119
  address sucker = makeAddr("sucker");
@@ -131,7 +132,7 @@ contract OmnichainDeployerEdgeCases is Test {
131
132
  address(permissions), abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode()
132
133
  );
133
134
 
134
- deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, address(0));
135
+ deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, directory, address(0));
135
136
 
136
137
  vm.mockCall(
137
138
  address(projects), abi.encodeWithSelector(IERC721.ownerOf.selector, projectId), abi.encode(projectOwner)
@@ -575,12 +576,8 @@ contract OmnichainDeployerEdgeCases is Test {
575
576
  // =========================================================================
576
577
  function test_launchRulesetsFor_requiresLaunchRulesetsPermission() public {
577
578
  IJBController controller = IJBController(makeAddr("controller"));
578
- IJBDirectory directory = IJBDirectory(makeAddr("directory"));
579
579
 
580
- // Mock controller validation chain.
581
- vm.mockCall(
582
- address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
583
- );
580
+ // Mock controllerOf on the deployer's immutable DIRECTORY.
584
581
  vm.mockCall(
585
582
  address(directory),
586
583
  abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
@@ -609,13 +606,9 @@ contract OmnichainDeployerEdgeCases is Test {
609
606
  _launchProjectWithHook(address(0));
610
607
 
611
608
  IJBController controller = IJBController(makeAddr("controller"));
612
- IJBDirectory directory = IJBDirectory(makeAddr("directory"));
613
609
  IJBRulesets rulesets = IJBRulesets(makeAddr("rulesets"));
614
610
 
615
- // Mock controller validation chain.
616
- vm.mockCall(
617
- address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
618
- );
611
+ // Mock controllerOf on the deployer's immutable DIRECTORY.
619
612
  vm.mockCall(
620
613
  address(directory),
621
614
  abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
@@ -279,7 +279,7 @@ contract TestAuditGaps is Test {
279
279
  vm.mockCall(
280
280
  address(permissions), abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode()
281
281
  );
282
- deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, address(0));
282
+ deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, directory, address(0));
283
283
 
284
284
  // Default mocks.
285
285
  vm.mockCall(
@@ -292,9 +292,7 @@ contract TestAuditGaps is Test {
292
292
  vm.mockCall(
293
293
  address(controller), abi.encodeWithSelector(IJBController.launchProjectFor.selector), abi.encode(projectId)
294
294
  );
295
- vm.mockCall(
296
- address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
297
- );
295
+ // Mock controllerOf on the deployer's immutable DIRECTORY.
298
296
  vm.mockCall(
299
297
  address(directory),
300
298
  abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
@@ -804,21 +802,9 @@ contract TestAuditGaps is Test {
804
802
 
805
803
  vm.warp(BASE_TIME + 10);
806
804
 
807
- // Mock a different controller for the project.
805
+ // The deployer's immutable DIRECTORY returns `controller` for this project.
806
+ // Passing a different controller should trigger ControllerMismatch.
808
807
  IJBController wrongController = IJBController(makeAddr("wrongController"));
809
- IJBDirectory wrongDirectory = IJBDirectory(makeAddr("wrongDirectory"));
810
-
811
- vm.mockCall(
812
- address(wrongController),
813
- abi.encodeWithSelector(IJBController.DIRECTORY.selector),
814
- abi.encode(wrongDirectory)
815
- );
816
- // controllerOf returns a different address than wrongController.
817
- vm.mockCall(
818
- address(wrongDirectory),
819
- abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),
820
- abi.encode(IERC165(makeAddr("otherController")))
821
- );
822
808
 
823
809
  JBRulesetConfig[] memory configs = new JBRulesetConfig[](1);
824
810
  configs[0] = _makeRulesetConfig(address(0), false, false);
@@ -65,7 +65,7 @@ contract Tiered721HookComposition is Test {
65
65
  vm.mockCall(
66
66
  address(permissions), abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode()
67
67
  );
68
- deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, address(0));
68
+ deployer = new JBOmnichainDeployer(suckerRegistry, hookDeployer, permissions, projects, directory, address(0));
69
69
  vm.mockCall(
70
70
  address(projects), abi.encodeWithSelector(IERC721.ownerOf.selector, projectId), abi.encode(projectOwner)
71
71
  );
@@ -76,9 +76,7 @@ contract Tiered721HookComposition is Test {
76
76
  vm.mockCall(
77
77
  address(controller), abi.encodeWithSelector(IJBController.launchProjectFor.selector), abi.encode(projectId)
78
78
  );
79
- vm.mockCall(
80
- address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
81
- );
79
+ // Mock controllerOf on the deployer's immutable DIRECTORY.
82
80
  vm.mockCall(
83
81
  address(directory),
84
82
  abi.encodeWithSelector(IJBDirectory.controllerOf.selector, projectId),