@bananapus/omnichain-deployers-v6 0.0.29 → 0.0.31

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 (28) hide show
  1. package/RISKS.md +5 -0
  2. package/package.json +3 -3
  3. package/src/JBOmnichainDeployer.sol +50 -12
  4. package/test/JBOmnichainDeployerGuard.t.sol +44 -8
  5. package/test/OmnichainDeployerEdgeCases.t.sol +8 -6
  6. package/test/OmnichainDeployerReentrancy.t.sol +8 -8
  7. package/test/Tiered721HookComposition.t.sol +11 -6
  8. package/test/audit/AuditFixesC2H6M14.t.sol +6 -21
  9. package/test/audit/CarryForwardRejectedHook.t.sol +5 -5
  10. package/test/audit/CashOutCountPropagation.t.sol +232 -0
  11. package/test/audit/CashOutSpecMerge.t.sol +372 -0
  12. package/test/audit/{CodexNemesisDeterministicDrift.t.sol → DeterministicDrift.t.sol} +1 -1
  13. package/test/audit/{CodexNemesisDeterministicPeerDrift.t.sol → DeterministicPeerDrift.t.sol} +3 -3
  14. package/test/audit/ExtraCashOutHookZeroReclaim.t.sol +340 -0
  15. package/test/audit/{CodexNemesisForwardedPermissions.t.sol → ForwardedPermissions.t.sol} +1 -1
  16. package/test/audit/JBOmnichainDeployer.t.sol +6 -6
  17. package/test/audit/{CodexNemesisNftCashoutSupplyMismatch.t.sol → NftCashoutSupplyMismatch.t.sol} +11 -9
  18. package/test/audit/{CodexNemesisAudit.t.sol → OmnichainAudit.t.sol} +1 -2
  19. package/test/audit/SplitCreditWeight.t.sol +437 -0
  20. package/test/audit/WeightScalingComparison.t.sol +11 -12
  21. package/test/fork/OmnichainForkTestBase.sol +2 -1
  22. package/test/fork/TestOmnichain721QueueAndAdjust.t.sol +5 -5
  23. package/test/fork/TestOmnichainCashOutFork.t.sol +42 -40
  24. package/test/fork/TestOmnichainStressFork.t.sol +87 -95
  25. package/test/fork/TestOmnichainWeightFork.t.sol +9 -27
  26. package/test/invariants/CrossChainDeployerInvariant.t.sol +6 -2
  27. package/test/invariants/OmnichainDeployerInvariant.t.sol +3 -5
  28. package/test/invariants/handlers/CrossChainDeployerHandler.sol +0 -1
package/RISKS.md CHANGED
@@ -86,6 +86,11 @@ Extra data hooks provided by the project owner in `_setup721` configuration can
86
86
  **Missing hook721 alias check enables double invocation.** *(Minor)*
87
87
  If the project owner configures the 721 hook as both the primary hook and as an extra data hook, it could be invoked twice. Accepted because this is self-inflicted misconfiguration — the deployer correctly processes each hook independently.
88
88
 
89
+ ### Hook Selection
90
+
91
+ **ApprovalExpected rulesets excluded from hook carry-forward.**
92
+ When no new tiers are provided, the deployer carries forward the 721 hook from the most recent approved ruleset. Rulesets with `ApprovalExpected` status are intentionally excluded even though they may become active. Hook selection is irreversible — if the pending ruleset is later rejected by the approval hook, we'd have locked in a hook from a ruleset that never became active. The deployer falls back to the current (already-approved) ruleset in this case.
93
+
89
94
  ### Cross-Chain Deployment
90
95
 
91
96
  **`_msgSender()` in deployment salt breaks cross-chain determinism.** *(Minor)*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/omnichain-deployers-v6",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,7 +17,7 @@
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.38",
20
+ "@bananapus/721-hook-v6": "^0.0.41",
21
21
  "@bananapus/buyback-hook-v6": "^0.0.30",
22
22
  "@bananapus/core-v6": "^0.0.36",
23
23
  "@bananapus/ownable-v6": "^0.0.20",
@@ -27,7 +27,7 @@
27
27
  "@uniswap/v4-core": "^1.0.2"
28
28
  },
29
29
  "devDependencies": {
30
- "@bananapus/address-registry-v6": "^0.0.17",
30
+ "@bananapus/address-registry-v6": "^0.0.20",
31
31
  "@sphinx-labs/plugins": "^0.33.2"
32
32
  }
33
33
  }
@@ -30,6 +30,7 @@ import {JBDeployerHookConfig} from "./structs/JBDeployerHookConfig.sol";
30
30
  import {JBOmnichain721Config} from "./structs/JBOmnichain721Config.sol";
31
31
  import {JBSuckerDeploymentConfig} from "./structs/JBSuckerDeploymentConfig.sol";
32
32
  import {JBTiered721HookConfig} from "./structs/JBTiered721HookConfig.sol";
33
+ import {mulDiv} from "@prb/math/src/Common.sol";
33
34
 
34
35
  /// @notice Deploys, manages, and operates Juicebox projects with suckers.
35
36
  // Project NFTs sent to this contract are not recoverable. The deployer does not
@@ -434,10 +435,10 @@ contract JBOmnichainDeployer is
434
435
  // If a 721 hook is set and opted into cash out handling, let it adjust the cash out parameters.
435
436
  if (address(tiered721Config.hook) != address(0) && tiered721Config.useDataHookForCashOut) {
436
437
  // Forward to the 721 hook. It may change the tax rate, count, and return hook specs.
437
- // We discard the inner hook's effectiveSurplusValue — this contract computes the cross-chain values.
438
- // We also discard its totalSupply since this contract computes the cross-chain supply.
438
+ // Capture the 721 hook's totalSupply and effectiveSurplusValue — NFT cash-outs should use
439
+ // local-only denominators so holders reclaim against local surplus, not omnichain surplus.
439
440
  // slither-disable-next-line unused-return
440
- (cashOutTaxRate, cashOutCount,,, tiered721HookSpecifications) =
441
+ (cashOutTaxRate, cashOutCount, totalSupply, effectiveSurplusValue, tiered721HookSpecifications) =
441
442
  IJBRulesetDataHook(address(tiered721Config.hook)).beforeCashOutRecordedWith(context);
442
443
  }
443
444
 
@@ -456,12 +457,20 @@ contract JBOmnichainDeployer is
456
457
  hookContext.totalSupply = totalSupply;
457
458
  hookContext.surplus.value = effectiveSurplusValue;
458
459
 
459
- // Forward to the extra hook. It may further change the tax rate, count, and return hook specs.
460
- // We discard the inner hook's effectiveSurplusValue — this contract computes the cross-chain values.
461
- // We also discard its totalSupply since this contract computes the cross-chain supply.
462
- // slither-disable-next-line unused-return
463
- (cashOutTaxRate, cashOutCount,,, extraHookSpecifications) =
464
- extraHook.dataHook.beforeCashOutRecordedWith(hookContext);
460
+ // Forward to the extra hook. It may further change the tax rate and return hook specs.
461
+ // We always discard totalSupply and effectiveSurplusValue — this contract computes
462
+ // cross-chain values for both. When the 721 hook is active, we also discard cashOutCount
463
+ // because the 721 hook redefines both cashOutCount and totalSupply as NFT cash-out weights
464
+ // (sum of tier prices), not fungible token counts. Letting the extra hook override
465
+ // cashOutCount would corrupt NFT pricing in the bonding curve.
466
+ if (address(tiered721Config.hook) != address(0) && tiered721Config.useDataHookForCashOut) {
467
+ // slither-disable-next-line unused-return
468
+ (cashOutTaxRate,,,, extraHookSpecifications) = extraHook.dataHook.beforeCashOutRecordedWith(hookContext);
469
+ } else {
470
+ // slither-disable-next-line unused-return
471
+ (cashOutTaxRate, cashOutCount,,, extraHookSpecifications) =
472
+ extraHook.dataHook.beforeCashOutRecordedWith(hookContext);
473
+ }
465
474
  }
466
475
 
467
476
  // If neither hook returned any specifications, return the adjusted values with no hook specs.
@@ -510,6 +519,8 @@ contract JBOmnichainDeployer is
510
519
  bool hasTiered721Spec;
511
520
  // The weight returned by the 721 hook (already scaled for splits).
512
521
  uint256 tiered721Weight;
522
+ // The weight attributable to tier splits when issueTokensForSplits is true.
523
+ uint256 splitCreditWeight;
513
524
  // Whether a 721 hook is configured for this project's ruleset.
514
525
  bool has721Hook;
515
526
  if (address(tiered721Config.hook) != address(0)) {
@@ -526,6 +537,14 @@ contract JBOmnichainDeployer is
526
537
  hasTiered721Spec = true;
527
538
  tiered721HookSpec = tiered721HookSpecs[0];
528
539
  totalSplitAmount = tiered721HookSpec.amount;
540
+
541
+ // Decode splitCreditWeight from the 721 hook's metadata (4th field).
542
+ // When issueTokensForSplits is true and splits exist, this holds the weight portion
543
+ // attributable to tier splits — used to prevent split credit erasure if the extra
544
+ // hook (e.g. buyback) returns weight=0.
545
+ if (tiered721HookSpec.metadata.length >= 128) {
546
+ (,,, splitCreditWeight) = abi.decode(tiered721HookSpec.metadata, (address, address, bytes, uint256));
547
+ }
529
548
  }
530
549
  }
531
550
 
@@ -541,11 +560,27 @@ contract JBOmnichainDeployer is
541
560
  if (address(extraHook.dataHook) != address(0) && extraHook.useDataHookForPay) {
542
561
  JBBeforePayRecordedContext memory hookContext = context;
543
562
  hookContext.amount.value = projectAmount;
544
- // Pass the 721 hook's weight (which accounts for split deductions) so the data hook
545
- // makes its decisions (e.g. mint-vs-swap) based on the correct post-split weight.
546
- if (has721Hook) hookContext.weight = tiered721Weight;
563
+ // Pass the original context.weight NOT the 721 hook's split-adjusted weight.
564
+ // The extra hook (e.g. buyback) applies its own weight logic; using the 721 hook's
565
+ // already-split-adjusted weight would double-discount the split ratio.
547
566
  (weight, dataHookSpecs) = extraHook.dataHook.beforePayRecordedWith(hookContext);
548
567
  customHookCalled = true;
568
+
569
+ // The custom hook (e.g. buyback) returned a weight based on the original context.weight.
570
+ // If the 721 hook scaled weight down for tier splits, apply the same ratio so the terminal
571
+ // doesn't over-mint tokens relative to the funds actually entering the project.
572
+ // When issueTokensForSplits is true, tiered721Weight == context.weight and the ratio is 1x.
573
+ if (has721Hook && context.weight > 0 && tiered721Weight != context.weight) {
574
+ weight = mulDiv(weight, tiered721Weight, context.weight);
575
+ }
576
+
577
+ // When the extra hook returns weight=0 (e.g. buyback found no profitable swap) but tier
578
+ // splits exist with issueTokensForSplits=true, the split credit must still mint fungible tokens.
579
+ // The split credit weight is independent of buyback routing — it represents the token issuance
580
+ // for funds forwarded to tier split beneficiaries.
581
+ if (weight == 0 && splitCreditWeight > 0) {
582
+ weight = splitCreditWeight;
583
+ }
549
584
  }
550
585
  }
551
586
 
@@ -813,6 +848,9 @@ contract JBOmnichainDeployer is
813
848
  {
814
849
  // First try the latest queued ruleset — if it's been explicitly approved
815
850
  // (or has no approval hook), its hook config should take precedence.
851
+ // Conservative: only use Approved or Empty status. ApprovalExpected is intentionally
852
+ // excluded because hook selection is irreversible — if the pending ruleset is later rejected
853
+ // by the approval hook, we'd have locked in a hook from a ruleset that never became active.
816
854
  (JBRuleset memory latestQueued, JBApprovalStatus approvalStatus) =
817
855
  controller.RULESETS().latestQueuedOf(projectId);
818
856
  if (
@@ -263,11 +263,11 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
263
263
  .setPermissionsFor(
264
264
  owner,
265
265
  JBPermissionsData({
266
- operator: address(deployer),
267
- // forge-lint: disable-next-line(unsafe-typecast)
268
- projectId: uint64(projectId),
269
- permissionIds: permissionIds
270
- })
266
+ operator: address(deployer),
267
+ // forge-lint: disable-next-line(unsafe-typecast)
268
+ projectId: uint64(projectId),
269
+ permissionIds: permissionIds
270
+ })
271
271
  );
272
272
  }
273
273
 
@@ -303,9 +303,10 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
303
303
 
304
304
  JBRulesetConfig[] memory rulesets = _makeRulesetConfigs(1);
305
305
 
306
- // Should succeed without reverting.
307
306
  JBOmnichain721Config memory empty721;
308
- deployer.queueRulesetsOf(projectId, empty721, rulesets, "queue", IJBController(address(jbController())));
307
+ (uint256 rulesetId,) =
308
+ deployer.queueRulesetsOf(projectId, empty721, rulesets, "queue", IJBController(address(jbController())));
309
+ assertGt(rulesetId, 0, "Queued ruleset ID must be non-zero");
309
310
  }
310
311
 
311
312
  /// @notice Queue rulesets reverts when called in the same block as launch
@@ -363,6 +364,41 @@ contract JBOmnichainDeployerGuardTest is TestBaseWorkflow {
363
364
 
364
365
  // Now should succeed.
365
366
  JBOmnichain721Config memory empty721b;
366
- deployer.queueRulesetsOf(projectId, empty721b, rulesets, "ok-now", IJBController(address(jbController())));
367
+ (uint256 rulesetId,) =
368
+ deployer.queueRulesetsOf(projectId, empty721b, rulesets, "ok-now", IJBController(address(jbController())));
369
+ assertGt(rulesetId, 0, "Queued ruleset ID must be non-zero after warping past conflict");
370
+ }
371
+
372
+ /// @notice When no new tiers are provided and no latestQueued exists (single launch ruleset that
373
+ /// became the current ruleset), the hook is carried forward from the current ruleset (lines 862-864).
374
+ function test_queueRulesetsOf_carriesForwardFromCurrent_whenNoLatestQueued() public {
375
+ // Launch with 1 ruleset — this deploys a 721 hook stored for the launch ruleset.
376
+ uint256 projectId = _launchProject(1);
377
+ uint256 launchRulesetId = block.timestamp;
378
+ _grantDeployerQueuePermission(projectId);
379
+
380
+ // Verify the 721 hook was stored for the launch ruleset.
381
+ (IJB721TiersHook launchHook,) = deployer.tiered721HookOf(projectId, launchRulesetId);
382
+ assertEq(address(launchHook), mockHookAddr, "Launch ruleset should have 721 hook stored");
383
+
384
+ // Warp forward so the launched ruleset is the current active one and the guard passes.
385
+ vm.warp(block.timestamp + 1 days);
386
+
387
+ // Queue a new ruleset with NO new tiers → triggers carry-forward logic.
388
+ JBRulesetConfig[] memory rulesets = _makeRulesetConfigs(1);
389
+ JBOmnichain721Config memory empty721;
390
+ (uint256 queuedRulesetId,) = deployer.queueRulesetsOf(
391
+ projectId, empty721, rulesets, "carry-forward", IJBController(address(jbController()))
392
+ );
393
+
394
+ assertGt(queuedRulesetId, 0, "Queued ruleset ID must be non-zero");
395
+
396
+ // Verify the hook was carried forward to the new queued ruleset.
397
+ (IJB721TiersHook carriedHook,) = deployer.tiered721HookOf(projectId, queuedRulesetId);
398
+ assertEq(
399
+ address(carriedHook),
400
+ address(launchHook),
401
+ "Queued ruleset should carry forward the 721 hook from the current ruleset"
402
+ );
367
403
  }
368
404
  }
@@ -322,9 +322,10 @@ contract OmnichainDeployerEdgeCases is Test {
322
322
  ctx.amount.value = 1 ether;
323
323
  ctx.weight = 1000;
324
324
 
325
- // The custom hook's weight is used directly (no mulDiv scaling).
325
+ // The custom hook's weight is scaled by the 721 split ratio (500/1000 = 50%).
326
+ // mulDiv(type(uint256).max, 500, 1000) = type(uint256).max / 2.
326
327
  (uint256 weight,) = deployer.beforePayRecordedWith(ctx);
327
- assertEq(weight, type(uint256).max, "custom hook's large weight should pass through directly");
328
+ assertEq(weight, type(uint256).max / 2, "custom hook's weight scaled by 721 split ratio");
328
329
  }
329
330
 
330
331
  // =========================================================================
@@ -471,10 +472,11 @@ contract OmnichainDeployerEdgeCases is Test {
471
472
  ) = deployer.beforeCashOutRecordedWith(ctx);
472
473
 
473
474
  assertEq(cashOutTaxRate, 2000, "Custom hook should receive and override 721-adjusted tax rate");
474
- assertEq(cashOutCount, 500, "Custom hook should receive and override 721-adjusted cashOutCount");
475
- // The deployer discards the inner hook's totalSupply and computes cross-chain supply instead.
476
- // With no suckers, this equals context.totalSupply.
477
- assertEq(totalSupply, ctx.totalSupply, "Should return cross-chain totalSupply (context value with no suckers)");
475
+ // After the security fix, the 721 hook's cashOutCount is preserved — the extra hook cannot override it.
476
+ assertEq(cashOutCount, 700, "721 hook cashOutCount must be preserved (extra hook cannot override NFT pricing)");
477
+ // The deployer captures the 721 hook's totalSupply (9000) — NFT cash-outs use local-only
478
+ // denominators. The extra hook's totalSupply is discarded, preserving the 721 hook's value.
479
+ assertEq(totalSupply, 9000, "Should return 721 hook totalSupply (local denominator)");
478
480
  assertEq(hookSpecifications.length, 2, "721 and custom cash out specs should both be returned");
479
481
  assertEq(address(hookSpecifications[0].hook), mock721, "721 hook spec should come first");
480
482
  assertEq(hookSpecifications[0].amount, 11, "721 hook spec amount should be preserved");
@@ -240,14 +240,14 @@ contract OmnichainDeployerReentrancy is OmnichainForkTestBase {
240
240
  vm.prank(address(hook));
241
241
  try jbMultiTerminal()
242
242
  .cashOutTokensOf({
243
- holder: address(hook),
244
- projectId: projectId,
245
- cashOutCount: hookTokens / 2,
246
- tokenToReclaim: JBConstants.NATIVE_TOKEN,
247
- minTokensReclaimed: 0,
248
- beneficiary: payable(address(hook)),
249
- metadata: ""
250
- }) {
243
+ holder: address(hook),
244
+ projectId: projectId,
245
+ cashOutCount: hookTokens / 2,
246
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
247
+ minTokensReclaimed: 0,
248
+ beneficiary: payable(address(hook)),
249
+ metadata: ""
250
+ }) {
251
251
  // If it succeeds, check conservation.
252
252
  uint256 surplusAfter = _terminalBalance(projectId, JBConstants.NATIVE_TOKEN);
253
253
  uint256 remainingTokens = jbTokens().totalBalanceOf(address(hook), projectId);
@@ -302,7 +302,8 @@ contract Tiered721HookComposition is Test {
302
302
  );
303
303
  JBBeforePayRecordedContext memory context = _makePayContext(projectId, block.timestamp);
304
304
  (uint256 weight, JBPayHookSpecification[] memory specs) = deployer.beforePayRecordedWith(context);
305
- assertEq(weight, 555, "weight = buyback hook's returned weight (721 weight passed as context)");
305
+ // Buyback returned weight=555, 721 split ratio=750/1000: mulDiv(555, 750, 1000) = 416.
306
+ assertEq(weight, 416, "weight = buyback weight scaled by 721 split ratio");
306
307
  assertEq(specs.length, 2, "721 spec + buyback spec");
307
308
  assertEq(address(specs[0].hook), hookAddr, "first = 721 hook");
308
309
  assertEq(specs[0].amount, splitAmount, "721 split amount preserved");
@@ -622,7 +623,8 @@ contract Tiered721HookComposition is Test {
622
623
  );
623
624
  JBBeforePayRecordedContext memory context = _makePayContext(projectId, block.timestamp);
624
625
  (uint256 weight,) = deployer.beforePayRecordedWith(context);
625
- assertEq(weight, 2000, "weight = custom hook's returned weight (721 weight passed as context)");
626
+ // Custom hook returned 2000, 721 split ratio=600/1000: mulDiv(2000, 600, 1000) = 1200.
627
+ assertEq(weight, 1200, "weight = custom hook weight scaled by 721 split ratio");
626
628
  }
627
629
 
628
630
  function test_beforePay_fullSplit_weightZero() public {
@@ -690,7 +692,8 @@ contract Tiered721HookComposition is Test {
690
692
  );
691
693
  JBBeforePayRecordedContext memory context = _makePayContext(projectId, block.timestamp);
692
694
  (uint256 weight, JBPayHookSpecification[] memory specs) = deployer.beforePayRecordedWith(context);
693
- assertEq(weight, 2000, "weight = buyback hook's returned weight (721 weight passed as context)");
695
+ // Buyback returned 2000, 721 split ratio=600/1000: mulDiv(2000, 600, 1000) = 1200.
696
+ assertEq(weight, 1200, "weight = buyback weight scaled by 721 split ratio");
694
697
  assertEq(specs.length, 2, "721 spec + buyback spec");
695
698
  assertEq(address(specs[0].hook), hookAddr, "first = 721 hook");
696
699
  assertEq(specs[0].amount, 0.4 ether, "721 split amount");
@@ -709,8 +712,9 @@ contract Tiered721HookComposition is Test {
709
712
  abi.encodeWithSelector(IJBRulesetDataHook.beforePayRecordedWith.selector),
710
713
  abi.encode(uint256(800), hookSpecs)
711
714
  );
712
- // Buyback mint path returns context.weight (which is now the 721 hook's weight = 800).
713
- uint256 mintPathWeight = 800;
715
+ // Buyback mint path returns context.weight unchanged. The deployer passes the original
716
+ // context.weight (1000) to the buyback hook, so the mint path returns 1000.
717
+ uint256 mintPathWeight = 1000;
714
718
  JBPayHookSpecification[] memory emptyBuybackSpecs = new JBPayHookSpecification[](0);
715
719
  vm.mockCall(
716
720
  buybackHookAddr,
@@ -719,7 +723,8 @@ contract Tiered721HookComposition is Test {
719
723
  );
720
724
  JBBeforePayRecordedContext memory context = _makePayContext(projectId, block.timestamp);
721
725
  (uint256 weight, JBPayHookSpecification[] memory specs) = deployer.beforePayRecordedWith(context);
722
- assertEq(weight, 800, "weight = 721 hook's weight (buyback mint path returns context.weight)");
726
+ // mulDiv(1000, 800, 1000) = 800: the 721 split ratio is correctly applied.
727
+ assertEq(weight, 800, "weight = buyback weight scaled by 721 split ratio (matches 721 hook weight)");
723
728
  assertEq(specs.length, 1, "only 721 spec (buyback empty)");
724
729
  assertEq(address(specs[0].hook), hookAddr, "spec = 721 hook");
725
730
  assertEq(specs[0].amount, 0.2 ether, "721 split amount");
@@ -8,14 +8,11 @@ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
8
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
9
9
  import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
10
10
  import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
11
- import {IJBRulesets} from "@bananapus/core-v6/src/interfaces/IJBRulesets.sol";
12
11
  import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
13
- import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
14
12
  import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
15
13
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
16
14
  import {JBFundAccessLimitGroup} from "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
17
15
  import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
18
- import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
19
16
  import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
20
17
  import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
21
18
  import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
@@ -379,19 +376,13 @@ contract AuditFixesC2H6M14 is Test {
379
376
  JBRulesetConfig[] memory configs = new JBRulesetConfig[](1);
380
377
  configs[0] = _rulesetConfig();
381
378
 
382
- // 721 hook mock returns empty cash-out specs by default.
383
- JBCashOutHookSpecification[] memory emptySpecs = new JBCashOutHookSpecification[](0);
384
- vm.mockCall(
385
- hookAddr,
386
- abi.encodeWithSelector(IJBRulesetDataHook.beforeCashOutRecordedWith.selector),
387
- abi.encode(uint256(5000), uint256(1000), uint256(10_000), uint256(0), emptySpecs)
388
- );
389
-
379
+ // Disable the 721 hook for cash-out so the deployer computes cross-chain surplus itself.
380
+ // These tests verify C-2 (surplus aggregation), not NFT cashout behavior.
390
381
  deployer.launchProjectFor({
391
382
  owner: projectOwner,
392
383
  projectUri: "test",
393
384
  deploy721Config: JBOmnichain721Config({
394
- deployTiersHookConfig: _empty721HookConfig(), useDataHookForCashOut: true, salt: bytes32(0)
385
+ deployTiersHookConfig: _empty721HookConfig(), useDataHookForCashOut: false, salt: bytes32(0)
395
386
  }),
396
387
  rulesetConfigurations: configs,
397
388
  terminalConfigurations: new JBTerminalConfig[](0),
@@ -420,19 +411,13 @@ contract AuditFixesC2H6M14 is Test {
420
411
  configs[0].metadata.dataHook = extraHookAddr;
421
412
  configs[0].metadata.useDataHookForCashOut = true;
422
413
 
423
- // 721 hook mock returns empty cash-out specs.
424
- JBCashOutHookSpecification[] memory emptySpecs = new JBCashOutHookSpecification[](0);
425
- vm.mockCall(
426
- hookAddr,
427
- abi.encodeWithSelector(IJBRulesetDataHook.beforeCashOutRecordedWith.selector),
428
- abi.encode(uint256(5000), uint256(1000), uint256(10_000), uint256(0), emptySpecs)
429
- );
430
-
414
+ // Disable the 721 hook for cash-out so the deployer computes cross-chain surplus itself.
415
+ // These tests verify H-6 (extra hook forwarding), not NFT cashout behavior.
431
416
  deployer.launchProjectFor({
432
417
  owner: projectOwner,
433
418
  projectUri: "test",
434
419
  deploy721Config: JBOmnichain721Config({
435
- deployTiersHookConfig: _empty721HookConfig(), useDataHookForCashOut: true, salt: bytes32(0)
420
+ deployTiersHookConfig: _empty721HookConfig(), useDataHookForCashOut: false, salt: bytes32(0)
436
421
  }),
437
422
  rulesetConfigurations: configs,
438
423
  terminalConfigurations: new JBTerminalConfig[](0),
@@ -318,11 +318,11 @@ contract CarryForwardRejectedHookTest is TestBaseWorkflow {
318
318
  .setPermissionsFor(
319
319
  owner,
320
320
  JBPermissionsData({
321
- operator: address(deployer),
322
- // forge-lint: disable-next-line(unsafe-typecast)
323
- projectId: uint64(projectId),
324
- permissionIds: permissionIds
325
- })
321
+ operator: address(deployer),
322
+ // forge-lint: disable-next-line(unsafe-typecast)
323
+ projectId: uint64(projectId),
324
+ permissionIds: permissionIds
325
+ })
326
326
  );
327
327
  }
328
328
  }
@@ -0,0 +1,232 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
8
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
9
+ import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
10
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
11
+ import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
12
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
13
+ import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
14
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
15
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
16
+ import {JBTokenAmount} from "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
17
+ import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookDeployer.sol";
18
+ import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
19
+
20
+ import {JBOmnichainDeployer} from "../../src/JBOmnichainDeployer.sol";
21
+
22
+ /// @notice Regression test: cashOutCount from the 721 hook must be propagated
23
+ /// to the extra data hook's context. Before fix, the extra hook receives the stale
24
+ /// context.cashOutCount instead of the 721-adjusted value.
25
+ contract CashOutCountPropagationTest is Test {
26
+ uint256 internal constant PROJECT_ID = 1;
27
+ uint256 internal constant RULESET_ID = 123;
28
+
29
+ // The 721 hook will change cashOutCount from ORIGINAL to ADJUSTED.
30
+ uint256 internal constant ORIGINAL_CASH_OUT_COUNT = 50 ether;
31
+ uint256 internal constant ADJUSTED_CASH_OUT_COUNT = 7 ether;
32
+ uint256 internal constant NFT_TOTAL_SUPPLY = 21 ether;
33
+ uint256 internal constant LOCAL_SURPLUS = 10 ether;
34
+
35
+ address internal holder = makeAddr("holder");
36
+ address internal permissions = makeAddr("permissions");
37
+ address internal projects = makeAddr("projects");
38
+ address internal hookDeployer = makeAddr("hookDeployer");
39
+ address internal suckerRegistry = makeAddr("suckerRegistry");
40
+ address internal directory = makeAddr("directory");
41
+
42
+ JBOmnichainDeployer internal deployer;
43
+ Mock721Hook internal nftHook;
44
+ address internal extraHookAddr;
45
+
46
+ function setUp() public {
47
+ vm.mockCall(permissions, abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode());
48
+
49
+ deployer = new JBOmnichainDeployer(
50
+ IJBSuckerRegistry(suckerRegistry),
51
+ IJB721TiersHookDeployer(hookDeployer),
52
+ IJBPermissions(permissions),
53
+ IJBProjects(projects),
54
+ IJBDirectory(directory),
55
+ address(0)
56
+ );
57
+
58
+ nftHook = new Mock721Hook(ADJUSTED_CASH_OUT_COUNT, NFT_TOTAL_SUPPLY);
59
+ extraHookAddr = makeAddr("extraHook");
60
+
61
+ _storeTiered721Hook(address(nftHook), true);
62
+ _storeExtraDataHook(extraHookAddr, true);
63
+ _mockSuckerRegistry();
64
+ _mockExtraHook();
65
+ }
66
+
67
+ /// @notice The extra data hook should receive the 721-adjusted cashOutCount,
68
+ /// not the original context.cashOutCount.
69
+ function test_extraHookReceivesAdjustedCashOutCount() public {
70
+ // Build the expected context that the extra hook should receive.
71
+ // The deployer copies the original context and patches cashOutTaxRate, totalSupply, surplus.value.
72
+ // With the fix, it should also patch cashOutCount.
73
+ // NOTE: We build expectedContext separately (not via pointer alias) to avoid Solidity memory aliasing.
74
+ JBBeforeCashOutRecordedContext memory expectedContext = JBBeforeCashOutRecordedContext({
75
+ terminal: address(0x1234),
76
+ holder: holder,
77
+ projectId: PROJECT_ID,
78
+ rulesetId: RULESET_ID,
79
+ cashOutCount: ADJUSTED_CASH_OUT_COUNT, // THIS IS THE FIX — must be the 721-adjusted value
80
+ totalSupply: NFT_TOTAL_SUPPLY, // from 721 hook
81
+ surplus: JBTokenAmount({
82
+ token: JBConstants.NATIVE_TOKEN,
83
+ decimals: 18,
84
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
85
+ value: LOCAL_SURPLUS // 721 hook passes through surplus value
86
+ }),
87
+ useTotalSurplus: false,
88
+ cashOutTaxRate: 5000, // 721 hook passes through
89
+ beneficiaryIsFeeless: false,
90
+ metadata: ""
91
+ });
92
+
93
+ // Build the input context with the ORIGINAL cashOutCount.
94
+ JBBeforeCashOutRecordedContext memory context = _cashOutContext();
95
+
96
+ // Sanity: the original context has a different cashOutCount than what the 721 hook returns.
97
+ assertEq(context.cashOutCount, ORIGINAL_CASH_OUT_COUNT);
98
+ assertTrue(ORIGINAL_CASH_OUT_COUNT != ADJUSTED_CASH_OUT_COUNT);
99
+
100
+ // Expect the extra hook to be called with the adjusted context (including adjusted cashOutCount).
101
+ vm.expectCall(extraHookAddr, abi.encodeCall(IJBRulesetDataHook.beforeCashOutRecordedWith, (expectedContext)));
102
+
103
+ deployer.beforeCashOutRecordedWith(context);
104
+ }
105
+
106
+ function _cashOutContext() internal view returns (JBBeforeCashOutRecordedContext memory context) {
107
+ context = JBBeforeCashOutRecordedContext({
108
+ terminal: address(0x1234),
109
+ holder: holder,
110
+ projectId: PROJECT_ID,
111
+ rulesetId: RULESET_ID,
112
+ cashOutCount: ORIGINAL_CASH_OUT_COUNT,
113
+ totalSupply: 100 ether,
114
+ surplus: JBTokenAmount({
115
+ token: JBConstants.NATIVE_TOKEN,
116
+ decimals: 18,
117
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
118
+ value: LOCAL_SURPLUS
119
+ }),
120
+ useTotalSurplus: false,
121
+ cashOutTaxRate: 5000,
122
+ beneficiaryIsFeeless: false,
123
+ metadata: ""
124
+ });
125
+ }
126
+
127
+ function _mockSuckerRegistry() internal {
128
+ vm.mockCall(
129
+ suckerRegistry,
130
+ abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector, PROJECT_ID, holder),
131
+ abi.encode(false)
132
+ );
133
+ vm.mockCall(
134
+ suckerRegistry,
135
+ abi.encodeWithSelector(IJBSuckerRegistry.remoteTotalSupplyOf.selector, PROJECT_ID),
136
+ abi.encode(0)
137
+ );
138
+ vm.mockCall(
139
+ suckerRegistry,
140
+ abi.encodeWithSelector(
141
+ IJBSuckerRegistry.remoteSurplusOf.selector,
142
+ PROJECT_ID,
143
+ uint256(18),
144
+ uint256(uint32(uint160(JBConstants.NATIVE_TOKEN)))
145
+ ),
146
+ abi.encode(0)
147
+ );
148
+ }
149
+
150
+ /// @dev Mock the extra hook to return passthrough values.
151
+ function _mockExtraHook() internal {
152
+ // We use a broad mock: any call to beforeCashOutRecordedWith returns passthrough values.
153
+ // The vm.expectCall will check the exact arguments.
154
+ JBCashOutHookSpecification[] memory specs = new JBCashOutHookSpecification[](0);
155
+ vm.mockCall(
156
+ extraHookAddr,
157
+ abi.encodeWithSelector(IJBRulesetDataHook.beforeCashOutRecordedWith.selector),
158
+ abi.encode(uint256(5000), uint256(0), uint256(0), uint256(0), specs)
159
+ );
160
+ }
161
+
162
+ /// @dev Store the 721 hook at _tiered721HookOf[PROJECT_ID][RULESET_ID] (slot 1).
163
+ function _storeTiered721Hook(address hook, bool useDataHookForCashOut) internal {
164
+ bytes32 outerSlot = keccak256(abi.encode(PROJECT_ID, uint256(1)));
165
+ bytes32 valueSlot = keccak256(abi.encode(RULESET_ID, outerSlot));
166
+
167
+ uint256 packed = uint256(uint160(hook));
168
+ if (useDataHookForCashOut) packed |= uint256(1) << 160;
169
+
170
+ vm.store(address(deployer), valueSlot, bytes32(packed));
171
+ }
172
+
173
+ /// @dev Store the extra data hook at _extraDataHookOf[PROJECT_ID][RULESET_ID] (slot 0).
174
+ /// JBDeployerHookConfig has: IJBRulesetDataHook dataHook (20 bytes), bool useDataHookForPay (1 byte),
175
+ /// bool useDataHookForCashOut (1 byte). All pack into a single slot.
176
+ function _storeExtraDataHook(address hook, bool useDataHookForCashOut) internal {
177
+ bytes32 outerSlot = keccak256(abi.encode(PROJECT_ID, uint256(0)));
178
+ bytes32 valueSlot = keccak256(abi.encode(RULESET_ID, outerSlot));
179
+
180
+ uint256 packed = uint256(uint160(hook));
181
+ // useDataHookForPay is at bit 160, useDataHookForCashOut is at bit 168.
182
+ if (useDataHookForCashOut) packed |= uint256(1) << 168;
183
+
184
+ vm.store(address(deployer), valueSlot, bytes32(packed));
185
+ }
186
+ }
187
+
188
+ /// @notice Mock 721 hook that returns adjusted cashOutCount and totalSupply values.
189
+ contract Mock721Hook is IJBRulesetDataHook {
190
+ uint256 internal immutable _cashOutCount;
191
+ uint256 internal immutable _totalSupply;
192
+
193
+ constructor(uint256 cashOutCount_, uint256 totalSupply_) {
194
+ _cashOutCount = cashOutCount_;
195
+ _totalSupply = totalSupply_;
196
+ }
197
+
198
+ function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
199
+ external
200
+ view
201
+ returns (
202
+ uint256 cashOutTaxRate,
203
+ uint256 effectiveCashOutCount,
204
+ uint256 effectiveTotalSupply,
205
+ uint256 effectiveSurplusValue,
206
+ JBCashOutHookSpecification[] memory hookSpecifications
207
+ )
208
+ {
209
+ cashOutTaxRate = context.cashOutTaxRate;
210
+ effectiveCashOutCount = _cashOutCount;
211
+ effectiveTotalSupply = _totalSupply;
212
+ effectiveSurplusValue = context.surplus.value;
213
+ hookSpecifications = new JBCashOutHookSpecification[](0);
214
+ }
215
+
216
+ function beforePayRecordedWith(JBBeforePayRecordedContext calldata context)
217
+ external
218
+ pure
219
+ returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications)
220
+ {
221
+ weight = context.weight;
222
+ hookSpecifications = new JBPayHookSpecification[](0);
223
+ }
224
+
225
+ function hasMintPermissionFor(uint256, JBRuleset memory, address) external pure returns (bool) {
226
+ return false;
227
+ }
228
+
229
+ function supportsInterface(bytes4) external pure returns (bool) {
230
+ return true;
231
+ }
232
+ }