@bananapus/omnichain-deployers-v6 0.0.28 → 0.0.30

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.
Files changed (34) hide show
  1. package/RISKS.md +62 -46
  2. package/package.json +8 -8
  3. package/script/Deploy.s.sol +8 -1
  4. package/src/JBOmnichainDeployer.sol +64 -19
  5. package/test/JBOmnichainDeployer.t.sol +2 -12
  6. package/test/JBOmnichainDeployerGuard.t.sol +45 -8
  7. package/test/OmnichainDeployerAttacks.t.sol +3 -1
  8. package/test/OmnichainDeployerEdgeCases.t.sol +12 -17
  9. package/test/OmnichainDeployerReentrancy.t.sol +8 -8
  10. package/test/TestAuditGaps.sol +4 -18
  11. package/test/Tiered721HookComposition.t.sol +13 -10
  12. package/test/audit/AuditFixesC2H6M14.t.sol +480 -0
  13. package/test/audit/CarryForwardRejectedHook.t.sol +6 -5
  14. package/test/audit/CashOutSpecMerge.t.sol +372 -0
  15. package/test/audit/DeterministicDrift.t.sol +78 -0
  16. package/test/audit/DeterministicPeerDrift.t.sol +79 -0
  17. package/test/audit/ExtraCashOutHookZeroReclaim.t.sol +340 -0
  18. package/test/audit/ForwardedPermissions.t.sol +297 -0
  19. package/test/audit/JBOmnichainDeployer.t.sol +16 -12
  20. package/test/audit/NftCashoutSupplyMismatch.t.sol +224 -0
  21. package/test/audit/{CodexNemesisAudit.t.sol → OmnichainAudit.t.sol} +9 -7
  22. package/test/audit/SplitCreditWeight.t.sol +437 -0
  23. package/test/audit/WeightScalingComparison.t.sol +14 -13
  24. package/test/fork/OmnichainForkTestBase.sol +5 -3
  25. package/test/fork/TestOmnichain721QueueAndAdjust.t.sol +5 -5
  26. package/test/fork/TestOmnichainCashOutFork.t.sol +42 -40
  27. package/test/fork/TestOmnichainStressFork.t.sol +87 -95
  28. package/test/fork/TestOmnichainWeightFork.t.sol +9 -27
  29. package/test/invariants/CrossChainDeployerInvariant.t.sol +208 -0
  30. package/test/invariants/OmnichainDeployerInvariant.t.sol +3 -5
  31. package/test/invariants/handlers/CrossChainDeployerHandler.sol +394 -0
  32. package/test/regression/EmptyRulesetConfigurations.t.sol +3 -5
  33. package/test/regression/HookOwnershipTransfer.t.sol +2 -5
  34. package/test/regression/ValidateController.t.sol +3 -11
package/RISKS.md CHANGED
@@ -1,81 +1,97 @@
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.
85
+
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.
88
+
89
+ ### Hook Selection
76
90
 
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.
91
+ **ApprovalExpected rulesets excluded from hook carry-forward.**
92
+ When no new tiers are provided, the deployer carries forward the 721 hook from the most recent approved ruleset. Rulesets with `ApprovalExpected` status are intentionally excluded even though they may become active. Hook selection is irreversible — if the pending ruleset is later rejected by the approval hook, we'd have locked in a hook from a ruleset that never became active. The deployer falls back to the current (already-approved) ruleset in this case.
78
93
 
79
- ### 8.2 Suckers receive 0% cashout tax (shared with revnet-core)
94
+ ### Cross-Chain Deployment
80
95
 
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.
96
+ **`_msgSender()` in deployment salt breaks cross-chain determinism.** *(Minor)*
97
+ `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.30",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,17 +17,17 @@
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.41",
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
  },
29
29
  "devDependencies": {
30
- "@bananapus/address-registry-v6": "^0.0.17",
30
+ "@bananapus/address-registry-v6": "^0.0.20",
31
31
  "@sphinx-labs/plugins": "^0.33.2"
32
32
  }
33
33
  }
@@ -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";
@@ -29,6 +30,7 @@ import {JBDeployerHookConfig} from "./structs/JBDeployerHookConfig.sol";
29
30
  import {JBOmnichain721Config} from "./structs/JBOmnichain721Config.sol";
30
31
  import {JBSuckerDeploymentConfig} from "./structs/JBSuckerDeploymentConfig.sol";
31
32
  import {JBTiered721HookConfig} from "./structs/JBTiered721HookConfig.sol";
33
+ import {mulDiv} from "@prb/math/src/Common.sol";
32
34
 
33
35
  /// @notice Deploys, manages, and operates Juicebox projects with suckers.
34
36
  // Project NFTs sent to this contract are not recoverable. The deployer does not
@@ -79,6 +81,10 @@ contract JBOmnichainDeployer is
79
81
  /// @notice Deploys and tracks suckers for projects.
80
82
  IJBSuckerRegistry public immutable SUCKER_REGISTRY;
81
83
 
84
+ /// @notice The directory used to validate controllers. Stored as immutable to prevent a user-provided
85
+ /// controller from returning a fake directory that confirms itself.
86
+ IJBDirectory public immutable DIRECTORY;
87
+
82
88
  //*********************************************************************//
83
89
  // -------------------- internal stored properties ------------------- //
84
90
  //*********************************************************************//
@@ -101,12 +107,14 @@ contract JBOmnichainDeployer is
101
107
  /// @param hookDeployer The deployer to use for project's tiered ERC-721 hooks.
102
108
  /// @param permissions The permissions to use for the contract.
103
109
  /// @param projects The projects to use for the contract.
110
+ /// @param directory The directory used to validate controllers against a trusted source.
104
111
  /// @param trustedForwarder The trusted forwarder for the ERC2771Context.
105
112
  constructor(
106
113
  IJBSuckerRegistry suckerRegistry,
107
114
  IJB721TiersHookDeployer hookDeployer,
108
115
  IJBPermissions permissions,
109
116
  IJBProjects projects,
117
+ IJBDirectory directory,
110
118
  address trustedForwarder
111
119
  )
112
120
  JBPermissioned(permissions)
@@ -115,6 +123,7 @@ contract JBOmnichainDeployer is
115
123
  PROJECTS = projects;
116
124
  SUCKER_REGISTRY = suckerRegistry;
117
125
  HOOK_DEPLOYER = hookDeployer;
126
+ DIRECTORY = directory;
118
127
 
119
128
  // Give the sucker registry permission to map tokens for all revnets.
120
129
  uint8[] memory permissionIds = new uint8[](1);
@@ -426,10 +435,10 @@ contract JBOmnichainDeployer is
426
435
  // If a 721 hook is set and opted into cash out handling, let it adjust the cash out parameters.
427
436
  if (address(tiered721Config.hook) != address(0) && tiered721Config.useDataHookForCashOut) {
428
437
  // Forward to the 721 hook. It may change the tax rate, count, and return hook specs.
429
- // We discard the inner hook's effectiveSurplusValue — this contract computes the cross-chain values.
430
- // We also discard its totalSupply since this contract computes the cross-chain supply.
438
+ // Capture the 721 hook's totalSupply and effectiveSurplusValue — NFT cash-outs should use
439
+ // local-only denominators so holders reclaim against local surplus, not omnichain surplus.
431
440
  // slither-disable-next-line unused-return
432
- (cashOutTaxRate, cashOutCount,,, tiered721HookSpecifications) =
441
+ (cashOutTaxRate, cashOutCount, totalSupply, effectiveSurplusValue, tiered721HookSpecifications) =
433
442
  IJBRulesetDataHook(address(tiered721Config.hook)).beforeCashOutRecordedWith(context);
434
443
  }
435
444
 
@@ -444,16 +453,23 @@ contract JBOmnichainDeployer is
444
453
  // Build a mutable copy of the context with the latest values (possibly updated by the 721 hook).
445
454
  JBBeforeCashOutRecordedContext memory hookContext = context;
446
455
  hookContext.cashOutTaxRate = cashOutTaxRate;
447
- hookContext.cashOutCount = cashOutCount;
448
456
  hookContext.totalSupply = totalSupply;
449
457
  hookContext.surplus.value = effectiveSurplusValue;
450
458
 
451
- // Forward to the extra hook. It may further change the tax rate, count, and return hook specs.
452
- // We discard the inner hook's effectiveSurplusValue — this contract computes the cross-chain values.
453
- // We also discard its totalSupply since this contract computes the cross-chain supply.
454
- // slither-disable-next-line unused-return
455
- (cashOutTaxRate, cashOutCount,,, extraHookSpecifications) =
456
- extraHook.dataHook.beforeCashOutRecordedWith(hookContext);
459
+ // Forward to the extra hook. It may further change the tax rate and return hook specs.
460
+ // We always discard totalSupply and effectiveSurplusValue — this contract computes
461
+ // cross-chain values for both. When the 721 hook is active, we also discard cashOutCount
462
+ // because the 721 hook redefines both cashOutCount and totalSupply as NFT cash-out weights
463
+ // (sum of tier prices), not fungible token counts. Letting the extra hook override
464
+ // cashOutCount would corrupt NFT pricing in the bonding curve.
465
+ if (address(tiered721Config.hook) != address(0) && tiered721Config.useDataHookForCashOut) {
466
+ // slither-disable-next-line unused-return
467
+ (cashOutTaxRate,,,, extraHookSpecifications) = extraHook.dataHook.beforeCashOutRecordedWith(hookContext);
468
+ } else {
469
+ // slither-disable-next-line unused-return
470
+ (cashOutTaxRate, cashOutCount,,, extraHookSpecifications) =
471
+ extraHook.dataHook.beforeCashOutRecordedWith(hookContext);
472
+ }
457
473
  }
458
474
 
459
475
  // If neither hook returned any specifications, return the adjusted values with no hook specs.
@@ -502,6 +518,8 @@ contract JBOmnichainDeployer is
502
518
  bool hasTiered721Spec;
503
519
  // The weight returned by the 721 hook (already scaled for splits).
504
520
  uint256 tiered721Weight;
521
+ // The weight attributable to tier splits when issueTokensForSplits is true.
522
+ uint256 splitCreditWeight;
505
523
  // Whether a 721 hook is configured for this project's ruleset.
506
524
  bool has721Hook;
507
525
  if (address(tiered721Config.hook) != address(0)) {
@@ -518,6 +536,14 @@ contract JBOmnichainDeployer is
518
536
  hasTiered721Spec = true;
519
537
  tiered721HookSpec = tiered721HookSpecs[0];
520
538
  totalSplitAmount = tiered721HookSpec.amount;
539
+
540
+ // Decode splitCreditWeight from the 721 hook's metadata (4th field).
541
+ // When issueTokensForSplits is true and splits exist, this holds the weight portion
542
+ // attributable to tier splits — used to prevent split credit erasure if the extra
543
+ // hook (e.g. buyback) returns weight=0.
544
+ if (tiered721HookSpec.metadata.length >= 128) {
545
+ (,,, splitCreditWeight) = abi.decode(tiered721HookSpec.metadata, (address, address, bytes, uint256));
546
+ }
521
547
  }
522
548
  }
523
549
 
@@ -533,11 +559,27 @@ contract JBOmnichainDeployer is
533
559
  if (address(extraHook.dataHook) != address(0) && extraHook.useDataHookForPay) {
534
560
  JBBeforePayRecordedContext memory hookContext = context;
535
561
  hookContext.amount.value = projectAmount;
536
- // Pass the 721 hook's weight (which accounts for split deductions) so the data hook
537
- // makes its decisions (e.g. mint-vs-swap) based on the correct post-split weight.
538
- if (has721Hook) hookContext.weight = tiered721Weight;
562
+ // Pass the original context.weight NOT the 721 hook's split-adjusted weight.
563
+ // The extra hook (e.g. buyback) applies its own weight logic; using the 721 hook's
564
+ // already-split-adjusted weight would double-discount the split ratio.
539
565
  (weight, dataHookSpecs) = extraHook.dataHook.beforePayRecordedWith(hookContext);
540
566
  customHookCalled = true;
567
+
568
+ // The custom hook (e.g. buyback) returned a weight based on the original context.weight.
569
+ // If the 721 hook scaled weight down for tier splits, apply the same ratio so the terminal
570
+ // doesn't over-mint tokens relative to the funds actually entering the project.
571
+ // When issueTokensForSplits is true, tiered721Weight == context.weight and the ratio is 1x.
572
+ if (has721Hook && context.weight > 0 && tiered721Weight != context.weight) {
573
+ weight = mulDiv(weight, tiered721Weight, context.weight);
574
+ }
575
+
576
+ // When the extra hook returns weight=0 (e.g. buyback found no profitable swap) but tier
577
+ // splits exist with issueTokensForSplits=true, the split credit must still mint fungible tokens.
578
+ // The split credit weight is independent of buyback routing — it represents the token issuance
579
+ // for funds forwarded to tier split beneficiaries.
580
+ if (weight == 0 && splitCreditWeight > 0) {
581
+ weight = splitCreditWeight;
582
+ }
541
583
  }
542
584
  }
543
585
 
@@ -715,8 +757,9 @@ contract JBOmnichainDeployer is
715
757
  });
716
758
  }
717
759
 
718
- // Transfer ownership of the project to the owner.
719
- PROJECTS.transferFrom({from: address(this), to: owner, tokenId: projectId});
760
+ // Transfer ownership of the project to the owner. Uses safeTransferFrom so contract receivers
761
+ // get an onERC721Received callback.
762
+ PROJECTS.safeTransferFrom({from: address(this), to: owner, tokenId: projectId});
720
763
  }
721
764
 
722
765
  /// @notice Internal implementation of `launchRulesetsFor`.
@@ -804,6 +847,9 @@ contract JBOmnichainDeployer is
804
847
  {
805
848
  // First try the latest queued ruleset — if it's been explicitly approved
806
849
  // (or has no approval hook), its hook config should take precedence.
850
+ // Conservative: only use Approved or Empty status. ApprovalExpected is intentionally
851
+ // excluded because hook selection is irreversible — if the pending ruleset is later rejected
852
+ // by the approval hook, we'd have locked in a hook from a ruleset that never became active.
807
853
  (JBRuleset memory latestQueued, JBApprovalStatus approvalStatus) =
808
854
  controller.RULESETS().latestQueuedOf(projectId);
809
855
  if (
@@ -920,13 +966,12 @@ contract JBOmnichainDeployer is
920
966
  }
921
967
 
922
968
  /// @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.
969
+ /// @dev Uses the immutable DIRECTORY instead of querying the controller, preventing a malicious
970
+ /// controller from returning a fake directory that confirms itself.
926
971
  /// @param projectId The ID of the project to validate the controller for.
927
972
  /// @param controller The controller to validate.
928
973
  function _validateController(uint256 projectId, IJBController controller) internal view {
929
- address current = address(controller.DIRECTORY().controllerOf(projectId));
974
+ address current = address(DIRECTORY.controllerOf(projectId));
930
975
  // Allow address(0) for fresh projects that haven't launched rulesets yet.
931
976
  if (current != address(0) && current != address(controller)) {
932
977
  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
 
@@ -262,11 +263,11 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
262
263
  .setPermissionsFor(
263
264
  owner,
264
265
  JBPermissionsData({
265
- operator: address(deployer),
266
- // forge-lint: disable-next-line(unsafe-typecast)
267
- projectId: uint64(projectId),
268
- permissionIds: permissionIds
269
- })
266
+ operator: address(deployer),
267
+ // forge-lint: disable-next-line(unsafe-typecast)
268
+ projectId: uint64(projectId),
269
+ permissionIds: permissionIds
270
+ })
270
271
  );
271
272
  }
272
273
 
@@ -302,9 +303,10 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
302
303
 
303
304
  JBRulesetConfig[] memory rulesets = _makeRulesetConfigs(1);
304
305
 
305
- // Should succeed without reverting.
306
306
  JBOmnichain721Config memory empty721;
307
- deployer.queueRulesetsOf(projectId, empty721, rulesets, "queue", IJBController(address(jbController())));
307
+ (uint256 rulesetId,) =
308
+ deployer.queueRulesetsOf(projectId, empty721, rulesets, "queue", IJBController(address(jbController())));
309
+ assertGt(rulesetId, 0, "Queued ruleset ID must be non-zero");
308
310
  }
309
311
 
310
312
  /// @notice Queue rulesets reverts when called in the same block as launch
@@ -362,6 +364,41 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
362
364
 
363
365
  // Now should succeed.
364
366
  JBOmnichain721Config memory empty721b;
365
- deployer.queueRulesetsOf(projectId, empty721b, rulesets, "ok-now", IJBController(address(jbController())));
367
+ (uint256 rulesetId,) =
368
+ deployer.queueRulesetsOf(projectId, empty721b, rulesets, "ok-now", IJBController(address(jbController())));
369
+ assertGt(rulesetId, 0, "Queued ruleset ID must be non-zero after warping past conflict");
370
+ }
371
+
372
+ /// @notice When no new tiers are provided and no latestQueued exists (single launch ruleset that
373
+ /// became the current ruleset), the hook is carried forward from the current ruleset (lines 862-864).
374
+ function test_queueRulesetsOf_carriesForwardFromCurrent_whenNoLatestQueued() public {
375
+ // Launch with 1 ruleset — this deploys a 721 hook stored for the launch ruleset.
376
+ uint256 projectId = _launchProject(1);
377
+ uint256 launchRulesetId = block.timestamp;
378
+ _grantDeployerQueuePermission(projectId);
379
+
380
+ // Verify the 721 hook was stored for the launch ruleset.
381
+ (IJB721TiersHook launchHook,) = deployer.tiered721HookOf(projectId, launchRulesetId);
382
+ assertEq(address(launchHook), mockHookAddr, "Launch ruleset should have 721 hook stored");
383
+
384
+ // Warp forward so the launched ruleset is the current active one and the guard passes.
385
+ vm.warp(block.timestamp + 1 days);
386
+
387
+ // Queue a new ruleset with NO new tiers → triggers carry-forward logic.
388
+ JBRulesetConfig[] memory rulesets = _makeRulesetConfigs(1);
389
+ JBOmnichain721Config memory empty721;
390
+ (uint256 queuedRulesetId,) = deployer.queueRulesetsOf(
391
+ projectId, empty721, rulesets, "carry-forward", IJBController(address(jbController()))
392
+ );
393
+
394
+ assertGt(queuedRulesetId, 0, "Queued ruleset ID must be non-zero");
395
+
396
+ // Verify the hook was carried forward to the new queued ruleset.
397
+ (IJB721TiersHook carriedHook,) = deployer.tiered721HookOf(projectId, queuedRulesetId);
398
+ assertEq(
399
+ address(carriedHook),
400
+ address(launchHook),
401
+ "Queued ruleset should carry forward the 721 hook from the current ruleset"
402
+ );
366
403
  }
367
404
  }
@@ -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(