@bananapus/omnichain-deployers-v6 0.0.13 → 0.0.15

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 (36) hide show
  1. package/ADMINISTRATION.md +37 -2
  2. package/ARCHITECTURE.md +23 -2
  3. package/AUDIT_INSTRUCTIONS.md +46 -8
  4. package/CHANGE_LOG.md +13 -1
  5. package/README.md +41 -19
  6. package/RISKS.md +26 -7
  7. package/SKILLS.md +102 -23
  8. package/STYLE_GUIDE.md +2 -2
  9. package/USER_JOURNEYS.md +149 -85
  10. package/foundry.toml +1 -1
  11. package/package.json +8 -7
  12. package/script/Deploy.s.sol +1 -1
  13. package/script/helpers/DeployersDeploymentLib.sol +1 -1
  14. package/src/JBOmnichainDeployer.sol +23 -16
  15. package/src/interfaces/IJBOmnichainDeployer.sol +1 -1
  16. package/src/structs/JBDeployerHookConfig.sol +1 -1
  17. package/src/structs/JBTiered721HookConfig.sol +1 -1
  18. package/test/JBOmnichainDeployer.t.sol +4 -3
  19. package/test/JBOmnichainDeployerGuard.t.sol +1 -1
  20. package/test/OmnichainDeployerAttacks.t.sol +4 -3
  21. package/test/OmnichainDeployerEdgeCases.t.sol +18 -10
  22. package/test/OmnichainDeployerReentrancy.t.sol +1 -1
  23. package/test/TestAuditGaps.sol +4 -3
  24. package/test/Tiered721HookComposition.t.sol +19 -12
  25. package/test/audit/WeightScalingComparison.t.sol +337 -0
  26. package/test/fork/OmnichainForkTestBase.sol +1 -1
  27. package/test/fork/TestOmnichain721QueueAndAdjust.t.sol +1 -1
  28. package/test/fork/TestOmnichainCashOutFork.t.sol +1 -1
  29. package/test/fork/TestOmnichainStressFork.t.sol +1 -1
  30. package/test/fork/TestOmnichainWeightFork.t.sol +1 -1
  31. package/test/fork/TestSuckerDeploymentFork.t.sol +1 -1
  32. package/test/invariants/OmnichainDeployerInvariant.t.sol +1 -1
  33. package/test/invariants/handlers/OmnichainDeployerHandler.sol +1 -1
  34. package/test/regression/EmptyRulesetConfigurations.t.sol +86 -0
  35. package/test/regression/HookOwnershipTransfer.t.sol +1 -1
  36. package/test/regression/ValidateController.t.sol +1 -1
package/ADMINISTRATION.md CHANGED
@@ -18,7 +18,7 @@ Admin privileges and their scope in nana-omnichain-deployers-v6.
18
18
  | Function | Required Role | Permission ID | Scope | What It Does |
19
19
  |----------|--------------|---------------|-------|--------------|
20
20
  | `deploySuckersFor` | Project owner or operator | `DEPLOY_SUCKERS` | Per-project | Deploys new cross-chain suckers for an existing project via the sucker registry. |
21
- | `launchRulesetsFor` | Project owner or operator | `QUEUE_RULESETS` + `SET_TERMINALS` | Per-project | Deploys a 721 tiers hook, launches new rulesets with terminal configuration for an existing project. Has a simplified overload without `deploy721Config`. |
21
+ | `launchRulesetsFor` | Project owner or operator | `LAUNCH_RULESETS` + `SET_TERMINALS` | Per-project | Deploys a 721 tiers hook, launches new rulesets with terminal configuration for an existing project. Has a simplified overload without `deploy721Config`. |
22
22
  | `queueRulesetsOf` | Project owner or operator | `QUEUE_RULESETS` | Per-project | Queues new rulesets for an existing project. If tiers provided, deploys a new 721 hook. Otherwise, carries forward the 721 hook from the latest ruleset. Has a simplified overload without `deploy721Config`. |
23
23
 
24
24
  ### Permissionless Functions
@@ -26,7 +26,7 @@ Admin privileges and their scope in nana-omnichain-deployers-v6.
26
26
  | Function | Who Can Call | What It Does |
27
27
  |----------|-------------|--------------|
28
28
  | `launchProjectFor` | Anyone | Creates a new project with a 721 tiers hook (even with 0 tiers) and suckers. The ERC-721 is minted to the specified `owner`. Returns `(projectId, hook, suckers)`. Has a simplified overload without `deploy721Config` that uses a default empty-tier 721 config. |
29
- | `beforePayRecordedWith` | JBMultiTerminal (via controller) | View function: calls 721 hook for specs, then custom hook (if configured) with reduced amount. Merges results. |
29
+ | `beforePayRecordedWith` | JBMultiTerminal (via controller) | View function: always calls the 721 hook (when its address is non-zero) for specs, then calls the custom hook (if configured and `useDataHookForPay` is set) with the reduced amount. Merges results. |
30
30
  | `beforeCashOutRecordedWith` | JBMultiTerminal (via controller) | View function: returns 0% cash-out tax for registered suckers. Calls 721 hook first (from `_tiered721HookOf`, if `useDataHookForCashOut: true`), then calls custom hook (from `_extraDataHookOf`, if `useDataHookForCashOut: true`) with the updated values from the 721 hook. Both hooks' specifications are merged. If neither has the flag set, returns original values. |
31
31
  | `hasMintPermissionFor` | JBController | View function: returns true for registered suckers, otherwise checks the custom hook in `_extraDataHookOf`. |
32
32
  | `extraDataHookOf` | Anyone | View function: returns the stored `JBDeployerHookConfig` for a project/ruleset pair (the custom data hook). |
@@ -63,6 +63,41 @@ Admin privileges and their scope in nana-omnichain-deployers-v6.
63
63
 
64
64
  **Cross-chain determinism:** The salt for sucker deployment is combined with `_msgSender()` (`keccak256(abi.encode(salt, _msgSender()))`). Deploying from the same sender address with the same salt on each chain produces matching sucker addresses.
65
65
 
66
+ ## Data Hook Proxy Pattern
67
+
68
+ `JBOmnichainDeployer` acts as a data hook proxy. When set as a project's `dataHook` in ruleset metadata, it wraps up to two inner hooks:
69
+
70
+ 1. **721 tiers hook** (`_tiered721HookOf[projectId][rulesetId]`): Handles NFT-based pay/cashout logic.
71
+ 2. **Extra data hook** (`_extraDataHookOf[projectId][rulesetId]`): An optional custom hook for additional pay/cashout logic.
72
+
73
+ ### Call flow for `beforePayRecordedWith`:
74
+
75
+ ```
76
+ Terminal -> Controller -> JBOmnichainDeployer.beforePayRecordedWith()
77
+ 1. Call 721 hook's beforePayRecordedWith (always, when its address is non-zero)
78
+ -> Get pay hook specifications and the total split amount
79
+ 2. Call extra hook's beforePayRecordedWith (if useDataHookForPay is set on extra hook config)
80
+ -> Amount is reduced by what the 721 hook already allocated
81
+ 3. Scale the extra hook's weight proportionally to the project's share of the payment
82
+ 4. Merge both hooks' specifications and return
83
+ ```
84
+
85
+ ### Call flow for `beforeCashOutRecordedWith`:
86
+
87
+ ```
88
+ Terminal -> Controller -> JBOmnichainDeployer.beforeCashOutRecordedWith()
89
+ 1. Check if caller is a registered sucker -> return 0% cash-out tax (fee-free bridging)
90
+ 2. Call 721 hook's beforeCashOutRecordedWith (if useDataHookForCashOut is set on 721 config)
91
+ -> Get cashout hook specifications and adjusted values
92
+ 3. Call extra hook's beforeCashOutRecordedWith (if useDataHookForCashOut is set on extra config)
93
+ -> Receives updated values from 721 hook
94
+ 4. Merge both hooks' specifications and return
95
+ ```
96
+
97
+ **`useDataHookForCashOut` / `useDataHookForPay` flags:** These flags control whether each hook participates in a given operation. For the **extra data hook**, the flags are stored per-ruleset in the `JBDeployerHookConfig` struct -- if the flag is `false`, that hook is skipped entirely and the original values are returned unchanged for that hook's portion. The **721 hook** behaves differently: it is **always** called during `beforePayRecordedWith` when its address is non-zero (no `useDataHookForPay` check), but for `beforeCashOutRecordedWith` it respects the `useDataHookForCashOut` flag stored in `JBTiered721HookConfig`.
98
+
99
+ **Write-once storage:** Both `_tiered721HookOf` and `_extraDataHookOf` mappings are written once during `_setup721()` and never updated. New rulesets can reference different hooks, but existing ruleset-to-hook mappings are permanent.
100
+
66
101
  ## Immutable Configuration
67
102
 
68
103
  These values are set at deployment and cannot be changed:
package/ARCHITECTURE.md CHANGED
@@ -18,6 +18,16 @@ src/
18
18
  └── JBSuckerDeploymentConfig.sol — Sucker deployment parameters
19
19
  ```
20
20
 
21
+ ## Hook Storage Mappings
22
+
23
+ The deployer maintains two internal mappings, both keyed by `(projectId, rulesetId)`:
24
+
25
+ - **`_tiered721HookOf`** — Stores the project's `IJB721TiersHook` reference and a `useDataHookForCashOut` flag. Always populated for every ruleset (every project gets a 721 hook). The hook is always consulted for payments (it controls NFT tier minting), and optionally consulted for cash outs based on the flag.
26
+
27
+ - **`_extraDataHookOf`** — Stores an optional secondary data hook (e.g., a buyback hook) extracted from the ruleset's original `metadata.dataHook` field before the deployer overwrites it with itself. Includes separate `useDataHookForPay` and `useDataHookForCashOut` flags, preserved from the original ruleset metadata. Only populated when the caller's ruleset config specifies a non-zero `dataHook`.
28
+
29
+ During `_setup721`, the deployer extracts any user-specified data hook into `_extraDataHookOf`, then replaces `metadata.dataHook` with itself and forces both pay/cashout flags to `true`. At runtime, the deployer delegates to the 721 hook first, then the extra hook (if present), and merges their results.
30
+
21
31
  ## Key Data Flows
22
32
 
23
33
  ### Omnichain Project Deployment
@@ -38,7 +48,7 @@ Payment → JBOmnichainDeployer.beforePayRecordedWith()
38
48
  → If 721 hook returned specs: include in merged output
39
49
  → Calls custom hook from _extraDataHookOf (if useDataHookForPay=true)
40
50
  → Custom hook receives reduced amount (payment - splitAmount)
41
- Adjusts weight proportionally for splits
51
+ Uses 721 hook's split-adjusted weight directly
42
52
  → Merges both hook specs (721 first if any, then custom)
43
53
 
44
54
  Cash Out → JBOmnichainDeployer.beforeCashOutRecordedWith()
@@ -76,6 +86,18 @@ Owner → JBOmnichainDeployer.launchRulesetsFor()
76
86
  | Sucker registry | `IJBSuckerRegistry` | Sucker deployment and discovery |
77
87
  | 721 hook deployer | `IJB721TiersHookDeployer` | 721 tiers hook deployment (always used) |
78
88
 
89
+ ## Design Decisions
90
+
91
+ 1. **Always deploy a 721 hook, even with 0 tiers.** Every project gets a 721 hook instance so that NFT tiers can be added later via `queueRulesetsOf` without changing the data hook architecture. The hook is also needed as the pay hook target for tier minting. Deploying with 0 tiers is a no-op at runtime (no specs returned) but keeps the infrastructure in place.
92
+
93
+ 2. **Deployer acts as a data hook wrapper instead of direct hook assignment.** The core protocol only supports a single `dataHook` per ruleset. The deployer inserts itself as that hook so it can compose two hooks (721 + custom) behind a single interface, while also injecting sucker-specific logic (0% cash-out tax for suckers, mint permission for suckers). Without this wrapper, projects would have to choose between NFT tiers, a buyback hook, and sucker privileges.
94
+
95
+ 3. **721 hook specs are merged first, custom hook specs second.** During payments, the 721 hook's split amount is subtracted from the payment before the custom hook sees it. This ordering ensures the 721 hook claims funds for tier mints at full price, and the custom hook (e.g., buyback) operates on the remaining amount. For cash outs, the 721 hook adjusts `cashOutTaxRate`/`cashOutCount`/`totalSupply` first, and the custom hook receives those already-updated values, allowing each hook to build on the previous hook's adjustments.
96
+
97
+ 4. **721 hook's weight is used directly after tier splits.** The 721 hook's `beforePayRecordedWith` returns a weight that is already adjusted for tier-split deductions (via `JB721TiersHookLib.calculateWeight`). The deployer uses this weight directly instead of re-scaling with `mulDiv`. This prevents double-counting: the 721 hook mints its own NFTs for the split amount, and the terminal mints fungible tokens only for the remainder at the hook's pre-adjusted weight.
98
+
99
+ 5. **Ruleset IDs are predicted as `block.timestamp + i`.** The deployer must store hook configs keyed by ruleset ID before the rulesets are actually created. It predicts IDs using the core protocol's convention (`block.timestamp` for the first, incrementing for subsequent rulesets in the same transaction). `queueRulesetsOf` explicitly reverts if `latestRulesetId >= block.timestamp`, which would mean rulesets were already queued in the same block and the prediction would be wrong.
100
+
79
101
  ## Dependencies
80
102
  - `@bananapus/core-v6` — Core protocol (controller, directory, permissions)
81
103
  - `@bananapus/721-hook-v6` — NFT tier deployment
@@ -83,4 +105,3 @@ Owner → JBOmnichainDeployer.launchRulesetsFor()
83
105
  - `@bananapus/permission-ids-v6` — Permission constants
84
106
  - `@bananapus/suckers-v6` — Cross-chain sucker registry
85
107
  - `@openzeppelin/contracts` — ERC2771, ERC721Receiver
86
- - `@prb/math` — Fixed-point math (`mulDiv` for weight scaling)
@@ -1,21 +1,39 @@
1
1
  # nana-omnichain-deployers-v6 -- Audit Instructions
2
2
 
3
+ ## Previous Audit Findings
4
+
5
+ No prior formal audit with finding IDs has been conducted on this repository. Known risks and design trade-offs are documented in [`RISKS.md`](./RISKS.md).
6
+
7
+ ## Compiler and Version Info
8
+
9
+ Settings from `foundry.toml`:
10
+
11
+ | Setting | Value |
12
+ |---------|-------|
13
+ | Solidity version | `0.8.28` |
14
+ | EVM target | `cancun` |
15
+ | Intermediate representation | `via_ir = true` |
16
+ | Optimizer runs | `200` |
17
+ | Dependency paths | `node_modules`, `lib` |
18
+ | Fuzz runs | `4,096` |
19
+ | Invariant runs | `1,024` (depth: `100`, `fail_on_revert: false`) |
20
+
3
21
  ## Scope
4
22
 
5
- One contract, 816 lines, four structs. This repo wraps Juicebox V6 project deployment to automatically configure cross-chain suckers and a 721 tiers hook. The deployer itself acts as a data hook proxy, composing a 721 hook with an optional custom hook (e.g. buyback) while granting registered suckers 0% cash-out tax and mint permission.
23
+ One contract, 872 lines, four structs. This repo wraps Juicebox V6 project deployment to automatically configure cross-chain suckers and a 721 tiers hook. The deployer itself acts as a data hook proxy, composing a 721 hook with an optional custom hook (e.g. buyback) while granting registered suckers 0% cash-out tax and mint permission.
6
24
 
7
25
  ## Architecture
8
26
 
9
27
  | File | Lines | Role |
10
28
  |------|------:|------|
11
- | `src/JBOmnichainDeployer.sol` | 816 | Main contract. Deploys projects, queues rulesets, proxies data hook calls. Implements `IJBRulesetDataHook`, `IERC721Receiver`, `JBPermissioned`, `ERC2771Context`. |
29
+ | `src/JBOmnichainDeployer.sol` | 872 | Main contract. Deploys projects, queues rulesets, proxies data hook calls. Implements `IJBRulesetDataHook`, `IERC721Receiver`, `JBPermissioned`, `ERC2771Context`. |
12
30
  | `src/interfaces/IJBOmnichainDeployer.sol` | 171 | Public interface. |
13
31
  | `src/structs/JBDeployerHookConfig.sol` | 11 | Stores custom data hook address + pay/cashout flags per ruleset. |
14
32
  | `src/structs/JBOmnichain721Config.sol` | 16 | 721 hook deployment config: tiers config + cashout flag + salt. |
15
33
  | `src/structs/JBSuckerDeploymentConfig.sol` | 12 | Sucker deployer configs + salt for deterministic addresses. |
16
34
  | `src/structs/JBTiered721HookConfig.sol` | 10 | Stores 721 hook address + `useDataHookForCashOut` flag per ruleset. |
17
35
 
18
- **Total source**: ~1,036 lines.
36
+ **Total source**: ~1,092 lines.
19
37
 
20
38
  ## External Dependencies
21
39
 
@@ -111,7 +129,7 @@ function launchRulesetsFor(
111
129
  ) external returns (uint256 rulesetId, IJB721TiersHook hook)
112
130
  ```
113
131
 
114
- **Permission checks**: Requires both `QUEUE_RULESETS` and `SET_TERMINALS` from project owner.
132
+ **Permission checks**: Requires both `LAUNCH_RULESETS` and `SET_TERMINALS` from project owner.
115
133
 
116
134
  **Controller validation**: `_validateController(projectId, controller)` checks `controller.DIRECTORY().controllerOf(projectId) == controller`.
117
135
 
@@ -252,7 +270,6 @@ For each ruleset config at index `i`:
252
270
  | `JBOmnichainDeployer_InvalidHook` | Ruleset's `metadata.dataHook == address(this)` | `_setup721` (called by all launch/queue functions) |
253
271
  | `JBOmnichainDeployer_ProjectIdMismatch` | `controller.launchProjectFor` returns unexpected project ID | `_launchProjectFor` |
254
272
  | `JBOmnichainDeployer_RulesetIdsUnpredictable` | `latestRulesetIdOf(projectId) >= block.timestamp` | `_queueRulesetsOf` |
255
- | `JBOmnichainDeployer_UnexpectedNFT` | **Declared but never used** (dead code). | N/A |
256
273
  | `JBOmnichainDeployer_UnexpectedNFTReceived` | `onERC721Received` called by non-`PROJECTS` contract | `onERC721Received` |
257
274
 
258
275
  ## Priority Audit Areas
@@ -311,7 +328,7 @@ For each ruleset config at index `i`:
311
328
 
312
329
  ## Test Suite Overview
313
330
 
314
- 15 test files, ~2,500 lines of test code:
331
+ 14 test files, ~5,000 lines of test code:
315
332
 
316
333
  | Category | Files | Coverage |
317
334
  |----------|-------|----------|
@@ -324,7 +341,7 @@ For each ruleset config at index `i`:
324
341
  | Invariants | `invariants/OmnichainDeployerInvariant.t.sol` + handler | Sucker 0% tax, 721 spec ordering, fund conservation, token supply consistency, deployer ETH balance, hook storage consistency |
325
342
  | Regression | `regression/HookOwnershipTransfer.t.sol` | Hook ownership transfer in `queueRulesetsOf` |
326
343
  | Regression | `regression/ValidateController.t.sol` | Controller validation rejects fake controllers |
327
- | Fork | `fork/TestOmnichain*.t.sol` (4 files) | Real V4 PoolManager + buyback hook integration, 721 queue-and-adjust, cashout fork, stress, weight fork |
344
+ | Fork | `fork/TestOmnichain*.t.sol` (5 files) | Real V4 PoolManager + buyback hook integration, 721 queue-and-adjust, cashout fork, stress, weight fork, sucker deployment fork |
328
345
 
329
346
  ## Testing Setup
330
347
 
@@ -342,7 +359,28 @@ RPC_ETHEREUM_MAINNET=<your_rpc> forge test --match-path 'test/fork/*.t.sol' -vvv
342
359
  RPC_ETHEREUM_MAINNET=<your_rpc> forge test --match-contract OmnichainDeployerInvariant -vvv
343
360
 
344
361
  # Compiler settings
345
- # Solidity 0.8.26, EVM version: cancun, via_ir: true, optimizer: 200 runs
362
+ # Solidity 0.8.28, EVM version: cancun, via_ir: true, optimizer: 200 runs
346
363
  ```
347
364
 
348
365
  Foundry config is at `foundry.toml`. Fuzz runs: 4096. Invariant runs: 1024, depth: 100, `fail_on_revert: false`.
366
+
367
+ ## How to Report Findings
368
+
369
+ Each finding should follow this 7-point structure:
370
+
371
+ 1. **Title** -- A short, descriptive name (e.g. "Ruleset ID prediction fails on same-block queue").
372
+ 2. **Affected contract(s)** -- File path(s) and line number(s).
373
+ 3. **Description** -- What the issue is and why it matters. Include relevant code snippets.
374
+ 4. **Trigger sequence** -- Step-by-step instructions to reproduce the issue (transactions, parameters, state preconditions).
375
+ 5. **Impact** -- What can go wrong: fund loss, privilege escalation, denial of service, incorrect accounting, etc.
376
+ 6. **Proof** -- A Foundry test, call trace, or formal argument demonstrating the issue. Runnable PoC strongly preferred.
377
+ 7. **Fix** -- A concrete recommendation. Code diff preferred; otherwise a description of the required change.
378
+
379
+ ### Severity Guide
380
+
381
+ | Severity | Criteria |
382
+ |----------|----------|
383
+ | **CRITICAL** | Direct loss or theft of funds, permanent freezing of funds, or unauthorized minting/burning of tokens. Exploitable without unusual preconditions. |
384
+ | **HIGH** | Significant fund loss under specific but realistic conditions, privilege escalation that bypasses access control, or corruption of core protocol state (e.g. wrong hook mappings that silently disable sucker bypass). |
385
+ | **MEDIUM** | Conditional issues requiring atypical state or timing (e.g. same-block race conditions), griefing attacks with bounded cost, or incorrect accounting that does not directly lose funds but violates documented invariants. |
386
+ | **LOW** | Code quality issues, gas inefficiencies, dead code, missing events, deviations from best practices, or edge cases with negligible economic impact. |
package/CHANGE_LOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  This document describes all changes between `nana-omnichain-deployers` (v5) and `nana-omnichain-deployers-v6` (v6).
4
4
 
5
+ ## Summary
6
+
7
+ - **Every project gets a 721 hook**: All projects launched through the omnichain deployer now receive a 721 hook (even without tiers), unifying the deployment model.
8
+ - **Dual-hook architecture**: Separate tracking of 721 hooks (`_tiered721HookOf`) and extra data hooks (`_extraDataHookOf`) enables composing both in `beforePayRecordedWith` with proportional weight scaling.
9
+ - **v5 ownership bug fixed**: `queue721RulesetsOf` in v5 deployed 721 hooks but never transferred ownership to the project — v6 properly calls `transferOwnershipToProject` on all paths.
10
+ - **Function consolidation**: `launch721ProjectFor`, `launch721RulesetsFor`, and `queue721RulesetsOf` merged into overloads of their non-721 counterparts using `JBOmnichain721Config`.
11
+ - **New safety checks**: Controller validation, ruleset ID collision protection, and explicit reverts replace `assert()`.
12
+
5
13
  ---
6
14
 
7
15
  ## 1. Breaking Changes
@@ -51,6 +59,8 @@ Both v6 overloads now return `IJB721TiersHook hook` alongside `rulesetId`.
51
59
 
52
60
  v6 also fixes v5's `queue721RulesetsOf` which deployed the 721 hook but never called `JBOwnable(hook).transferOwnershipToProject(projectId)`, leaving the hook owned by the deployer contract rather than the project. In v6, all paths that deploy a 721 hook — including `_queueRulesetsOf` — properly transfer hook ownership to the project.
53
61
 
62
+ > **⚠️ This was a v5 bug**: Any project that used v5's `queue721RulesetsOf` to deploy a 721 hook has that hook owned by the deployer contract, not the project. Projects affected should manually transfer hook ownership.
63
+
54
64
  ### 1.6 `queue721RulesetsOf` removed
55
65
 
56
66
  Replaced by the `queueRulesetsOf(... JBOmnichain721Config ...)` overload described above.
@@ -73,7 +83,7 @@ The `dataHook` field moved from last to first. This changes ABI encoding and is
73
83
  ### 1.9 Solidity version bumped
74
84
 
75
85
  **v5**: `pragma solidity 0.8.23`
76
- **v6**: `pragma solidity 0.8.26`
86
+ **v6**: `pragma solidity 0.8.28`
77
87
 
78
88
  ### 1.10 721 hook config types replaced
79
89
 
@@ -311,3 +321,5 @@ v6: `if (msg.sender != address(PROJECTS)) revert JBOmnichainDeployer_UnexpectedN
311
321
  | `queue721RulesetsOf(projectId, deployTiersHookConfig, queueRulesetsConfig, controller, salt)` | `queueRulesetsOf(projectId, deploy721Config, rulesetConfigs, memo, controller)` | 721 config bundled into `JBOmnichain721Config`. Standard `JBRulesetConfig[]` replaces `JBQueueRulesetsConfig`. |
312
322
  | `dataHookOf(projectId, rulesetId)` | `extraDataHookOf(projectId, rulesetId)` + `tiered721HookOf(projectId, rulesetId)` | Split into two views. `extraDataHookOf` returns `JBDeployerHookConfig memory`. `tiered721HookOf` returns `(IJB721TiersHook, bool)`. |
313
323
  | `deploySuckersFor(projectId, suckerConfig)` | `deploySuckersFor(projectId, suckerConfig)` | Unchanged. |
324
+
325
+ > **Cross-repo impact**: Uses `LAUNCH_RULESETS` from `nana-permission-ids-v6` (split from `QUEUE_RULESETS`). The dual-hook composition pattern in `beforePayRecordedWith` uses `mulDiv` from `@prb/math` to scale weight proportionally — `revnet-core-v6` implements a similar pattern for its buyback hook + 721 hook composition.
package/README.md CHANGED
@@ -11,7 +11,7 @@ Launching a cross-chain Juicebox project normally takes several steps: deploy th
11
11
  It works by inserting itself as the data hook on every ruleset it touches, storing hooks in two separate mappings: the 721 tiers hook is stored per-ruleset in `_tiered721HookOf[projectId][rulesetId]` with its own `useDataHookForCashOut` flag, and an optional custom data hook (e.g., buyback hook) is stored per-ruleset in `_extraDataHookOf[projectId][rulesetId]` with `useDataHookForPay` and `useDataHookForCashOut` flags. When the protocol calls data hook functions during payments and cash outs, the deployer:
12
12
 
13
13
  - **Checks if the holder is a sucker** -- if so, returns 0% cash out tax and grants mint permission. This early return means suckers can always bridge tokens without interference, even if the project's hooks would revert.
14
- - **Composes the 721 hook and custom data hook** for payments -- the 721 hook is called first (via `tiered721HookOf`) to get its specs (including split fund amounts), then the custom hook from `_extraDataHookOf` (if `useDataHookForPay: true`) is called with a reduced amount context (payment minus split amount) so it only considers the available funds. The deployer adjusts the returned weight proportionally for splits, ensuring the terminal only mints tokens for the amount that actually enters the project treasury. If the 721 hook returns no specs (0 tiers), it is skipped in the merged output.
14
+ - **Composes the 721 hook and custom data hook** for payments -- the 721 hook is called first (via `tiered721HookOf`) to get its specs (including split fund amounts), then the custom hook from `_extraDataHookOf` (if `useDataHookForPay: true`) is called with a reduced amount context (payment minus split amount) so it only considers the available funds. The 721 hook's weight (already split-adjusted by `JB721TiersHookLib.calculateWeight`) is used directly, ensuring the terminal only mints tokens for the amount that actually enters the project treasury. If the 721 hook returns no specs (0 tiers), it is skipped in the merged output.
15
15
  - **Composes hooks for cash outs** -- the 721 hook is called first (if `useDataHookForCashOut: true`), updating the cash out parameters (tax rate, count, supply). Then the custom hook is called (if `useDataHookForCashOut: true`) with the already-updated values from the 721 hook. Both hooks' specifications are merged into a single array (721 specs first, then custom hook specs). If the 721 hook has `useDataHookForCashOut: true` and reverts (e.g., for fungible-only cashouts), that revert propagates. Set `useDataHookForCashOut: false` on the 721 config to skip it.
16
16
  - **Returns default values** if neither hook has the relevant flag set.
17
17
 
@@ -84,7 +84,7 @@ This means a project can have both a 721 hook (for NFT minting on payments) and
84
84
 
85
85
  ### Simplified Overloads
86
86
 
87
- Each of `launchProjectFor`, `launchRulesetsFor`, and `queueRulesetsOf` has a simplified overload that omits the `deploy721Config` parameter. These use `_default721Config(rulesetConfigurations)`, which creates an empty-tier 721 config with `currency` from the first ruleset's `baseCurrency`, `decimals = 18`, `useDataHookForCashOut = false`, and no salt. For `queueRulesetsOf`, since the default config has 0 tiers, the existing 721 hook is always carried forward.
87
+ Each of `launchProjectFor`, `launchRulesetsFor`, and `queueRulesetsOf` has a simplified overload that omits the `deploy721Config` parameter. These use `_default721Config(rulesetConfigurations)`, which creates an empty-tier 721 config with `currency` from the first ruleset's `baseCurrency`, `decimals = 18`, `useDataHookForCashOut = false`, and no salt. At least one ruleset configuration is required. For `queueRulesetsOf`, since the default config has 0 tiers, the existing 721 hook is always carried forward.
88
88
 
89
89
  ### Deterministic Cross-Chain Addresses
90
90
 
@@ -99,6 +99,15 @@ This means:
99
99
  - Different senders can't collide, even with the same salt
100
100
  - `salt = bytes32(0)` skips sucker deployment entirely
101
101
 
102
+ ### Supported Chains
103
+
104
+ `JBOmnichainDeployer` supports the same chains as the sucker deployers it wraps. Currently supported:
105
+
106
+ - **Mainnets**: Ethereum, Optimism, Base, Arbitrum
107
+ - **Testnets**: Ethereum Sepolia, Optimism Sepolia, Base Sepolia, Arbitrum Sepolia
108
+
109
+ To deploy a cross-chain project, call `launchProjectFor` on each chain with the same salt. The sucker deployers use CREATE2 so that matching salts from the same sender produce deterministic addresses across chains.
110
+
102
111
  ### Ruleset ID Prediction
103
112
 
104
113
  The deployer stores hook configs keyed by predicted ruleset IDs (`block.timestamp + i`). This works because `JBRulesets` assigns IDs as `latestId >= block.timestamp ? latestId + 1 : block.timestamp`. For new projects, `latestId` starts at 0, so the first ID is always `block.timestamp`.
@@ -155,7 +164,8 @@ Add to `remappings.txt`:
155
164
  [profile.default]
156
165
  solc = '0.8.26'
157
166
  evm_version = 'cancun'
158
- optimizer_runs = 100000
167
+ via_ir = true
168
+ optimizer_runs = 200
159
169
 
160
170
  [fuzz]
161
171
  runs = 4096
@@ -165,28 +175,40 @@ runs = 4096
165
175
 
166
176
  ```
167
177
  src/
168
- JBOmnichainDeployer.sol # Main contract (~817 lines)
178
+ JBOmnichainDeployer.sol # Main contract (~817 lines)
169
179
  interfaces/
170
- IJBOmnichainDeployer.sol # Public interface
180
+ IJBOmnichainDeployer.sol # Public interface
171
181
  structs/
172
- JBDeployerHookConfig.sol # Custom hook config (dataHook + flags)
173
- JBOmnichain721Config.sol # 721 hook deployment config
174
- JBTiered721HookConfig.sol # Per-ruleset 721 hook config
175
- JBSuckerDeploymentConfig.sol # Sucker deployment params
182
+ JBDeployerHookConfig.sol # Custom hook config (dataHook + flags)
183
+ JBOmnichain721Config.sol # 721 hook deployment config
184
+ JBSuckerDeploymentConfig.sol # Sucker deployment params
185
+ JBTiered721HookConfig.sol # Per-ruleset 721 hook config
176
186
  test/
177
- JBOmnichainDeployer.t.sol # Unit tests
178
- JBOmnichainDeployerGuard.t.sol # Ruleset ID prediction tests
179
- OmnichainDeployerAttacks.t.sol # Adversarial security tests
180
- OmnichainDeployerEdgeCases.t.sol # Edge case tests (weight, cashout, mint)
181
- OmnichainDeployerReentrancy.t.sol # Reentrancy tests
182
- Tiered721HookComposition.t.sol # 721 hook + custom hook composition tests
183
- fork/ # Fork tests against mainnet
187
+ JBOmnichainDeployer.t.sol # Unit tests
188
+ JBOmnichainDeployerGuard.t.sol # Ruleset ID prediction tests
189
+ OmnichainDeployerAttacks.t.sol # Adversarial security tests
190
+ OmnichainDeployerEdgeCases.t.sol # Edge case tests (weight, cashout, mint)
191
+ OmnichainDeployerReentrancy.t.sol # Reentrancy tests
192
+ TestAuditGaps.sol # Audit gap coverage tests
193
+ Tiered721HookComposition.t.sol # 721 hook + custom hook composition tests
194
+ fork/
195
+ OmnichainForkTestBase.sol # Shared fork test base
196
+ TestOmnichain721QueueAndAdjust.t.sol # Fork: queue and adjust 721 tiers
197
+ TestOmnichainCashOutFork.t.sol # Fork: cash out flows
198
+ TestOmnichainStressFork.t.sol # Fork: stress / load tests
199
+ TestOmnichainWeightFork.t.sol # Fork: weight decay tests
200
+ TestSuckerDeploymentFork.t.sol # Fork: sucker deployment
201
+ invariants/
202
+ OmnichainDeployerInvariant.t.sol # Invariant tests
203
+ handlers/
204
+ OmnichainDeployerHandler.sol # Invariant handler
184
205
  regression/
185
- HookOwnershipTransfer.t.sol # Hook ownership transfer regression
206
+ HookOwnershipTransfer.t.sol # Hook ownership transfer regression
207
+ ValidateController.t.sol # Controller validation regression
186
208
  script/
187
- Deploy.s.sol # Sphinx deployment script
209
+ Deploy.s.sol # Sphinx deployment script
188
210
  helpers/
189
- DeployersDeploymentLib.sol # Deployment address helper
211
+ DeployersDeploymentLib.sol # Deployment address helper
190
212
  ```
191
213
 
192
214
  ## Permissions
package/RISKS.md CHANGED
@@ -4,14 +4,14 @@
4
4
 
5
5
  - **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`.
6
6
  - **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.
7
- - **Controller trust.** The deployer passes an arbitrary `IJBController controller` parameter. `_validateController` checks `DIRECTORY.controllerOf(projectId)`, but during `launchProjectFor` the project does not yet exist -- validation is skipped, relying on the controller returning the correct project ID.
7
+ - **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 -- validation is skipped, relying on the controller returning the correct project ID.
8
8
  - **Extra data hooks.** Arbitrary `IJBRulesetDataHook` addresses from ruleset metadata are stored and delegated to with `staticcall`. A malicious hook can return arbitrary weight, cashout tax rate, or hook specifications.
9
9
 
10
10
  ## 2. Economic / Manipulation Risks
11
11
 
12
12
  - **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.
13
13
  - **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.
14
- - **721 hook amount splitting.** The deployer computes `projectAmount = context.amount.value - totalSplitAmount` and scales weight proportionally. 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.
14
+ - **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.
15
15
 
16
16
  ## 3. Access Control
17
17
 
@@ -21,21 +21,40 @@
21
21
 
22
22
  ## 4. DoS Vectors
23
23
 
24
- - **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.
25
- - **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).
24
+ - **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).
25
+ - **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 `staticcall` to the extra data hook has no gas limit — a gas-griefing hook can consume the entire transaction gas. The 721 hook call is similarly unbounded.
26
26
  - **721 hook deployment revert.** `HOOK_DEPLOYER.deployHookFor` is called without try-catch. A failing deployment blocks the entire project launch.
27
27
 
28
- ## 5. Integration Risks
28
+ ## 5. Reentrancy Surface
29
+
30
+ - **`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.
31
+ - **`beforePayRecordedWith` delegates to external hooks.** Calls `IJBRulesetDataHook(tiered721Hook).beforePayRecordedWith(context)` (not try-caught) and optionally delegates to the extra data hook via `staticcall`. The 721 hook call 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.
32
+ - **`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.
33
+ - **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.
34
+
35
+ ## 6. Integration Risks
29
36
 
30
37
  - **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).
31
38
  - **Carried-forward 721 hook on queue.** When `tiers.length == 0`, `queueRulesetsOf` carries forward the hook from `_tiered721HookOf[projectId][latestRulesetId]`. If the latest ruleset was not deployed through this deployer, the mapping is empty and the call reverts with `JBOmnichainDeployer_InvalidHook`.
32
39
  - **ERC721Receiver restriction.** `onERC721Received` only accepts from `PROJECTS`. Any other NFTs sent to this contract are permanently lost.
40
+ - **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.
41
+ - **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.
33
42
 
34
- ## 6. Invariants to Verify
43
+ ## 7. Invariants to Verify
35
44
 
36
45
  - For any project launched through this deployer, `DIRECTORY.controllerOf(projectId)` matches the controller used during launch.
37
46
  - `_tiered721HookOf[projectId][rulesetId]` is non-zero for every rulesetId created through this deployer.
38
47
  - Sucker cashouts always receive 0% tax rate (no path where `isSuckerOf` returns true but tax > 0).
39
- - `beforePayRecordedWith` weight scaling: `weight * projectAmount / context.amount.value` never exceeds the original hook-returned weight.
48
+ - `beforePayRecordedWith` uses the 721 hook's weight directly (already split-adjusted by `JB721TiersHookLib.calculateWeight`), so no additional scaling is applied.
40
49
  - Self-reference prevention: `rulesetConfigurations[i].metadata.dataHook` cannot be `address(this)` after `_setup721`.
41
50
  - Project NFT ownership: after `_launchProjectFor`, the project NFT is owned by `owner`, not the deployer.
51
+
52
+ ## 8. Accepted Behaviors
53
+
54
+ ### 8.1 Controller validation skipped during `launchProjectFor` (by design)
55
+
56
+ `_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. Validation is skipped, relying on `controller.launchProjectFor()` to return the correct project ID. 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.
57
+
58
+ ### 8.2 Suckers receive 0% cashout tax (shared with revnet-core)
59
+
60
+ `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.
package/SKILLS.md CHANGED
@@ -29,7 +29,7 @@ Single-transaction deployment of Juicebox projects with cross-chain suckers and
29
29
  | Function | What it does |
30
30
  |----------|-------------|
31
31
  | `beforePayRecordedWith(context)` | Calls the 721 hook first (via `_tiered721HookOf`) for its specs (including split amounts), then calls the custom hook from `_extraDataHookOf` (if `useDataHookForPay: true`) with a reduced amount context (payment minus split amount) for weight + specs. Adjusts the returned weight proportionally so the terminal only mints tokens for the amount entering the project (`weight = mulDiv(weight, amount - splitAmount, amount)`). Merges specs (721 hook specs first if any, then custom hook specs). If the 721 hook returns no specs (0 tiers), its slot is omitted from the output. |
32
- | `beforeCashOutRecordedWith(context)` | If holder is a sucker: returns 0% tax immediately. Calls the 721 hook first (if `useDataHookForCashOut: true`), updating cash out parameters. Then calls the custom hook from `_extraDataHookOf` (if `useDataHookForCashOut: true`) with the already-updated values. Both hooks' specifications are merged (721 specs first, then custom hook specs). If the 721 hook has the flag set and reverts (e.g., fungible cashout), the revert propagates. If neither has the flag set, returns original values. |
32
+ | `beforeCashOutRecordedWith(context)` | If holder is a sucker: returns 0% tax immediately. Calls the 721 hook first (if `useDataHookForCashOut: true`), updating cash out parameters. Then calls the custom hook from `_extraDataHookOf` (if `useDataHookForCashOut: true`) with the already-updated values. Both hooks' specifications are merged (721 specs first, then custom hook specs). If the 721 hook has the flag set and reverts (e.g., fungible cashout), the revert propagates. If neither has the flag set, returns original values. Hook specifications include a `noop` field — the 721 hook always returns `noop: false` (it needs its callback), while a custom hook like the buyback hook may return `noop: true` with routing diagnostics when the protocol path wins. |
33
33
  | `hasMintPermissionFor(projectId, ruleset, addr)` | Returns `true` for registered suckers, OR if the custom hook in `_extraDataHookOf` grants permission. Returns `false` only if it doesn't grant it. |
34
34
 
35
35
  ### Views
@@ -82,30 +82,54 @@ Single-transaction deployment of Juicebox projects with cross-chain suckers and
82
82
  | `JBOmnichainDeployer_ProjectIdMismatch` | `launchProjectFor` -- the project ID returned by the controller does not match the predicted `PROJECTS.count() + 1` |
83
83
  | `JBOmnichainDeployer_ControllerMismatch` | `launchRulesetsFor`/`queueRulesetsOf` -- the provided controller does not match the project's controller in `JBDirectory` |
84
84
 
85
+ ## Events
86
+
87
+ `JBOmnichainDeployer` does not declare any custom events. All observable state changes (project creation, ruleset queuing, sucker deployment) are emitted by the underlying contracts it calls (`IJBController`, `IJBSuckerRegistry`, `IJB721TiersHookDeployer`).
88
+
89
+ ## Constants
90
+
91
+ | Name | Value | Context |
92
+ |------|-------|---------|
93
+ | `PROJECTS` | Set at construction | `IJBProjects` -- mints project NFTs. Immutable. |
94
+ | `HOOK_DEPLOYER` | Set at construction | `IJB721TiersHookDeployer` -- deploys 721 tiers hooks. Immutable. |
95
+ | `SUCKER_REGISTRY` | Set at construction | `IJBSuckerRegistry` -- deploys/tracks suckers, `isSuckerOf` checks. Immutable. |
96
+ | `projectId = 0` (wildcard) | Used in constructor | `MAP_SUCKER_TOKEN` permission granted to `SUCKER_REGISTRY` with `projectId=0`, giving it token mapping rights for all projects. |
97
+ | `decimals = 18` | Used in `_default721Config` | Default decimal precision when 721 config is omitted (simplified overloads). |
98
+ | `baseCurrency` | From first ruleset | When 721 config is omitted, `baseCurrency` is read from `rulesetConfigurations[0].metadata.baseCurrency`. Reverts with `JBOmnichainDeployer_NoRulesetConfigurations` if the array is empty. |
99
+
85
100
  ## Gotchas
86
101
 
87
- 1. `launchProjectFor` requires **no permissions** -- anyone can launch a project to any owner address.
88
- 2. `queueRulesetsOf` **reverts if called in the same block** as a previous ruleset queue (whether via deployer or directly). The `launchProjectFor` function doesn't have this guard because it predicts IDs from `PROJECTS.count()`, which is always 0 for a new project.
89
- 3. Ruleset IDs in `_extraDataHookOf` are keyed by `block.timestamp + i`. If the controller assigns different IDs than predicted, the stored hook configs will be orphaned and the deployer will behave as if no hooks were set (returning default values).
90
- 4. Sucker deployment salts are hashed with `_msgSender()`: `keccak256(abi.encode(salt, _msgSender()))`. Cross-chain deterministic addresses require using the **same sender** on each chain. The 721 hook salt uses `keccak256(abi.encode(_msgSender(), salt))` (reversed order).
91
- 5. `salt = bytes32(0)` **skips sucker deployment entirely**. Use a nonzero salt to deploy suckers.
92
- 6. The deployer **always forces `useDataHookForCashOut = true`** at the protocol level so it can intercept cash outs for sucker tax exemption. However, the 721 hook's `useDataHookForCashOut` flag (stored in `_tiered721HookOf`) and the custom hook's flag (stored in `_extraDataHookOf`) each control whether that hook processes cash outs. Set `useDataHookForCashOut: false` on the 721 config to skip it for fungible cashouts (it reverts with `JB721Hook_UnexpectedTokenCashedOut` otherwise).
93
- 7. Suckers get an **early return** in `beforeCashOutRecordedWith` -- they bypass all stored hooks entirely. This means suckers can cash out even if any hook would revert.
94
- 8. If no custom hook is stored or it doesn't grant permission, `hasMintPermissionFor` returns `false` for non-suckers. Only the custom hook in `_extraDataHookOf` is checked — the 721 hook is not consulted.
95
- 9. `_setup721()` sets `metadata.dataHook = address(this)`, `metadata.useDataHookForPay = true`, and `metadata.useDataHookForCashOut = true` on every ruleset. These cannot be overridden.
96
- 10. Hook ownership is transferred to the **project** (not the owner) via `JBOwnable.transferOwnershipToProject(projectId)`. This happens **after** the project NFT is minted — in `launchProjectFor`, the hook is deployed before `controller.launchProjectFor`, and ownership is transferred after the project exists.
97
- 11. The deployer holds the project NFT temporarily during launch. If the controller's `launchProjectFor` reverts, the entire transaction reverts -- no stuck NFTs.
98
- 12. The constructor grants `MAP_SUCKER_TOKEN` permission to `SUCKER_REGISTRY` with `projectId=0`, meaning the registry can map tokens for **any project** deployed through this deployer.
99
- 13. All data hook functions (`beforePayRecordedWith`, `beforeCashOutRecordedWith`, `hasMintPermissionFor`) are `view`. If the project's real hook needs to modify state in these functions, it will fail.
100
- 14. Setting a hook's `dataHook` to `address(this)` (the deployer itself) reverts with `JBOmnichainDeployer_InvalidHook` in `_setup721()`. This prevents infinite forwarding loops.
101
- 15. `onERC721Received` only accepts NFTs from the `PROJECTS` contract. Sending any other ERC-721 to the deployer will revert.
102
- 16. ERC2771 meta-transaction support allows gasless deployments via a trusted forwarder. Salt hashing uses `_msgSender()` (not `msg.sender`), so forwarder-relayed transactions use the original sender's address for deterministic sucker addresses.
103
- 17. Every project always gets a 721 hook, even with an empty tiers array. This wires up the 721 hook from the start, so the project owner can add and sell NFTs later without needing to reconfigure the data hook in a new ruleset.
104
- 18. The 721 hook is stored per-ruleset in `_tiered721HookOf[projectId][rulesetId]` with its `useDataHookForCashOut` flag. The custom data hook (if any) is stored separately in `_extraDataHookOf[projectId][rulesetId]`. They are never in the same array.
105
- 19. For payments, `beforePayRecordedWith` calls the 721 hook first (via `_tiered721HookOf`) to get its specs (including split fund amounts and tier metadata), then calls the custom hook from `_extraDataHookOf` (if `useDataHookForPay: true`) with a reduced amount context (payment minus split amount) so the buyback hook only considers the available amount. The deployer then adjusts the weight proportionally for splits (`weight = mulDiv(weight, amount - splitAmount, amount)`). The 721 hook's specs come first in the merged result, but only if the hook returned specs (0-tier hooks produce no specs).
106
- 20. For cash outs, `beforeCashOutRecordedWith` calls the 721 hook first (from `_tiered721HookOf`, if `useDataHookForCashOut: true`), updating the cash out parameters (tax rate, count, supply). Then calls the custom hook (from `_extraDataHookOf`, if `useDataHookForCashOut: true`) with the already-updated values from the 721 hook. Both hooks' specifications are merged (721 specs first, then custom hook specs). If the 721 hook has the flag set and reverts (e.g., `JB721Hook_UnexpectedTokenCashedOut` for fungible cashouts), the revert propagates. Set `useDataHookForCashOut: false` on the 721 config to skip it.
107
- 21. The `JBOmnichain721Config` parameter bundles the 721 hook deployment config (`deployTiersHookConfig`), the `useDataHookForCashOut` flag, and the `salt`. Custom data hooks are read from each ruleset's `metadata.dataHook` field.
108
- 22. For `queueRulesetsOf`, if no new tiers are provided (`deploy721Config.deployTiersHookConfig.tiersConfig.tiers.length == 0`), the 721 hook from the **latest ruleset** is carried forward instead of deploying a new one. This is looked up from `_tiered721HookOf[projectId][latestRulesetId]`.
102
+ ### Deployment
103
+
104
+ - `launchProjectFor` requires **no permissions** -- anyone can launch a project to any owner address.
105
+ - `queueRulesetsOf` **reverts if called in the same block** as a previous ruleset queue (whether via deployer or directly). The `launchProjectFor` function doesn't have this guard because it predicts IDs from `PROJECTS.count()`, which is always 0 for a new project.
106
+ - Ruleset IDs in `_extraDataHookOf` are keyed by `block.timestamp + i`. If the controller assigns different IDs than predicted, the stored hook configs will be orphaned and the deployer will behave as if no hooks were set (returning default values).
107
+ - Sucker deployment salts are hashed with `_msgSender()`: `keccak256(abi.encode(salt, _msgSender()))`. Cross-chain deterministic addresses require using the **same sender** on each chain. The 721 hook salt uses `keccak256(abi.encode(_msgSender(), salt))` (reversed order).
108
+ - `salt = bytes32(0)` **skips sucker deployment entirely**. Use a nonzero salt to deploy suckers.
109
+ - Hook ownership is transferred to the **project** (not the owner) via `JBOwnable.transferOwnershipToProject(projectId)`. This happens **after** the project NFT is minted.
110
+ - The deployer holds the project NFT temporarily during launch. If the controller's `launchProjectFor` reverts, the entire transaction reverts -- no stuck NFTs.
111
+ - Every project always gets a 721 hook, even with an empty tiers array. This wires up the 721 hook from the start, so tiers can be added later without reconfiguring the data hook.
112
+ - For `queueRulesetsOf`, if no new tiers are provided, the 721 hook from the **latest ruleset** is carried forward instead of deploying a new one. Looked up from `_tiered721HookOf[projectId][latestRulesetId]`.
113
+
114
+ ### Data Hook Behavior
115
+
116
+ - The deployer **always forces `useDataHookForCashOut = true`** at the protocol level so it can intercept cash outs for sucker tax exemption. However, the 721 hook's `useDataHookForCashOut` flag (stored in `_tiered721HookOf`) and the custom hook's flag (stored in `_extraDataHookOf`) each control whether that hook processes cash outs. Set `useDataHookForCashOut: false` on the 721 config to skip it for fungible cashouts (it reverts with `JB721Hook_UnexpectedTokenCashedOut` otherwise).
117
+ - Suckers get an **early return** in `beforeCashOutRecordedWith` -- they bypass all stored hooks entirely. Suckers can cash out even if any hook would revert.
118
+ - If no custom hook is stored or it doesn't grant permission, `hasMintPermissionFor` returns `false` for non-suckers. Only the custom hook in `_extraDataHookOf` is checked -- the 721 hook is not consulted.
119
+ - `_setup721()` sets `metadata.dataHook = address(this)`, `metadata.useDataHookForPay = true`, and `metadata.useDataHookForCashOut = true` on every ruleset. These cannot be overridden.
120
+ - All data hook functions (`beforePayRecordedWith`, `beforeCashOutRecordedWith`, `hasMintPermissionFor`) are `view`. If the project's real hook needs to modify state in these functions, it will fail.
121
+ - Setting a hook's `dataHook` to `address(this)` (the deployer itself) reverts with `JBOmnichainDeployer_InvalidHook`. This prevents infinite forwarding loops.
122
+ - The 721 hook is stored per-ruleset in `_tiered721HookOf[projectId][rulesetId]` with its `useDataHookForCashOut` flag. The custom data hook (if any) is stored separately in `_extraDataHookOf[projectId][rulesetId]`. They are never in the same mapping.
123
+ - The `JBOmnichain721Config` parameter bundles the 721 hook deployment config, the `useDataHookForCashOut` flag, and the `salt`. Custom data hooks are read from each ruleset's `metadata.dataHook` field.
124
+
125
+ ### Permissions
126
+
127
+ - The constructor grants `MAP_SUCKER_TOKEN` permission to `SUCKER_REGISTRY` with `projectId=0`, meaning the registry can map tokens for **any project** deployed through this deployer.
128
+
129
+ ### Edge Cases
130
+
131
+ - `onERC721Received` only accepts NFTs from the `PROJECTS` contract. Sending any other ERC-721 to the deployer will revert.
132
+ - ERC2771 meta-transaction support allows gasless deployments via a trusted forwarder. Salt hashing uses `_msgSender()` (not `msg.sender`), so forwarder-relayed transactions use the original sender's address for deterministic sucker addresses.
109
133
 
110
134
  ## Example Integration
111
135
 
@@ -172,3 +196,58 @@ JBOmnichain721Config memory queue721Config = JBOmnichain721Config({
172
196
  controller: controller
173
197
  });
174
198
  ```
199
+
200
+ ## Buyback Hook + 721 Hook Composition
201
+
202
+ The most complex use case: a project with NFT tiers (721 hook) AND a buyback hook, both running on every payment. The deployer composes them automatically.
203
+
204
+ ```solidity
205
+ import {IJBBuybackHook} from "@bananapus/buyback-hook-v6/src/interfaces/IJBBuybackHook.sol";
206
+ import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
207
+
208
+ // --- Key concept: the buyback hook goes in metadata.dataHook ---
209
+ // The deployer extracts it, stores it in _extraDataHookOf, and replaces
210
+ // metadata.dataHook with address(this) so it can intercept all calls.
211
+
212
+ // 1. Configure the buyback hook as the ruleset's custom data hook.
213
+ JBRulesetConfig[] memory rulesetConfigs = new JBRulesetConfig[](1);
214
+ rulesetConfigs[0].metadata = JBRulesetMetadata({
215
+ // ... other metadata fields ...
216
+ dataHook: address(buybackHook), // <-- deployer extracts this
217
+ useDataHookForPay: true, // buyback hook processes payments
218
+ useDataHookForCashOut: false // buyback hook does NOT process cashouts
219
+ // ...
220
+ });
221
+
222
+ // 2. Configure the 721 hook with NFT tiers.
223
+ JBOmnichain721Config memory deploy721Config = JBOmnichain721Config({
224
+ deployTiersHookConfig: tiersHookConfig, // your NFT tier config
225
+ useDataHookForCashOut: false, // false = skip 721 on fungible cashouts
226
+ salt: bytes32("my-hook-salt")
227
+ });
228
+
229
+ // 3. Launch -- both hooks are wired up automatically.
230
+ (uint256 projectId, IJB721TiersHook hook, address[] memory suckers) =
231
+ omnichainDeployer.launchProjectFor({
232
+ owner: msg.sender,
233
+ projectUri: "ipfs://metadata",
234
+ deploy721Config: deploy721Config,
235
+ rulesetConfigurations: rulesetConfigs,
236
+ terminalConfigurations: terminalConfigs,
237
+ memo: "Buyback + 721 project",
238
+ suckerDeploymentConfiguration: suckerConfig,
239
+ controller: controller
240
+ });
241
+
242
+ // --- What happens on each payment: ---
243
+ // 1. beforePayRecordedWith calls the 721 hook first (tier matching, NFT minting specs)
244
+ // 2. Reduces the payment amount by the 721 split amount
245
+ // 3. Calls the buyback hook with the reduced amount (so it only buys back with leftover)
246
+ // 4. Adjusts weight proportionally: weight = mulDiv(weight, amount - splitAmount, amount)
247
+ // 5. Merges specs: 721 specs first, then buyback specs
248
+ //
249
+ // --- What happens on cashout: ---
250
+ // With useDataHookForCashOut: false on both hooks:
251
+ // - Suckers still get 0% tax (the deployer intercepts before any hook)
252
+ // - Regular users get standard bonding curve cashout (no hook interference)
253
+ ```