@bananapus/omnichain-deployers-v6 0.0.23 → 0.0.24

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.24",
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.32",
21
+ "@bananapus/buyback-hook-v6": "^0.0.26",
22
+ "@bananapus/core-v6": "^0.0.32",
23
23
  "@bananapus/ownable-v6": "^0.0.17",
24
24
  "@bananapus/permission-ids-v6": "^0.0.15",
25
- "@bananapus/suckers-v6": "^0.0.21",
25
+ "@bananapus/suckers-v6": "^0.0.22",
26
26
  "@openzeppelin/contracts": "^5.6.1",
27
27
  "@uniswap/v4-core": "^1.0.2"
28
28
  },
@@ -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";
@@ -808,10 +809,24 @@ contract JBOmnichainDeployer is
808
809
  // Use the caller-provided flag when deploying a new hook.
809
810
  use721ForCashOut = deploy721Config.useDataHookForCashOut;
810
811
  } 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];
812
+ uint256 sourceRulesetId;
813
+ {
814
+ // First try the latest queued ruleset — if it's been explicitly approved
815
+ // (or has no approval hook), its hook config should take precedence.
816
+ (JBRuleset memory latestQueued, JBApprovalStatus approvalStatus) =
817
+ controller.RULESETS().latestQueuedOf(projectId);
818
+ if (
819
+ latestQueued.id != 0
820
+ && (approvalStatus == JBApprovalStatus.Approved || approvalStatus == JBApprovalStatus.Empty)
821
+ && address(_tiered721HookOf[projectId][latestQueued.id].hook) != address(0)
822
+ ) {
823
+ sourceRulesetId = latestQueued.id;
824
+ } else {
825
+ // Fall back to the current (active, approved) ruleset.
826
+ sourceRulesetId = controller.RULESETS().currentOf(projectId).id;
827
+ }
828
+ }
829
+ JBTiered721HookConfig memory previousConfig = _tiered721HookOf[projectId][sourceRulesetId];
815
830
  hook = previousConfig.hook;
816
831
  // Revert if no hook exists to carry forward — this means no tiers were provided and
817
832
  // 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";
@@ -393,10 +394,22 @@ contract TestJBOmnichainDeployer is Test {
393
394
  abi.encode(launchTimestamp)
394
395
  );
395
396
 
396
- // Mock currentOf to return a ruleset whose id matches the launch so carry-forward can look up the stored 721
397
- // hook.
397
+ // Mock latestQueuedOf to return the launch ruleset with Empty approval status.
398
+ {
399
+ JBRuleset memory latestQueuedRuleset;
400
+ // forge-lint: disable-next-line(unsafe-typecast)
401
+ latestQueuedRuleset.id = uint48(launchTimestamp);
402
+ vm.mockCall(
403
+ address(rulesets),
404
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, projectId),
405
+ abi.encode(latestQueuedRuleset, JBApprovalStatus.Empty)
406
+ );
407
+ }
408
+
409
+ // Mock currentOf as a fallback (not reached when latestQueuedOf succeeds).
398
410
  {
399
411
  JBRuleset memory currentRuleset;
412
+ // forge-lint: disable-next-line(unsafe-typecast)
400
413
  currentRuleset.id = uint48(launchTimestamp);
401
414
  vm.mockCall(
402
415
  address(rulesets),
@@ -485,9 +498,19 @@ contract TestJBOmnichainDeployer is Test {
485
498
  abi.encode(launchTimestamp)
486
499
  );
487
500
 
488
- // Mock currentOf to return a ruleset whose id matches the launch so carry-forward can look up the stored 721
489
- // hook.
501
+ // Mock latestQueuedOf to return the launch ruleset with Empty approval status.
502
+ JBRuleset memory latestQueuedRuleset;
503
+ // forge-lint: disable-next-line(unsafe-typecast)
504
+ latestQueuedRuleset.id = uint48(launchTimestamp);
505
+ vm.mockCall(
506
+ address(rulesets),
507
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, projectId),
508
+ abi.encode(latestQueuedRuleset, JBApprovalStatus.Empty)
509
+ );
510
+
511
+ // Mock currentOf as a fallback (not reached when latestQueuedOf succeeds).
490
512
  JBRuleset memory currentRuleset;
513
+ // forge-lint: disable-next-line(unsafe-typecast)
491
514
  currentRuleset.id = uint48(launchTimestamp);
492
515
  vm.mockCall(
493
516
  address(rulesets),
@@ -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";
@@ -614,6 +615,17 @@ contract OmnichainDeployerEdgeCases is Test {
614
615
  abi.encode(uint256(50)) // A past ruleset ID — no hook stored at this ID
615
616
  );
616
617
 
618
+ // Mock latestQueuedOf to return a ruleset with id=50 (no hook stored for this id via the deployer).
619
+ // The carry-forward logic checks latestQueuedOf first; since no hook was stored at id=50,
620
+ // it falls through to currentOf.
621
+ JBRuleset memory latestQueuedRuleset;
622
+ latestQueuedRuleset.id = uint48(50);
623
+ vm.mockCall(
624
+ address(rulesets),
625
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, projectId),
626
+ abi.encode(latestQueuedRuleset, JBApprovalStatus.Empty)
627
+ );
628
+
617
629
  // Mock currentOf to return a ruleset with id=50 (no hook stored for this id via the deployer).
618
630
  JBRuleset memory currentRuleset;
619
631
  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";
@@ -539,6 +540,7 @@ contract TestAuditGaps is Test {
539
540
  // latestRulesetIdOf still = BASE_TIME (from launch).
540
541
  // Guard: BASE_TIME >= BASE_TIME + 1 -> false -> passes.
541
542
  _mockLatestRulesetId(BASE_TIME);
543
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
542
544
  _mockCurrentRulesetId(BASE_TIME);
543
545
 
544
546
  uint256 expectedQueuedId = BASE_TIME + 1;
@@ -566,6 +568,7 @@ contract TestAuditGaps is Test {
566
568
  // Warp forward 1 second for first queue.
567
569
  vm.warp(BASE_TIME + 1);
568
570
  _mockLatestRulesetId(BASE_TIME);
571
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
569
572
  _mockCurrentRulesetId(BASE_TIME);
570
573
 
571
574
  uint256 firstQueueTime = BASE_TIME + 1;
@@ -596,6 +599,7 @@ contract TestAuditGaps is Test {
596
599
 
597
600
  // Warp forward 1 more second. Now block.timestamp = BASE_TIME + 2.
598
601
  vm.warp(BASE_TIME + 2);
602
+ _mockLatestQueuedRuleset(firstQueueTime, JBApprovalStatus.Empty);
599
603
 
600
604
  uint256 secondQueueTime = BASE_TIME + 2;
601
605
  vm.mockCall(
@@ -674,6 +678,7 @@ contract TestAuditGaps is Test {
674
678
  // Warp past the latestRulesetId: block.timestamp = BASE_TIME + 3.
675
679
  vm.warp(BASE_TIME + 3);
676
680
  _mockLatestRulesetId(latestRulesetId);
681
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
677
682
  _mockCurrentRulesetId(BASE_TIME);
678
683
 
679
684
  uint256 expectedQueuedId = BASE_TIME + 3;
@@ -702,6 +707,7 @@ contract TestAuditGaps is Test {
702
707
  // Warp forward.
703
708
  vm.warp(BASE_TIME + 100);
704
709
  _mockLatestRulesetId(BASE_TIME);
710
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
705
711
  _mockCurrentRulesetId(BASE_TIME);
706
712
 
707
713
  uint256 expectedQueuedId = BASE_TIME + 100;
@@ -742,16 +748,8 @@ contract TestAuditGaps is Test {
742
748
  // Warp forward.
743
749
  vm.warp(BASE_TIME + 50);
744
750
  _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
- );
751
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
752
+ _mockCurrentRulesetId(BASE_TIME);
755
753
 
756
754
  uint256 expectedQueuedId = BASE_TIME + 50;
757
755
  vm.mockCall(
@@ -834,6 +832,7 @@ contract TestAuditGaps is Test {
834
832
 
835
833
  vm.warp(BASE_TIME + 10);
836
834
  _mockLatestRulesetId(BASE_TIME);
835
+ _mockLatestQueuedRuleset(BASE_TIME, JBApprovalStatus.Empty);
837
836
  _mockCurrentRulesetId(BASE_TIME);
838
837
 
839
838
  uint256 expectedQueuedId = BASE_TIME + 10;
@@ -883,12 +882,24 @@ contract TestAuditGaps is Test {
883
882
 
884
883
  function _mockCurrentRulesetId(uint256 currentRulesetId) internal {
885
884
  JBRuleset memory r;
885
+ // forge-lint: disable-next-line(unsafe-typecast)
886
886
  r.id = uint48(currentRulesetId);
887
887
  vm.mockCall(
888
888
  address(rulesetsContract), abi.encodeWithSelector(IJBRulesets.currentOf.selector, projectId), abi.encode(r)
889
889
  );
890
890
  }
891
891
 
892
+ function _mockLatestQueuedRuleset(uint256 rulesetId, JBApprovalStatus status) internal {
893
+ JBRuleset memory r;
894
+ // forge-lint: disable-next-line(unsafe-typecast)
895
+ r.id = uint48(rulesetId);
896
+ vm.mockCall(
897
+ address(rulesetsContract),
898
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, projectId),
899
+ abi.encode(r, status)
900
+ );
901
+ }
902
+
892
903
  function _makePayContext(uint256 pid, uint256 rid) internal returns (JBBeforePayRecordedContext memory) {
893
904
  return JBBeforePayRecordedContext({
894
905
  terminal: makeAddr("terminal"),
@@ -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
  }
@@ -236,7 +240,8 @@ contract CarryForwardRejectedHookTest is TestBaseWorkflow {
236
240
 
237
241
  function _default721Config() internal pure returns (JBOmnichain721Config memory config) {
238
242
  config.deployTiersHookConfig.tiersConfig.currency =
239
- uint32(uint160(address(0x000000000000000000000000000000000000EEEe)));
243
+ // forge-lint: disable-next-line(unsafe-typecast)
244
+ uint32(uint160(address(0x000000000000000000000000000000000000EEEe)));
240
245
  config.deployTiersHookConfig.tiersConfig.decimals = 18;
241
246
  }
242
247
 
@@ -255,6 +260,7 @@ contract CarryForwardRejectedHookTest is TestBaseWorkflow {
255
260
  metadata: JBRulesetMetadata({
256
261
  reservedPercent: 0,
257
262
  cashOutTaxRate: 0,
263
+ // forge-lint: disable-next-line(unsafe-typecast)
258
264
  baseCurrency: uint32(uint160(address(0x000000000000000000000000000000000000EEEe))),
259
265
  pausePay: false,
260
266
  pauseCreditTransfers: false,
@@ -290,6 +296,7 @@ contract CarryForwardRejectedHookTest is TestBaseWorkflow {
290
296
  jbPermissions()
291
297
  .setPermissionsFor(
292
298
  owner,
299
+ // forge-lint: disable-next-line(unsafe-typecast)
293
300
  JBPermissionsData({operator: address(this), projectId: uint64(projectId), permissionIds: permissionIds})
294
301
  );
295
302
 
@@ -298,7 +305,10 @@ contract CarryForwardRejectedHookTest is TestBaseWorkflow {
298
305
  .setPermissionsFor(
299
306
  owner,
300
307
  JBPermissionsData({
301
- operator: address(deployer), projectId: uint64(projectId), permissionIds: permissionIds
308
+ operator: address(deployer),
309
+ // forge-lint: disable-next-line(unsafe-typecast)
310
+ projectId: uint64(projectId),
311
+ permissionIds: permissionIds
302
312
  })
303
313
  );
304
314
  }
@@ -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";
@@ -197,8 +198,20 @@ contract JBOmnichainDeployerTest is Test {
197
198
  abi.encode(initialRulesetId)
198
199
  );
199
200
 
200
- // Mock currentOf to return a JBRuleset with id = initialRulesetId so the carry-forward lookup succeeds.
201
+ // Mock latestQueuedOf to return the initial ruleset with Empty approval status,
202
+ // so the carry-forward logic finds the 721 hook from the latest queued ruleset.
203
+ JBRuleset memory latestQueuedRuleset;
204
+ // forge-lint: disable-next-line(unsafe-typecast)
205
+ latestQueuedRuleset.id = uint48(initialRulesetId);
206
+ vm.mockCall(
207
+ address(rulesets),
208
+ abi.encodeWithSelector(IJBRulesets.latestQueuedOf.selector, PROJECT_ID),
209
+ abi.encode(latestQueuedRuleset, JBApprovalStatus.Empty)
210
+ );
211
+
212
+ // Mock currentOf as a fallback (not reached in this scenario since latestQueuedOf succeeds).
201
213
  JBRuleset memory currentRuleset;
214
+ // forge-lint: disable-next-line(unsafe-typecast)
202
215
  currentRuleset.id = uint48(initialRulesetId);
203
216
  vm.mockCall(
204
217
  address(rulesets),