@bananapus/omnichain-deployers-v6 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ARCHITECTURE.md CHANGED
@@ -31,6 +31,15 @@ caller
31
31
  -> project ownership is transferred to the intended owner
32
32
  ```
33
33
 
34
+ ### 721 Hook Carry-Forward (Queue Path)
35
+
36
+ When `queueRulesetsOf` is called without new tiers, the deployer carries the existing 721 hook forward. The source ruleset is chosen with this precedence:
37
+
38
+ 1. **Latest queued ruleset** — if its approval status is `Approved` or `Empty` (no approval hook) and it has a hook config stored in the deployer.
39
+ 2. **Current active ruleset** — fallback when no qualifying queued ruleset exists.
40
+
41
+ This ensures that a recently queued (and approved) ruleset's hook config takes precedence over a potentially stale active ruleset. The `useDataHookForCashOut` flag is also preserved from whichever source ruleset is selected.
42
+
34
43
  ### Pay And Cash-Out Wrapping
35
44
 
36
45
  ```text
@@ -47,6 +56,7 @@ runtime callback
47
56
  - Hook order matters: the 721 hook runs first, and the extra hook receives the updated context.
48
57
  - The deployer's predicted ruleset IDs must stay aligned with `JBRulesets` behavior; the storage keys depend on it.
49
58
  - Every project launched through this repo gets a 721 hook surface, even if it starts with zero tiers.
59
+ - Carry-forward must prefer the latest approved queued ruleset over the current ruleset to avoid losing hook config from a recently queued update.
50
60
 
51
61
  ## Where Complexity Lives
52
62
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## v6 post-audit (M-11 fix)
4
+
5
+ - **Carry-forward hook selection improved.** `queueRulesetsOf` now checks `latestQueuedOf(projectId)` before falling back to `currentOf(projectId)` when carrying forward a 721 hook. Previously it only read `currentOf`, which could miss a recently queued (and approved) ruleset's hook config. The source ruleset must have approval status `Approved` or `Empty` and a stored hook config in the deployer.
6
+ - The `useDataHookForCashOut` flag is preserved from whichever source ruleset is selected during carry-forward.
7
+
3
8
  ## Scope
4
9
 
5
10
  This file describes the verified change from `nana-omnichain-deployers-v5` to the current `nana-omnichain-deployers-v6` repo.
package/RISKS.md CHANGED
@@ -52,7 +52,7 @@ This file focuses on the risks in the deployer layer that launches Juicebox proj
52
52
  ## 6. Integration Risks
53
53
 
54
54
  - **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).
55
- - **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`.
55
+ - **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.
56
56
  - **ERC721Receiver restriction.** `onERC721Received` only accepts from `PROJECTS`. Any other NFTs sent to this contract are permanently lost.
57
57
  - **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.
58
58
  - **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.
package/USER_JOURNEYS.md CHANGED
@@ -36,10 +36,11 @@
36
36
  **Success:** the deployer carries the existing hook forward, stores it against the queued ruleset, and preserves the ownership and wrapper assumptions bridge flows need.
37
37
 
38
38
  **Flow**
39
- 1. Queue the next ruleset through the deployer using the path that reuses the existing 721 hook.
40
- 2. Validate the controller and queued ruleset inputs before relying on the result.
41
- 3. Let the deployer transfer or confirm hook ownership as needed so future project-controlled behavior still resolves correctly.
42
- 4. Confirm the queued ruleset now points at the carried-forward hook rather than accidentally dropping the 721 layer.
39
+ 1. Queue the next ruleset through the deployer using the path that reuses the existing 721 hook (pass zero tiers).
40
+ 2. The deployer selects the source hook: it first checks the latest queued ruleset (if approved or with no approval hook), then falls back to the current active ruleset. This prevents losing a recently queued hook config.
41
+ 3. The `useDataHookForCashOut` flag is preserved from whichever source ruleset is selected.
42
+ 4. Validate the controller and queued ruleset inputs before relying on the result.
43
+ 5. Confirm the queued ruleset now points at the carried-forward hook rather than accidentally dropping the 721 layer.
43
44
 
44
45
  ## Journey 4: Compose A Tiered 721 Hook With A Custom Extra Hook
45
46
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/omnichain-deployers-v6",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,12 +17,12 @@
17
17
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-omnichain-deployers-v6'"
18
18
  },
19
19
  "dependencies": {
20
- "@bananapus/721-hook-v6": "^0.0.31",
21
- "@bananapus/buyback-hook-v6": "^0.0.25",
22
- "@bananapus/core-v6": "^0.0.31",
20
+ "@bananapus/721-hook-v6": "^0.0.33",
21
+ "@bananapus/buyback-hook-v6": "^0.0.27",
22
+ "@bananapus/core-v6": "^0.0.34",
23
23
  "@bananapus/ownable-v6": "^0.0.17",
24
- "@bananapus/permission-ids-v6": "^0.0.15",
25
- "@bananapus/suckers-v6": "^0.0.21",
24
+ "@bananapus/permission-ids-v6": "^0.0.17",
25
+ "@bananapus/suckers-v6": "^0.0.25",
26
26
  "@openzeppelin/contracts": "^5.6.1",
27
27
  "@uniswap/v4-core": "^1.0.2"
28
28
  },
@@ -30,4 +30,4 @@
30
30
  "@bananapus/address-registry-v6": "^0.0.17",
31
31
  "@sphinx-labs/plugins": "^0.33.2"
32
32
  }
33
- }
33
+ }
@@ -3,6 +3,7 @@ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
5
5
  import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookProjectDeployer.sol";
6
+ import {JBApprovalStatus} from "@bananapus/core-v6/src/enums/JBApprovalStatus.sol";
6
7
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
7
8
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
8
9
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -132,13 +133,14 @@ contract JBOmnichainDeployer is
132
133
  // ------------------------- external views -------------------------- //
133
134
  //*********************************************************************//
134
135
 
135
- /// @notice Allow cash outs from suckers without a tax.
136
+ /// @notice Allow cash outs from suckers without a tax, and compute cross-chain tax supply for non-sucker cash outs.
136
137
  /// @dev This function is part of `IJBRulesetDataHook`, and gets called before the revnet processes a cash out.
137
138
  /// @param context Standard Juicebox cash out context. See `JBBeforeCashOutRecordedContext`.
138
139
  /// @return cashOutTaxRate The cash out tax rate, which influences the amount of terminal tokens which get cashed
139
140
  /// out.
140
141
  /// @return cashOutCount The number of project tokens that are cashed out.
141
- /// @return totalSupply The total project token supply.
142
+ /// @return totalSupply The total token supply across all chains (for both proportional reclaim and tax).
143
+ /// @return effectiveSurplusValue The global surplus across all chains for proportional reclaim.
142
144
  /// @return hookSpecifications The amount of funds and the data to send to cash out hooks (this contract).
143
145
  function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
144
146
  external
@@ -148,18 +150,28 @@ contract JBOmnichainDeployer is
148
150
  uint256 cashOutTaxRate,
149
151
  uint256 cashOutCount,
150
152
  uint256 totalSupply,
153
+ uint256 effectiveSurplusValue,
151
154
  JBCashOutHookSpecification[] memory hookSpecifications
152
155
  )
153
156
  {
154
157
  // If the cash out is from a sucker, bypass all taxes and fees.
158
+ // Pass through the local surplus so the reclaim calculation can compute the pro-rata share.
155
159
  if (SUCKER_REGISTRY.isSuckerOf({projectId: context.projectId, addr: context.holder})) {
156
- return (0, context.cashOutCount, context.totalSupply, hookSpecifications);
160
+ return (0, context.cashOutCount, context.totalSupply, context.surplus.value, hookSpecifications);
157
161
  }
158
162
 
159
163
  // Start with the values from the context. Hooks below may override these.
160
164
  cashOutTaxRate = context.cashOutTaxRate;
161
165
  cashOutCount = context.cashOutCount;
162
- totalSupply = context.totalSupply;
166
+
167
+ // Compute the cross-chain total supply: local supply + sum of known peer chain supplies.
168
+ // This prevents the cash out tax from vanishing when a holder dominates the local supply.
169
+ totalSupply = context.totalSupply + SUCKER_REGISTRY.remoteTotalSupplyOf(context.projectId);
170
+
171
+ // Compute the cross-chain surplus: local surplus + sum of known peer chain surpluses.
172
+ // This prevents disproportionate reclaim when tokens bridge away but surplus stays.
173
+ effectiveSurplusValue = context.surplus.value
174
+ + SUCKER_REGISTRY.remoteSurplusOf(context.projectId, 18, uint256(uint160(context.surplus.token)));
163
175
 
164
176
  // Will hold the 721 hook's cash out specifications (always 0 or 1 element).
165
177
  JBCashOutHookSpecification[] memory tiered721HookSpecifications;
@@ -169,8 +181,11 @@ contract JBOmnichainDeployer is
169
181
 
170
182
  // If a 721 hook is set and opted into cash out handling, let it adjust the cash out parameters.
171
183
  if (address(tiered721Config.hook) != address(0) && tiered721Config.useDataHookForCashOut) {
172
- // Forward to the 721 hook. It may change the tax rate, count, supply, and return hook specs.
173
- (cashOutTaxRate, cashOutCount, totalSupply, tiered721HookSpecifications) =
184
+ // Forward to the 721 hook. It may change the tax rate, count, and return hook specs.
185
+ // We discard the inner hook's effectiveSurplusValue — this contract computes the cross-chain values.
186
+ // We also discard its totalSupply since this contract computes the cross-chain supply.
187
+ // slither-disable-next-line unused-return
188
+ (cashOutTaxRate, cashOutCount,,, tiered721HookSpecifications) =
174
189
  IJBRulesetDataHook(address(tiered721Config.hook)).beforeCashOutRecordedWith(context);
175
190
  }
176
191
 
@@ -188,14 +203,17 @@ contract JBOmnichainDeployer is
188
203
  hookContext.cashOutCount = cashOutCount;
189
204
  hookContext.totalSupply = totalSupply;
190
205
 
191
- // Forward to the extra hook. It may further change the tax rate, count, supply, and return hook specs.
192
- (cashOutTaxRate, cashOutCount, totalSupply, extraHookSpecifications) =
206
+ // Forward to the extra hook. It may further change the tax rate, count, and return hook specs.
207
+ // We discard the inner hook's effectiveSurplusValue — this contract computes the cross-chain values.
208
+ // We also discard its totalSupply since this contract computes the cross-chain supply.
209
+ // slither-disable-next-line unused-return
210
+ (cashOutTaxRate, cashOutCount,,, extraHookSpecifications) =
193
211
  extraHook.dataHook.beforeCashOutRecordedWith(hookContext);
194
212
  }
195
213
 
196
214
  // If neither hook returned any specifications, return the adjusted values with no hook specs.
197
215
  if (tiered721HookSpecifications.length == 0 && extraHookSpecifications.length == 0) {
198
- return (cashOutTaxRate, cashOutCount, totalSupply, hookSpecifications);
216
+ return (cashOutTaxRate, cashOutCount, totalSupply, effectiveSurplusValue, hookSpecifications);
199
217
  }
200
218
 
201
219
  // Merge both hooks' specifications: 721 spec (if any) first, then extra hook specs.
@@ -214,7 +232,7 @@ contract JBOmnichainDeployer is
214
232
  hookSpecifications = extraHookSpecifications;
215
233
  }
216
234
 
217
- return (cashOutTaxRate, cashOutCount, totalSupply, hookSpecifications);
235
+ return (cashOutTaxRate, cashOutCount, totalSupply, effectiveSurplusValue, hookSpecifications);
218
236
  }
219
237
 
220
238
  /// @notice Forward the call to the original data hook.
@@ -613,6 +631,10 @@ contract JBOmnichainDeployer is
613
631
  });
614
632
  }
615
633
 
634
+ //*********************************************************************//
635
+ // -------------------------- internal views ------------------------ //
636
+ //*********************************************************************//
637
+
616
638
  //*********************************************************************//
617
639
  // ------------------------ internal functions ----------------------- //
618
640
  //*********************************************************************//
@@ -808,10 +830,24 @@ contract JBOmnichainDeployer is
808
830
  // Use the caller-provided flag when deploying a new hook.
809
831
  use721ForCashOut = deploy721Config.useDataHookForCashOut;
810
832
  } else {
811
- // Read the *current* (approved) ruleset — not `latestRulesetId` — because a queued-but-unapproved
812
- // ruleset may have been rejected by the approval hook, so its hook config should not be carried forward.
813
- uint256 currentRulesetId = controller.RULESETS().currentOf(projectId).id;
814
- JBTiered721HookConfig memory previousConfig = _tiered721HookOf[projectId][currentRulesetId];
833
+ uint256 sourceRulesetId;
834
+ {
835
+ // First try the latest queued ruleset — if it's been explicitly approved
836
+ // (or has no approval hook), its hook config should take precedence.
837
+ (JBRuleset memory latestQueued, JBApprovalStatus approvalStatus) =
838
+ controller.RULESETS().latestQueuedOf(projectId);
839
+ if (
840
+ latestQueued.id != 0
841
+ && (approvalStatus == JBApprovalStatus.Approved || approvalStatus == JBApprovalStatus.Empty)
842
+ && address(_tiered721HookOf[projectId][latestQueued.id].hook) != address(0)
843
+ ) {
844
+ sourceRulesetId = latestQueued.id;
845
+ } else {
846
+ // Fall back to the current (active, approved) ruleset.
847
+ sourceRulesetId = controller.RULESETS().currentOf(projectId).id;
848
+ }
849
+ }
850
+ JBTiered721HookConfig memory previousConfig = _tiered721HookOf[projectId][sourceRulesetId];
815
851
  hook = previousConfig.hook;
816
852
  // Revert if no hook exists to carry forward — this means no tiers were provided and
817
853
  // no previous ruleset had a 721 hook deployed through this contract.
@@ -3,6 +3,7 @@ pragma solidity 0.8.28;
3
3
 
4
4
  import {Test} from "forge-std/Test.sol";
5
5
 
6
+ import {JBApprovalStatus} from "@bananapus/core-v6/src/enums/JBApprovalStatus.sol";
6
7
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
7
8
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
9
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
@@ -88,6 +89,18 @@ contract TestJBOmnichainDeployer is Test {
88
89
  abi.encodeWithSelector(IJBRulesetDataHook.beforePayRecordedWith.selector),
89
90
  abi.encode(uint256(1000), new JBPayHookSpecification[](0))
90
91
  );
92
+
93
+ // Default: no remote supply or surplus (non-omnichain project).
94
+ vm.mockCall(
95
+ address(suckerRegistry),
96
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteTotalSupplyOf.selector),
97
+ abi.encode(uint256(0))
98
+ );
99
+ vm.mockCall(
100
+ address(suckerRegistry),
101
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteSurplusOf.selector),
102
+ abi.encode(uint256(0))
103
+ );
91
104
  }
92
105
 
93
106
  //*********************************************************************//
@@ -206,7 +219,7 @@ contract TestJBOmnichainDeployer is Test {
206
219
 
207
220
  JBBeforeCashOutRecordedContext memory context = _makeCashOutContext(projectId, rulesetId, sucker);
208
221
 
209
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) =
222
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) =
210
223
  deployer.beforeCashOutRecordedWith(context);
211
224
 
212
225
  assertEq(cashOutTaxRate, 0, "sucker should get 0 tax");
@@ -222,7 +235,7 @@ contract TestJBOmnichainDeployer is Test {
222
235
 
223
236
  JBBeforeCashOutRecordedContext memory context = _makeCashOutContext(projectId, rulesetId, randomAddr);
224
237
 
225
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) =
238
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) =
226
239
  deployer.beforeCashOutRecordedWith(context);
227
240
 
228
241
  assertEq(cashOutTaxRate, context.cashOutTaxRate, "non-sucker should get original tax");
@@ -393,10 +406,22 @@ contract TestJBOmnichainDeployer is Test {
393
406
  abi.encode(launchTimestamp)
394
407
  );
395
408
 
396
- // Mock currentOf to return a ruleset whose id matches the launch so carry-forward can look up the stored 721
397
- // hook.
409
+ // Mock latestQueuedOf to return the launch ruleset with Empty approval status.
410
+ {
411
+ JBRuleset memory latestQueuedRuleset;
412
+ // forge-lint: disable-next-line(unsafe-typecast)
413
+ latestQueuedRuleset.id = uint48(launchTimestamp);
414
+ vm.mockCall(
415
+ address(rulesets),
416
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, projectId),
417
+ abi.encode(latestQueuedRuleset, JBApprovalStatus.Empty)
418
+ );
419
+ }
420
+
421
+ // Mock currentOf as a fallback (not reached when latestQueuedOf succeeds).
398
422
  {
399
423
  JBRuleset memory currentRuleset;
424
+ // forge-lint: disable-next-line(unsafe-typecast)
400
425
  currentRuleset.id = uint48(launchTimestamp);
401
426
  vm.mockCall(
402
427
  address(rulesets),
@@ -485,9 +510,19 @@ contract TestJBOmnichainDeployer is Test {
485
510
  abi.encode(launchTimestamp)
486
511
  );
487
512
 
488
- // Mock currentOf to return a ruleset whose id matches the launch so carry-forward can look up the stored 721
489
- // hook.
513
+ // Mock latestQueuedOf to return the launch ruleset with Empty approval status.
514
+ JBRuleset memory latestQueuedRuleset;
515
+ // forge-lint: disable-next-line(unsafe-typecast)
516
+ latestQueuedRuleset.id = uint48(launchTimestamp);
517
+ vm.mockCall(
518
+ address(rulesets),
519
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, projectId),
520
+ abi.encode(latestQueuedRuleset, JBApprovalStatus.Empty)
521
+ );
522
+
523
+ // Mock currentOf as a fallback (not reached when latestQueuedOf succeeds).
490
524
  JBRuleset memory currentRuleset;
525
+ // forge-lint: disable-next-line(unsafe-typecast)
491
526
  currentRuleset.id = uint48(launchTimestamp);
492
527
  vm.mockCall(
493
528
  address(rulesets),
@@ -82,6 +82,18 @@ contract MockSuckerRegistry is IJBSuckerRegistry {
82
82
  return new address[](0);
83
83
  }
84
84
 
85
+ function remoteTotalSupplyOf(uint256) external pure override returns (uint256) {
86
+ return 0;
87
+ }
88
+
89
+ function remoteBalanceOf(uint256, uint256, uint256) external pure override returns (uint256) {
90
+ return 0;
91
+ }
92
+
93
+ function remoteSurplusOf(uint256, uint256, uint256) external pure override returns (uint256) {
94
+ return 0;
95
+ }
96
+
85
97
  function removeDeprecatedSucker(uint256, address) external override {}
86
98
  function removeSuckerDeployer(address) external override {}
87
99
  }
@@ -43,7 +43,7 @@ contract RevertingDataHook is IJBRulesetDataHook {
43
43
  external
44
44
  pure
45
45
  override
46
- returns (uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
46
+ returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
47
47
  {
48
48
  revert("Hook always reverts");
49
49
  }
@@ -72,9 +72,11 @@ contract InflatingDataHook is IJBRulesetDataHook {
72
72
  external
73
73
  pure
74
74
  override
75
- returns (uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
75
+ returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
76
76
  {
77
- return (context.cashOutTaxRate, context.cashOutCount, context.totalSupply, new JBCashOutHookSpecification[](0));
77
+ return (
78
+ context.cashOutTaxRate, context.cashOutCount, context.totalSupply, 0, new JBCashOutHookSpecification[](0)
79
+ );
78
80
  }
79
81
 
80
82
  function hasMintPermissionFor(uint256, JBRuleset calldata, address) external pure override returns (bool) {
@@ -142,6 +144,18 @@ contract OmnichainDeployerAttacks is Test {
142
144
  abi.encodeWithSelector(IJBRulesetDataHook.beforePayRecordedWith.selector),
143
145
  abi.encode(uint256(1000), new JBPayHookSpecification[](0))
144
146
  );
147
+
148
+ // Default: no remote supply or surplus (non-omnichain project).
149
+ vm.mockCall(
150
+ address(suckerRegistry),
151
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteTotalSupplyOf.selector),
152
+ abi.encode(uint256(0))
153
+ );
154
+ vm.mockCall(
155
+ address(suckerRegistry),
156
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteSurplusOf.selector),
157
+ abi.encode(uint256(0))
158
+ );
145
159
  }
146
160
 
147
161
  // =========================================================================
@@ -166,7 +180,7 @@ contract OmnichainDeployerAttacks is Test {
166
180
 
167
181
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, sucker);
168
182
 
169
- (uint256 cashOutTaxRate,,,) = deployer.beforeCashOutRecordedWith(ctx);
183
+ (uint256 cashOutTaxRate,,,,) = deployer.beforeCashOutRecordedWith(ctx);
170
184
  assertEq(cashOutTaxRate, 0, "Sucker should get 0 tax");
171
185
  }
172
186
 
@@ -182,7 +196,7 @@ contract OmnichainDeployerAttacks is Test {
182
196
 
183
197
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, attacker);
184
198
 
185
- (uint256 cashOutTaxRate,,,) = deployer.beforeCashOutRecordedWith(ctx);
199
+ (uint256 cashOutTaxRate,,,,) = deployer.beforeCashOutRecordedWith(ctx);
186
200
  assertEq(cashOutTaxRate, 5000, "Non-sucker should get original tax");
187
201
  }
188
202
 
@@ -291,7 +305,7 @@ contract OmnichainDeployerAttacks is Test {
291
305
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, storedRulesetId, sucker);
292
306
 
293
307
  // Sucker gets early return with 0 tax — never hits the reverting hook.
294
- (uint256 cashOutTaxRate,,,) = deployer.beforeCashOutRecordedWith(ctx);
308
+ (uint256 cashOutTaxRate,,,,) = deployer.beforeCashOutRecordedWith(ctx);
295
309
  assertEq(cashOutTaxRate, 0, "Sucker bypasses hook and gets 0 tax");
296
310
  }
297
311
 
@@ -3,6 +3,7 @@ pragma solidity 0.8.28;
3
3
 
4
4
  import {Test} from "forge-std/Test.sol";
5
5
 
6
+ import {JBApprovalStatus} from "@bananapus/core-v6/src/enums/JBApprovalStatus.sol";
6
7
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
7
8
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
9
  import {IJBCashOutHook} from "@bananapus/core-v6/src/interfaces/IJBCashOutHook.sol";
@@ -77,7 +78,7 @@ contract CustomCashOutHook is IJBRulesetDataHook {
77
78
  external
78
79
  view
79
80
  override
80
- returns (uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
81
+ returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
81
82
  {
82
83
  JBCashOutHookSpecification[] memory hookSpecifications;
83
84
 
@@ -91,7 +92,7 @@ contract CustomCashOutHook is IJBRulesetDataHook {
91
92
  });
92
93
  }
93
94
 
94
- return (cashOutTaxRateReturn, cashOutCountReturn, totalSupplyReturn, hookSpecifications);
95
+ return (cashOutTaxRateReturn, cashOutCountReturn, totalSupplyReturn, 0, hookSpecifications);
95
96
  }
96
97
 
97
98
  function hasMintPermissionFor(uint256, JBRuleset calldata, address) external view override returns (bool) {
@@ -144,6 +145,18 @@ contract OmnichainDeployerEdgeCases is Test {
144
145
  address(suckerRegistry), abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector), abi.encode(false)
145
146
  );
146
147
 
148
+ // Default: no remote supply or surplus (non-omnichain project).
149
+ vm.mockCall(
150
+ address(suckerRegistry),
151
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteTotalSupplyOf.selector),
152
+ abi.encode(uint256(0))
153
+ );
154
+ vm.mockCall(
155
+ address(suckerRegistry),
156
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteSurplusOf.selector),
157
+ abi.encode(uint256(0))
158
+ );
159
+
147
160
  // Hook deployer mocks (every path now deploys a 721 hook).
148
161
  vm.mockCall(
149
162
  address(hookDeployer),
@@ -323,7 +336,7 @@ contract OmnichainDeployerEdgeCases is Test {
323
336
 
324
337
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, attacker);
325
338
 
326
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
339
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
327
340
  assertEq(cashOutTaxRate, 5000, "Should return original tax rate");
328
341
  assertEq(cashOutCount, 1000, "Should return original cashOutCount");
329
342
  assertEq(totalSupply, 10_000, "Should return original totalSupply");
@@ -340,10 +353,12 @@ contract OmnichainDeployerEdgeCases is Test {
340
353
 
341
354
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, attacker);
342
355
 
343
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
356
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
344
357
  assertEq(cashOutTaxRate, 2000, "Should return custom hook tax rate");
345
358
  assertEq(cashOutCount, 500, "Should return custom hook cashOutCount");
346
- assertEq(totalSupply, 8000, "Should return custom hook totalSupply");
359
+ // The deployer discards the inner hook's totalSupply and computes cross-chain supply instead.
360
+ // With no suckers, this equals context.totalSupply.
361
+ assertEq(totalSupply, ctx.totalSupply, "Should return cross-chain totalSupply (context value with no suckers)");
347
362
  }
348
363
 
349
364
  // =========================================================================
@@ -369,7 +384,7 @@ contract OmnichainDeployerEdgeCases is Test {
369
384
 
370
385
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, sucker);
371
386
 
372
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
387
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
373
388
  assertEq(cashOutTaxRate, 0, "Sucker should get 0 tax regardless of hooks");
374
389
  assertEq(cashOutCount, 1000, "Sucker should get original cashOutCount");
375
390
  assertEq(totalSupply, 10_000, "Sucker should get original totalSupply");
@@ -390,14 +405,14 @@ contract OmnichainDeployerEdgeCases is Test {
390
405
  vm.mockCall(
391
406
  mock721,
392
407
  abi.encodeWithSelector(IJBRulesetDataHook.beforeCashOutRecordedWith.selector),
393
- abi.encode(uint256(9999), uint256(1), uint256(1), new JBCashOutHookSpecification[](0))
408
+ abi.encode(uint256(9999), uint256(1), uint256(1), uint256(0), new JBCashOutHookSpecification[](0))
394
409
  );
395
410
 
396
411
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, attacker);
397
412
 
398
413
  // Since useDataHookForCashOut is false, the 721 hook should NOT be called.
399
414
  // Original values should be returned.
400
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
415
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
401
416
  assertEq(cashOutTaxRate, 5000, "Should return original tax rate, not 721 hook's 9999");
402
417
  assertEq(cashOutCount, 1000, "Should return original cashOutCount, not 721 hook's 1");
403
418
  assertEq(totalSupply, 10_000, "Should return original totalSupply, not 721 hook's 1");
@@ -416,10 +431,12 @@ contract OmnichainDeployerEdgeCases is Test {
416
431
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, attacker);
417
432
 
418
433
  // Since useDataHookForCashOut is true, the custom hook SHOULD be called.
419
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
434
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
420
435
  assertEq(cashOutTaxRate, 9999, "Should return custom hook's tax rate");
421
436
  assertEq(cashOutCount, 1, "Should return custom hook's cashOutCount");
422
- assertEq(totalSupply, 1, "Should return custom hook's totalSupply");
437
+ // The deployer discards the inner hook's totalSupply and computes cross-chain supply instead.
438
+ // With no suckers, this equals context.totalSupply.
439
+ assertEq(totalSupply, ctx.totalSupply, "Should return cross-chain totalSupply (context value with no suckers)");
423
440
  }
424
441
 
425
442
  function test_beforeCashOut_merges721AndCustomHookSpecifications() public {
@@ -440,7 +457,7 @@ contract OmnichainDeployerEdgeCases is Test {
440
457
  vm.mockCall(
441
458
  mock721,
442
459
  abi.encodeWithSelector(IJBRulesetDataHook.beforeCashOutRecordedWith.selector),
443
- abi.encode(uint256(4000), uint256(700), uint256(9000), specs)
460
+ abi.encode(uint256(4000), uint256(700), uint256(9000), uint256(0), specs)
444
461
  );
445
462
 
446
463
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, attacker);
@@ -448,13 +465,15 @@ contract OmnichainDeployerEdgeCases is Test {
448
465
  (
449
466
  uint256 cashOutTaxRate,
450
467
  uint256 cashOutCount,
451
- uint256 totalSupply,
468
+ uint256 totalSupply,,
452
469
  JBCashOutHookSpecification[] memory hookSpecifications
453
470
  ) = deployer.beforeCashOutRecordedWith(ctx);
454
471
 
455
472
  assertEq(cashOutTaxRate, 2000, "Custom hook should receive and override 721-adjusted tax rate");
456
473
  assertEq(cashOutCount, 500, "Custom hook should receive and override 721-adjusted cashOutCount");
457
- assertEq(totalSupply, 8000, "Custom hook should receive and override 721-adjusted totalSupply");
474
+ // The deployer discards the inner hook's totalSupply and computes cross-chain supply instead.
475
+ // With no suckers, this equals context.totalSupply.
476
+ assertEq(totalSupply, ctx.totalSupply, "Should return cross-chain totalSupply (context value with no suckers)");
458
477
  assertEq(hookSpecifications.length, 2, "721 and custom cash out specs should both be returned");
459
478
  assertEq(address(hookSpecifications[0].hook), mock721, "721 hook spec should come first");
460
479
  assertEq(hookSpecifications[0].amount, 11, "721 hook spec amount should be preserved");
@@ -476,7 +495,7 @@ contract OmnichainDeployerEdgeCases is Test {
476
495
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, attacker);
477
496
 
478
497
  // Since useDataHookForCashOut is false, the custom hook should NOT be called.
479
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
498
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
480
499
  assertEq(cashOutTaxRate, 5000, "Should return original tax rate, not custom hook's 2000");
481
500
  assertEq(cashOutCount, 1000, "Should return original cashOutCount, not custom hook's 1");
482
501
  assertEq(totalSupply, 10_000, "Should return original totalSupply, not custom hook's 1");
@@ -493,7 +512,7 @@ contract OmnichainDeployerEdgeCases is Test {
493
512
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, rulesetId, attacker);
494
513
 
495
514
  // No 721 hook, no custom hook — should fall through to original values.
496
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
515
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
497
516
  assertEq(cashOutTaxRate, 5000, "Should return original tax rate");
498
517
  assertEq(cashOutCount, 1000, "Should return original cashOutCount");
499
518
  assertEq(totalSupply, 10_000, "Should return original totalSupply");
@@ -614,6 +633,17 @@ contract OmnichainDeployerEdgeCases is Test {
614
633
  abi.encode(uint256(50)) // A past ruleset ID — no hook stored at this ID
615
634
  );
616
635
 
636
+ // Mock latestQueuedOf to return a ruleset with id=50 (no hook stored for this id via the deployer).
637
+ // The carry-forward logic checks latestQueuedOf first; since no hook was stored at id=50,
638
+ // it falls through to currentOf.
639
+ JBRuleset memory latestQueuedRuleset;
640
+ latestQueuedRuleset.id = uint48(50);
641
+ vm.mockCall(
642
+ address(rulesets),
643
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, projectId),
644
+ abi.encode(latestQueuedRuleset, JBApprovalStatus.Empty)
645
+ );
646
+
617
647
  // Mock currentOf to return a ruleset with id=50 (no hook stored for this id via the deployer).
618
648
  JBRuleset memory currentRuleset;
619
649
  currentRuleset.id = uint48(50);
@@ -3,6 +3,7 @@ pragma solidity 0.8.28;
3
3
 
4
4
  import {Test} from "forge-std/Test.sol";
5
5
 
6
+ import {JBApprovalStatus} from "@bananapus/core-v6/src/enums/JBApprovalStatus.sol";
6
7
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
7
8
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
9
  import {IJBPayHook} from "@bananapus/core-v6/src/interfaces/IJBPayHook.sol";
@@ -54,9 +55,11 @@ contract PayRevertingHook is IJBRulesetDataHook {
54
55
  external
55
56
  pure
56
57
  override
57
- returns (uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
58
+ returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
58
59
  {
59
- return (context.cashOutTaxRate, context.cashOutCount, context.totalSupply, new JBCashOutHookSpecification[](0));
60
+ return (
61
+ context.cashOutTaxRate, context.cashOutCount, context.totalSupply, 0, new JBCashOutHookSpecification[](0)
62
+ );
60
63
  }
61
64
 
62
65
  function hasMintPermissionFor(uint256, JBRuleset calldata, address) external pure override returns (bool) {
@@ -85,7 +88,7 @@ contract CashOutRevertingHook is IJBRulesetDataHook {
85
88
  external
86
89
  pure
87
90
  override
88
- returns (uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
91
+ returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
89
92
  {
90
93
  revert CustomCashOutError();
91
94
  }
@@ -116,9 +119,11 @@ contract MintPermissionRevertingHook is IJBRulesetDataHook {
116
119
  external
117
120
  pure
118
121
  override
119
- returns (uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
122
+ returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
120
123
  {
121
- return (context.cashOutTaxRate, context.cashOutCount, context.totalSupply, new JBCashOutHookSpecification[](0));
124
+ return (
125
+ context.cashOutTaxRate, context.cashOutCount, context.totalSupply, 0, new JBCashOutHookSpecification[](0)
126
+ );
122
127
  }
123
128
 
124
129
  function hasMintPermissionFor(uint256, JBRuleset calldata, address) external pure override returns (bool) {
@@ -145,10 +150,10 @@ contract ExtremeCashOutHook is IJBRulesetDataHook {
145
150
  external
146
151
  pure
147
152
  override
148
- returns (uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
153
+ returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
149
154
  {
150
155
  // Return extreme values: max tax rate, zero count, max supply.
151
- return (type(uint256).max, 0, type(uint256).max, new JBCashOutHookSpecification[](0));
156
+ return (type(uint256).max, 0, type(uint256).max, 0, new JBCashOutHookSpecification[](0));
152
157
  }
153
158
 
154
159
  function hasMintPermissionFor(uint256, JBRuleset calldata, address) external pure override returns (bool) {
@@ -188,9 +193,11 @@ contract ManySpecsHook is IJBRulesetDataHook {
188
193
  external
189
194
  pure
190
195
  override
191
- returns (uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
196
+ returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
192
197
  {
193
- return (context.cashOutTaxRate, context.cashOutCount, context.totalSupply, new JBCashOutHookSpecification[](0));
198
+ return (
199
+ context.cashOutTaxRate, context.cashOutCount, context.totalSupply, 0, new JBCashOutHookSpecification[](0)
200
+ );
194
201
  }
195
202
 
196
203
  function hasMintPermissionFor(uint256, JBRuleset calldata, address) external pure override returns (bool) {
@@ -218,9 +225,11 @@ contract ZeroWeightHook is IJBRulesetDataHook {
218
225
  external
219
226
  pure
220
227
  override
221
- returns (uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
228
+ returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
222
229
  {
223
- return (context.cashOutTaxRate, context.cashOutCount, context.totalSupply, new JBCashOutHookSpecification[](0));
230
+ return (
231
+ context.cashOutTaxRate, context.cashOutCount, context.totalSupply, 0, new JBCashOutHookSpecification[](0)
232
+ );
224
233
  }
225
234
 
226
235
  function hasMintPermissionFor(uint256, JBRuleset calldata, address) external pure override returns (bool) {
@@ -309,6 +318,18 @@ contract TestAuditGaps is Test {
309
318
  address(suckerRegistry), abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector), abi.encode(false)
310
319
  );
311
320
 
321
+ // Default: no remote supply or surplus (non-omnichain project).
322
+ vm.mockCall(
323
+ address(suckerRegistry),
324
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteTotalSupplyOf.selector),
325
+ abi.encode(uint256(0))
326
+ );
327
+ vm.mockCall(
328
+ address(suckerRegistry),
329
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteSurplusOf.selector),
330
+ abi.encode(uint256(0))
331
+ );
332
+
312
333
  // Default 721 hook mock: returns context weight and empty specs (0 tiers, no splits).
313
334
  // A real 721 hook with no tiers returns contextWeight unchanged.
314
335
  vm.mockCall(
@@ -368,7 +389,7 @@ contract TestAuditGaps is Test {
368
389
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, storedRulesetId, sucker);
369
390
 
370
391
  // Sucker gets early return -- never hits the reverting hook.
371
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
392
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
372
393
  assertEq(cashOutTaxRate, 0, "Sucker should get 0 tax even with reverting hook");
373
394
  assertEq(cashOutCount, ctx.cashOutCount, "Sucker cashOutCount should pass through");
374
395
  assertEq(totalSupply, ctx.totalSupply, "Sucker totalSupply should pass through");
@@ -426,10 +447,12 @@ contract TestAuditGaps is Test {
426
447
 
427
448
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, storedRulesetId, attacker);
428
449
 
429
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
450
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
430
451
  assertEq(cashOutTaxRate, type(uint256).max, "Should pass through max tax rate from hook");
431
452
  assertEq(cashOutCount, 0, "Should pass through 0 count from hook");
432
- assertEq(totalSupply, type(uint256).max, "Should pass through max supply from hook");
453
+ // The deployer discards the inner hook's totalSupply and computes cross-chain supply instead.
454
+ // With no suckers, this equals context.totalSupply.
455
+ assertEq(totalSupply, ctx.totalSupply, "Should return cross-chain totalSupply (context value with no suckers)");
433
456
  }
434
457
 
435
458
  // -------------------------------------------------------------------------
@@ -481,7 +504,7 @@ contract TestAuditGaps is Test {
481
504
  // Cash out should work since the hook only reverts for pay (useDataHookForCashOut = false).
482
505
  JBBeforeCashOutRecordedContext memory ctx = _makeCashOutContext(projectId, storedRulesetId, attacker);
483
506
 
484
- (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(ctx);
507
+ (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(ctx);
485
508
  assertEq(cashOutTaxRate, ctx.cashOutTaxRate, "Cash out should return original tax rate");
486
509
  assertEq(cashOutCount, ctx.cashOutCount, "Cash out should return original count");
487
510
  assertEq(totalSupply, ctx.totalSupply, "Cash out should return original supply");
@@ -539,6 +562,7 @@ contract TestAuditGaps is Test {
539
562
  // latestRulesetIdOf still = BASE_TIME (from launch).
540
563
  // Guard: BASE_TIME >= BASE_TIME + 1 -> false -> passes.
541
564
  _mockLatestRulesetId(BASE_TIME);
565
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
542
566
  _mockCurrentRulesetId(BASE_TIME);
543
567
 
544
568
  uint256 expectedQueuedId = BASE_TIME + 1;
@@ -566,6 +590,7 @@ contract TestAuditGaps is Test {
566
590
  // Warp forward 1 second for first queue.
567
591
  vm.warp(BASE_TIME + 1);
568
592
  _mockLatestRulesetId(BASE_TIME);
593
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
569
594
  _mockCurrentRulesetId(BASE_TIME);
570
595
 
571
596
  uint256 firstQueueTime = BASE_TIME + 1;
@@ -596,6 +621,7 @@ contract TestAuditGaps is Test {
596
621
 
597
622
  // Warp forward 1 more second. Now block.timestamp = BASE_TIME + 2.
598
623
  vm.warp(BASE_TIME + 2);
624
+ _mockLatestQueuedRuleset(firstQueueTime, JBApprovalStatus.Empty);
599
625
 
600
626
  uint256 secondQueueTime = BASE_TIME + 2;
601
627
  vm.mockCall(
@@ -674,6 +700,7 @@ contract TestAuditGaps is Test {
674
700
  // Warp past the latestRulesetId: block.timestamp = BASE_TIME + 3.
675
701
  vm.warp(BASE_TIME + 3);
676
702
  _mockLatestRulesetId(latestRulesetId);
703
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
677
704
  _mockCurrentRulesetId(BASE_TIME);
678
705
 
679
706
  uint256 expectedQueuedId = BASE_TIME + 3;
@@ -702,6 +729,7 @@ contract TestAuditGaps is Test {
702
729
  // Warp forward.
703
730
  vm.warp(BASE_TIME + 100);
704
731
  _mockLatestRulesetId(BASE_TIME);
732
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
705
733
  _mockCurrentRulesetId(BASE_TIME);
706
734
 
707
735
  uint256 expectedQueuedId = BASE_TIME + 100;
@@ -742,16 +770,8 @@ contract TestAuditGaps is Test {
742
770
  // Warp forward.
743
771
  vm.warp(BASE_TIME + 50);
744
772
  _mockLatestRulesetId(BASE_TIME);
745
-
746
- // Mock currentOf to return a ruleset whose id matches the launch ruleset so carry-forward can look up the
747
- // stored 721 hook.
748
- JBRuleset memory currentRuleset;
749
- currentRuleset.id = uint48(BASE_TIME);
750
- vm.mockCall(
751
- address(rulesetsContract),
752
- abi.encodeWithSelector(IJBRulesets.currentOf.selector, projectId),
753
- abi.encode(currentRuleset)
754
- );
773
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
774
+ _mockCurrentRulesetId(BASE_TIME);
755
775
 
756
776
  uint256 expectedQueuedId = BASE_TIME + 50;
757
777
  vm.mockCall(
@@ -834,6 +854,7 @@ contract TestAuditGaps is Test {
834
854
 
835
855
  vm.warp(BASE_TIME + 10);
836
856
  _mockLatestRulesetId(BASE_TIME);
857
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
837
858
  _mockCurrentRulesetId(BASE_TIME);
838
859
 
839
860
  uint256 expectedQueuedId = BASE_TIME + 10;
@@ -883,12 +904,24 @@ contract TestAuditGaps is Test {
883
904
 
884
905
  function _mockCurrentRulesetId(uint256 currentRulesetId) internal {
885
906
  JBRuleset memory r;
907
+ // forge-lint: disable-next-line(unsafe-typecast)
886
908
  r.id = uint48(currentRulesetId);
887
909
  vm.mockCall(
888
910
  address(rulesetsContract), abi.encodeWithSelector(IJBRulesets.currentOf.selector, projectId), abi.encode(r)
889
911
  );
890
912
  }
891
913
 
914
+ function _mockLatestQueuedRuleset(uint256 rulesetId, JBApprovalStatus status) internal {
915
+ JBRuleset memory r;
916
+ // forge-lint: disable-next-line(unsafe-typecast)
917
+ r.id = uint48(rulesetId);
918
+ vm.mockCall(
919
+ address(rulesetsContract),
920
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, projectId),
921
+ abi.encode(r, status)
922
+ );
923
+ }
924
+
892
925
  function _makePayContext(uint256 pid, uint256 rid) internal returns (JBBeforePayRecordedContext memory) {
893
926
  return JBBeforePayRecordedContext({
894
927
  terminal: makeAddr("terminal"),
@@ -106,6 +106,16 @@ contract Tiered721HookComposition is Test {
106
106
  vm.mockCall(
107
107
  address(suckerRegistry), abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector), abi.encode(false)
108
108
  );
109
+ vm.mockCall(
110
+ address(suckerRegistry),
111
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteTotalSupplyOf.selector),
112
+ abi.encode(uint256(0))
113
+ );
114
+ vm.mockCall(
115
+ address(suckerRegistry),
116
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteSurplusOf.selector),
117
+ abi.encode(uint256(0))
118
+ );
109
119
  JBPayHookSpecification[] memory default721Specs = new JBPayHookSpecification[](1);
110
120
  default721Specs[0] =
111
121
  JBPayHookSpecification({hook: IJBPayHook(hookAddr), noop: false, amount: 0, metadata: bytes("")});
@@ -330,7 +340,7 @@ contract Tiered721HookComposition is Test {
330
340
  abi.encode(true)
331
341
  );
332
342
  JBBeforeCashOutRecordedContext memory context = _makeCashOutContext(projectId, block.timestamp, sucker);
333
- (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(context);
343
+ (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(context);
334
344
  assertEq(taxRate, 0, "sucker gets 0 tax");
335
345
  assertEq(cashOutCount, context.cashOutCount);
336
346
  assertEq(totalSupply, context.totalSupply);
@@ -349,13 +359,15 @@ contract Tiered721HookComposition is Test {
349
359
  vm.mockCall(
350
360
  buybackHookAddr,
351
361
  abi.encodeWithSelector(IJBRulesetDataHook.beforeCashOutRecordedWith.selector),
352
- abi.encode(uint256(3000), uint256(500), uint256(5000), cashOutSpecs)
362
+ abi.encode(uint256(3000), uint256(500), uint256(5000), uint256(0), cashOutSpecs)
353
363
  );
354
364
  JBBeforeCashOutRecordedContext memory context = _makeCashOutContext(projectId, block.timestamp, randomAddr);
355
- (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(context);
365
+ (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(context);
356
366
  assertEq(taxRate, 3000, "buyback hook's tax rate");
357
367
  assertEq(cashOutCount, 500, "buyback hook's cashOutCount");
358
- assertEq(totalSupply, 5000, "buyback hook's totalSupply");
368
+ // The deployer discards the inner hook's totalSupply and computes cross-chain supply instead.
369
+ // With no suckers, this equals context.totalSupply.
370
+ assertEq(totalSupply, context.totalSupply, "cross-chain totalSupply (context value with no suckers)");
359
371
  }
360
372
 
361
373
  function test_beforeCashOut_zeroTiers_forwardsToUserHook() public {
@@ -375,13 +387,15 @@ contract Tiered721HookComposition is Test {
375
387
  vm.mockCall(
376
388
  customHookAddr,
377
389
  abi.encodeWithSelector(IJBRulesetDataHook.beforeCashOutRecordedWith.selector),
378
- abi.encode(uint256(2000), uint256(100), uint256(1000), cashOutSpecs)
390
+ abi.encode(uint256(2000), uint256(100), uint256(1000), uint256(0), cashOutSpecs)
379
391
  );
380
392
  JBBeforeCashOutRecordedContext memory context = _makeCashOutContext(projectId, block.timestamp, randomAddr);
381
- (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(context);
393
+ (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(context);
382
394
  assertEq(taxRate, 2000, "user hook's tax rate");
383
395
  assertEq(cashOutCount, 100, "user hook's cashOutCount");
384
- assertEq(totalSupply, 1000, "user hook's totalSupply");
396
+ // The deployer discards the inner hook's totalSupply and computes cross-chain supply instead.
397
+ // With no suckers, this equals context.totalSupply.
398
+ assertEq(totalSupply, context.totalSupply, "cross-chain totalSupply (context value with no suckers)");
385
399
  }
386
400
 
387
401
  function test_beforeCashOut_zeroTiers_noUserHook_returnsOriginal() public {
@@ -398,7 +412,7 @@ contract Tiered721HookComposition is Test {
398
412
  controller
399
413
  );
400
414
  JBBeforeCashOutRecordedContext memory context = _makeCashOutContext(projectId, block.timestamp, randomAddr);
401
- (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,) = deployer.beforeCashOutRecordedWith(context);
415
+ (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(context);
402
416
  assertEq(taxRate, context.cashOutTaxRate, "original tax rate");
403
417
  assertEq(cashOutCount, context.cashOutCount);
404
418
  assertEq(totalSupply, context.totalSupply);
@@ -30,6 +30,7 @@ import {JBOmnichain721Config} from "../../src/structs/JBOmnichain721Config.sol";
30
30
  import {JBSuckerDeploymentConfig} from "../../src/structs/JBSuckerDeploymentConfig.sol";
31
31
 
32
32
  contract RejectingApprovalHook is ERC165, IJBRulesetApprovalHook {
33
+ // forge-lint: disable-next-line(mixed-case-function)
33
34
  function DURATION() external pure override returns (uint256) {
34
35
  return 0;
35
36
  }
@@ -73,10 +74,12 @@ contract SequentialHookDeployer is IJB721TiersHookDeployer {
73
74
  }
74
75
 
75
76
  contract MockSuckerRegistryCarryForward is IJBSuckerRegistry {
77
+ // forge-lint: disable-next-line(mixed-case-function)
76
78
  function DIRECTORY() external pure override returns (IJBDirectory) {
77
79
  return IJBDirectory(address(0));
78
80
  }
79
81
 
82
+ // forge-lint: disable-next-line(mixed-case-function)
80
83
  function PROJECTS() external pure override returns (IJBProjects) {
81
84
  return IJBProjects(address(0));
82
85
  }
@@ -97,6 +100,7 @@ contract MockSuckerRegistryCarryForward is IJBSuckerRegistry {
97
100
  return new address[](0);
98
101
  }
99
102
 
103
+ // forge-lint: disable-next-line(mixed-case-function)
100
104
  function MAX_TO_REMOTE_FEE() external pure override returns (uint256) {
101
105
  return 0;
102
106
  }
@@ -122,6 +126,18 @@ contract MockSuckerRegistryCarryForward is IJBSuckerRegistry {
122
126
  return new address[](0);
123
127
  }
124
128
 
129
+ function remoteTotalSupplyOf(uint256) external pure override returns (uint256) {
130
+ return 0;
131
+ }
132
+
133
+ function remoteBalanceOf(uint256, uint256, uint256) external pure override returns (uint256) {
134
+ return 0;
135
+ }
136
+
137
+ function remoteSurplusOf(uint256, uint256, uint256) external pure override returns (uint256) {
138
+ return 0;
139
+ }
140
+
125
141
  function removeDeprecatedSucker(uint256, address) external override {}
126
142
  function removeSuckerDeployer(address) external override {}
127
143
  }
@@ -236,7 +252,8 @@ contract CarryForwardRejectedHookTest is TestBaseWorkflow {
236
252
 
237
253
  function _default721Config() internal pure returns (JBOmnichain721Config memory config) {
238
254
  config.deployTiersHookConfig.tiersConfig.currency =
239
- uint32(uint160(address(0x000000000000000000000000000000000000EEEe)));
255
+ // forge-lint: disable-next-line(unsafe-typecast)
256
+ uint32(uint160(address(0x000000000000000000000000000000000000EEEe)));
240
257
  config.deployTiersHookConfig.tiersConfig.decimals = 18;
241
258
  }
242
259
 
@@ -255,6 +272,7 @@ contract CarryForwardRejectedHookTest is TestBaseWorkflow {
255
272
  metadata: JBRulesetMetadata({
256
273
  reservedPercent: 0,
257
274
  cashOutTaxRate: 0,
275
+ // forge-lint: disable-next-line(unsafe-typecast)
258
276
  baseCurrency: uint32(uint160(address(0x000000000000000000000000000000000000EEEe))),
259
277
  pausePay: false,
260
278
  pauseCreditTransfers: false,
@@ -290,6 +308,7 @@ contract CarryForwardRejectedHookTest is TestBaseWorkflow {
290
308
  jbPermissions()
291
309
  .setPermissionsFor(
292
310
  owner,
311
+ // forge-lint: disable-next-line(unsafe-typecast)
293
312
  JBPermissionsData({operator: address(this), projectId: uint64(projectId), permissionIds: permissionIds})
294
313
  );
295
314
 
@@ -298,7 +317,10 @@ contract CarryForwardRejectedHookTest is TestBaseWorkflow {
298
317
  .setPermissionsFor(
299
318
  owner,
300
319
  JBPermissionsData({
301
- operator: address(deployer), projectId: uint64(projectId), permissionIds: permissionIds
320
+ operator: address(deployer),
321
+ // forge-lint: disable-next-line(unsafe-typecast)
322
+ projectId: uint64(projectId),
323
+ permissionIds: permissionIds
302
324
  })
303
325
  );
304
326
  }
@@ -0,0 +1,158 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
7
+ import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
8
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
9
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
10
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
11
+ import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
12
+ import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
13
+ import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
14
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
15
+ import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
16
+ import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookProjectDeployer.sol";
17
+ import {IJBOwnable} from "@bananapus/ownable-v6/src/interfaces/IJBOwnable.sol";
18
+ import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
19
+ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
20
+ import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
21
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
22
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
23
+ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
24
+
25
+ import {JBOmnichainDeployer} from "../../src/JBOmnichainDeployer.sol";
26
+ import {JBOmnichain721Config} from "../../src/structs/JBOmnichain721Config.sol";
27
+ import {JBSuckerDeploymentConfig} from "../../src/structs/JBSuckerDeploymentConfig.sol";
28
+
29
+ contract CodexNemesisAudit is Test {
30
+ IJBPermissions internal permissions = IJBPermissions(makeAddr("permissions"));
31
+ IJBProjects internal projects = IJBProjects(makeAddr("projects"));
32
+ IJB721TiersHookDeployer internal hookDeployer = IJB721TiersHookDeployer(makeAddr("hookDeployer"));
33
+ IJBSuckerRegistry internal mockSuckerRegistry = IJBSuckerRegistry(makeAddr("suckerRegistry"));
34
+ IJBController internal controller = IJBController(makeAddr("controller"));
35
+ IJBDirectory internal directory = IJBDirectory(makeAddr("directory"));
36
+ address internal hookAddr = makeAddr("hook721");
37
+ address internal projectOwner = makeAddr("projectOwner");
38
+ address internal operator = makeAddr("operator");
39
+
40
+ uint256 internal constant PROJECT_ID = 42;
41
+
42
+ function setUp() public {
43
+ vm.mockCall(
44
+ address(permissions), abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode()
45
+ );
46
+ vm.mockCall(
47
+ address(permissions), abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true)
48
+ );
49
+ vm.mockCall(
50
+ address(projects), abi.encodeWithSelector(IERC721.ownerOf.selector, PROJECT_ID), abi.encode(projectOwner)
51
+ );
52
+ vm.mockCall(
53
+ address(hookDeployer),
54
+ abi.encodeWithSelector(IJB721TiersHookDeployer.deployHookFor.selector),
55
+ abi.encode(IJB721TiersHook(hookAddr))
56
+ );
57
+ vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.transferOwnershipToProject.selector), abi.encode());
58
+ }
59
+
60
+ function test_poc_launchRulesetsFor_revertsWhenProjectHasNoControllerYet() public {
61
+ JBOmnichainDeployer deployer =
62
+ new JBOmnichainDeployer(mockSuckerRegistry, hookDeployer, permissions, projects, address(0));
63
+
64
+ vm.mockCall(
65
+ address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
66
+ );
67
+ // A freshly created project with no controller yet is valid for core launchRulesetsFor,
68
+ // but the deployer rejects it before the controller can set itself as first controller.
69
+ vm.mockCall(
70
+ address(directory),
71
+ abi.encodeWithSelector(IJBDirectory.controllerOf.selector, PROJECT_ID),
72
+ abi.encode(IERC165(address(0)))
73
+ );
74
+
75
+ JBRulesetConfig[] memory configs = new JBRulesetConfig[](1);
76
+ configs[0] = _makeRulesetConfig();
77
+
78
+ vm.prank(projectOwner);
79
+ vm.expectRevert(JBOmnichainDeployer.JBOmnichainDeployer_ControllerMismatch.selector);
80
+ deployer.launchRulesetsFor(PROJECT_ID, configs, new JBTerminalConfig[](0), "memo", controller);
81
+ }
82
+
83
+ function test_poc_deploySuckersFor_requiresHiddenPermissionForDeployerItself() public {
84
+ vm.mockCall(address(directory), abi.encodeWithSelector(IJBDirectory.PROJECTS.selector), abi.encode(projects));
85
+
86
+ JBSuckerRegistry registry = new JBSuckerRegistry(directory, permissions, address(this), address(0));
87
+ JBOmnichainDeployer deployer = new JBOmnichainDeployer(
88
+ IJBSuckerRegistry(address(registry)), hookDeployer, permissions, projects, address(0)
89
+ );
90
+
91
+ vm.mockCall(
92
+ address(permissions),
93
+ abi.encodeWithSelector(
94
+ IJBPermissions.hasPermission.selector,
95
+ operator,
96
+ projectOwner,
97
+ PROJECT_ID,
98
+ JBPermissionIds.DEPLOY_SUCKERS,
99
+ true,
100
+ true
101
+ ),
102
+ abi.encode(true)
103
+ );
104
+ vm.mockCall(
105
+ address(permissions),
106
+ abi.encodeWithSelector(
107
+ IJBPermissions.hasPermission.selector,
108
+ address(deployer),
109
+ projectOwner,
110
+ PROJECT_ID,
111
+ JBPermissionIds.DEPLOY_SUCKERS,
112
+ true,
113
+ true
114
+ ),
115
+ abi.encode(false)
116
+ );
117
+
118
+ JBSuckerDeploymentConfig memory config = JBSuckerDeploymentConfig({
119
+ deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: bytes32("SUCKER_SALT")
120
+ });
121
+
122
+ vm.prank(operator);
123
+ vm.expectRevert(
124
+ abi.encodeWithSelector(
125
+ JBPermissioned.JBPermissioned_Unauthorized.selector,
126
+ projectOwner,
127
+ address(deployer),
128
+ PROJECT_ID,
129
+ JBPermissionIds.DEPLOY_SUCKERS
130
+ )
131
+ );
132
+ deployer.deploySuckersFor(PROJECT_ID, config);
133
+ }
134
+
135
+ function _makeRulesetConfig() internal pure returns (JBRulesetConfig memory config) {
136
+ config.metadata = JBRulesetMetadata({
137
+ reservedPercent: 0,
138
+ cashOutTaxRate: 0,
139
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
140
+ pausePay: false,
141
+ pauseCreditTransfers: false,
142
+ allowOwnerMinting: false,
143
+ allowSetCustomToken: false,
144
+ allowTerminalMigration: false,
145
+ allowSetTerminals: false,
146
+ allowSetController: false,
147
+ allowAddAccountingContext: false,
148
+ allowAddPriceFeed: false,
149
+ ownerMustSendPayouts: false,
150
+ holdFees: false,
151
+ useTotalSurplusForCashOuts: false,
152
+ useDataHookForPay: false,
153
+ useDataHookForCashOut: false,
154
+ dataHook: address(0),
155
+ metadata: 0
156
+ });
157
+ }
158
+ }
@@ -3,6 +3,7 @@ pragma solidity 0.8.28;
3
3
 
4
4
  import {Test} from "forge-std/Test.sol";
5
5
 
6
+ import {JBApprovalStatus} from "@bananapus/core-v6/src/enums/JBApprovalStatus.sol";
6
7
  import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
7
8
  import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
8
9
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
@@ -144,6 +145,14 @@ contract JBOmnichainDeployerTest is Test {
144
145
  abi.encode()
145
146
  );
146
147
  vm.mockCall(suckerRegistry, abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector), abi.encode(false));
148
+ vm.mockCall(
149
+ suckerRegistry,
150
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteTotalSupplyOf.selector),
151
+ abi.encode(uint256(0))
152
+ );
153
+ vm.mockCall(
154
+ suckerRegistry, abi.encodeWithSelector(IJBSuckerRegistry.remoteSurplusOf.selector), abi.encode(uint256(0))
155
+ );
147
156
 
148
157
  JBOmnichainDeployer deployer =
149
158
  new JBOmnichainDeployer(IJBSuckerRegistry(suckerRegistry), hookDeployer, permissions, projects, address(0));
@@ -152,7 +161,7 @@ contract JBOmnichainDeployerTest is Test {
152
161
  vm.mockCall(
153
162
  hookAddr,
154
163
  abi.encodeWithSelector(IJBRulesetDataHook.beforeCashOutRecordedWith.selector),
155
- abi.encode(uint256(1234), uint256(55), uint256(999), emptySpecs)
164
+ abi.encode(uint256(1234), uint256(55), uint256(999), uint256(0), emptySpecs)
156
165
  );
157
166
 
158
167
  JBRulesetConfig[] memory launchConfigs = new JBRulesetConfig[](1);
@@ -176,11 +185,13 @@ contract JBOmnichainDeployerTest is Test {
176
185
  assertTrue(initialUseForCashOut, "launch should store the initial cash-out flag");
177
186
 
178
187
  JBBeforeCashOutRecordedContext memory initialContext = _cashOutContext(initialRulesetId);
179
- (uint256 initialTaxRate, uint256 initialCashOutCount, uint256 initialTotalSupply,) =
188
+ (uint256 initialTaxRate, uint256 initialCashOutCount, uint256 initialTotalSupply,,) =
180
189
  deployer.beforeCashOutRecordedWith(initialContext);
181
190
  assertEq(initialTaxRate, 1234, "initial ruleset should forward cash-outs into the 721 hook");
182
191
  assertEq(initialCashOutCount, 55, "initial ruleset should use the 721 hook cash-out count");
183
- assertEq(initialTotalSupply, 999, "initial ruleset should use the 721 hook total supply");
192
+ // The deployer discards the inner hook's totalSupply and computes cross-chain supply instead.
193
+ // With no suckers, this equals context.totalSupply (777).
194
+ assertEq(initialTotalSupply, 777, "initial ruleset should use cross-chain totalSupply (context value)");
184
195
 
185
196
  vm.mockCall(
186
197
  address(controller), abi.encodeWithSelector(IJBController.DIRECTORY.selector), abi.encode(directory)
@@ -197,8 +208,20 @@ contract JBOmnichainDeployerTest is Test {
197
208
  abi.encode(initialRulesetId)
198
209
  );
199
210
 
200
- // Mock currentOf to return a JBRuleset with id = initialRulesetId so the carry-forward lookup succeeds.
211
+ // Mock latestQueuedOf to return the initial ruleset with Empty approval status,
212
+ // so the carry-forward logic finds the 721 hook from the latest queued ruleset.
213
+ JBRuleset memory latestQueuedRuleset;
214
+ // forge-lint: disable-next-line(unsafe-typecast)
215
+ latestQueuedRuleset.id = uint48(initialRulesetId);
216
+ vm.mockCall(
217
+ address(rulesets),
218
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, PROJECT_ID),
219
+ abi.encode(latestQueuedRuleset, JBApprovalStatus.Empty)
220
+ );
221
+
222
+ // Mock currentOf as a fallback (not reached in this scenario since latestQueuedOf succeeds).
201
223
  JBRuleset memory currentRuleset;
224
+ // forge-lint: disable-next-line(unsafe-typecast)
202
225
  currentRuleset.id = uint48(initialRulesetId);
203
226
  vm.mockCall(
204
227
  address(rulesets),
@@ -226,13 +249,15 @@ contract JBOmnichainDeployerTest is Test {
226
249
  assertTrue(queuedUseForCashOut, "queue should preserve the existing 721 cash-out flag");
227
250
 
228
251
  JBBeforeCashOutRecordedContext memory queuedContext = _cashOutContext(queuedRulesetId);
229
- (uint256 queuedTaxRate, uint256 queuedCashOutCount, uint256 queuedTotalSupply,) =
252
+ (uint256 queuedTaxRate, uint256 queuedCashOutCount, uint256 queuedTotalSupply,,) =
230
253
  deployer.beforeCashOutRecordedWith(queuedContext);
231
254
 
232
255
  // The 721 hook is properly consulted for cash-outs in the queued ruleset.
233
256
  assertEq(queuedTaxRate, 1234, "queued ruleset should forward cash-outs into the 721 hook");
234
257
  assertEq(queuedCashOutCount, 55, "queued ruleset should use the 721 hook cash-out count");
235
- assertEq(queuedTotalSupply, 999, "queued ruleset should use the 721 hook total supply");
258
+ // The deployer discards the inner hook's totalSupply and computes cross-chain supply instead.
259
+ // With no suckers, this equals context.totalSupply (777).
260
+ assertEq(queuedTotalSupply, 777, "queued ruleset should use cross-chain totalSupply (context value)");
236
261
  }
237
262
 
238
263
  function _cashOutContext(uint256 rulesetId) internal pure returns (JBBeforeCashOutRecordedContext memory context) {
@@ -70,6 +70,18 @@ contract WeightScalingComparisonTest is Test {
70
70
  address(suckerRegistry), abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector), abi.encode(false)
71
71
  );
72
72
 
73
+ // Default: no remote supply or surplus (non-omnichain project).
74
+ vm.mockCall(
75
+ address(suckerRegistry),
76
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteTotalSupplyOf.selector),
77
+ abi.encode(uint256(0))
78
+ );
79
+ vm.mockCall(
80
+ address(suckerRegistry),
81
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteSurplusOf.selector),
82
+ abi.encode(uint256(0))
83
+ );
84
+
73
85
  // Mock hook deployer to return our mock hook address.
74
86
  vm.mockCall(
75
87
  address(hookDeployer),
@@ -27,6 +27,7 @@ import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
27
27
  import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
28
28
  import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
29
29
  import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
30
+ import {JB721CheckpointsDeployer} from "@bananapus/721-hook-v6/src/JB721CheckpointsDeployer.sol";
30
31
  import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
31
32
  import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookProjectDeployer.sol";
32
33
  import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
@@ -181,7 +182,14 @@ abstract contract OmnichainForkTestBase is TestBaseWorkflow {
181
182
  suckerRegistry = new JBSuckerRegistry(jbDirectory(), jbPermissions(), multisig(), address(0));
182
183
  hookStore = new JB721TiersHookStore();
183
184
  exampleHook = new JB721TiersHook(
184
- jbDirectory(), jbPermissions(), jbPrices(), jbRulesets(), hookStore, jbSplits(), address(0)
185
+ jbDirectory(),
186
+ jbPermissions(),
187
+ jbPrices(),
188
+ jbRulesets(),
189
+ hookStore,
190
+ jbSplits(),
191
+ new JB721CheckpointsDeployer(),
192
+ address(0)
185
193
  );
186
194
  addressRegistry = new JBAddressRegistry();
187
195
  hookDeployer721 = new JB721TiersHookDeployer(exampleHook, hookStore, addressRegistry, multisig());
@@ -179,7 +179,7 @@ contract TestSuckerDeploymentFork is OmnichainForkTestBase {
179
179
  });
180
180
 
181
181
  // Call beforeCashOutRecordedWith directly on the omnichain deployer.
182
- (uint256 returnedTaxRate, uint256 returnedCashOutCount, uint256 returnedTotalSupply,) =
182
+ (uint256 returnedTaxRate, uint256 returnedCashOutCount, uint256 returnedTotalSupply,,) =
183
183
  omnichainDeployer.beforeCashOutRecordedWith(context);
184
184
 
185
185
  // Sucker should get 0% tax.