@bananapus/721-hook-v6 0.0.30 → 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.
package/ARCHITECTURE.md CHANGED
@@ -65,7 +65,7 @@ holder burns NFT
65
65
  ## Dependencies
66
66
 
67
67
  - `nana-core-v6` hooks, permissions, controller, and terminal surfaces
68
- - Optional token URI resolvers such as `banny-retail-v6` and `defifa-collection-deployer-v6`
68
+ - Optional token URI resolvers such as `banny-retail-v6` and `defifa`
69
69
 
70
70
  ## Safe Change Guide
71
71
 
package/README.md CHANGED
@@ -115,6 +115,7 @@ script/
115
115
  ## Risks And Notes
116
116
 
117
117
  - tier accounting is sensitive to reserve minting, split routing, and cross-currency normalization
118
+ - tiny split allocations can round down to zero recipient amounts; integrations should not rely on dust-sized split routing
118
119
  - custom token URI resolvers are part of the security surface because they define how metadata is served
119
120
  - projects need to be deliberate about whether the hook participates in pay, cash-out, or both paths
120
121
  - tier mutations after launch are powerful and should be permissioned carefully
package/RISKS.md CHANGED
@@ -113,3 +113,7 @@ This file focuses on the tiered-NFT accounting, reserve-mint, and cash-out risks
113
113
  ### 8.3 Currency mismatch silently skips minting (accepted degradation)
114
114
 
115
115
  If the payment currency differs from the tier pricing currency and `PRICES == address(0)`, `_processPayment` returns without minting NFTs or creating credits. Funds enter the project balance but no NFTs are issued. This is accepted because: (1) reverting would block all payments in the mismatched currency, (2) the project owner chose not to configure price feeds (by not setting `PRICES`), and (3) the funds are not lost — they increase the project's surplus and are reclaimable via cash-out. Projects that need cross-currency NFT minting must configure `JBPrices`.
116
+
117
+ ### 8.4 Tiny split allocations can round down to zero recipient amounts
118
+
119
+ Split metadata is expressed in whole token units after conversion and capping. For very small allocations, each rounded per-tier split amount can become zero even though the overall forwarded amount is still reduced by the capped split total. This is an accepted precision tradeoff for dust-sized payments: integrations should not rely on sub-precision split routing and should expect tiny split allocations to be economically lossy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,9 +17,9 @@
17
17
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-721-hook-v6'"
18
18
  },
19
19
  "dependencies": {
20
- "@bananapus/address-registry-v6": "^0.0.16",
21
- "@bananapus/core-v6": "^0.0.30",
22
- "@bananapus/ownable-v6": "^0.0.16",
20
+ "@bananapus/address-registry-v6": "^0.0.17",
21
+ "@bananapus/core-v6": "^0.0.31",
22
+ "@bananapus/ownable-v6": "^0.0.17",
23
23
  "@bananapus/permission-ids-v6": "^0.0.15",
24
24
  "@openzeppelin/contracts": "^5.6.1",
25
25
  "@prb/math": "^4.1.0",
@@ -666,7 +666,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
666
666
  (tokenIds, leftoverAmount, restrictedCost) =
667
667
  STORE.recordMint({amount: leftoverAmount, tierIds: tierIdsToMint, isOwnerMint: false});
668
668
 
669
- // Enforce `cantBuyWithCredits`: restricted tiers must be fully covered by fresh payment (not credits).
669
+ // Enforce `cantBuyWithCredits`: only tiers explicitly configured as credit-restricted must be fully
670
+ // covered by fresh payment (not stored credits). Split-bearing tiers are not automatically restricted;
671
+ // deployers must set that flag in tier configuration when they need that invariant.
670
672
  if (restrictedCost > value) revert JB721TiersHook_CantBuyWithCredits();
671
673
 
672
674
  // Mint each token.
@@ -27,6 +27,7 @@ import {JBIpfsDecoder} from "./JBIpfsDecoder.sol";
27
27
  /// @dev Handles tier adjustments, split calculations, price normalization, and split fund distribution.
28
28
  library JB721TiersHookLib {
29
29
  error JB721TiersHookLib_NoTerminalForLeftover(uint256 projectId, address token, uint256 leftoverAmount);
30
+ error JB721TiersHookLib_TokenTransferAmountMismatch(uint256 expectedAmount, uint256 receivedAmount);
30
31
  // Events mirrored from IJB721TiersHook (emitted via DELEGATECALL from the hook's context).
31
32
  event AddToBalanceReverted(uint256 indexed projectId, address token, uint256 amount, bytes reason);
32
33
  event AddTier(uint256 indexed tierId, JB721TierConfig tier, address caller);
@@ -102,8 +103,8 @@ library JB721TiersHookLib {
102
103
  {
103
104
  uint256[] memory tierIdsAdded = store.recordAddTiers(tiersToAdd);
104
105
 
105
- // slither-disable-next-line reentrancy-events
106
106
  for (uint256 i; i < tiersToAdd.length; i++) {
107
+ // slither-disable-next-line reentrancy-events
107
108
  emit AddTier({tierId: tierIdsAdded[i], tier: tiersToAdd[i], caller: caller});
108
109
  }
109
110
 
@@ -140,12 +141,18 @@ library JB721TiersHookLib {
140
141
  {
141
142
  // forge-lint: disable-next-line(unsafe-typecast)
142
143
  uint256 pricingCurrency = uint256(uint32(packedPricingContext));
143
- if (amountCurrency == pricingCurrency) return (amountValue, true);
144
+ // forge-lint: disable-next-line(unsafe-typecast)
145
+ uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
146
+ if (amountCurrency == pricingCurrency) {
147
+ if (amountDecimals == pricingDecimals) return (amountValue, true);
148
+ if (amountDecimals > pricingDecimals) {
149
+ return (amountValue / (10 ** (amountDecimals - pricingDecimals)), true);
150
+ }
151
+ return (amountValue * (10 ** (pricingDecimals - amountDecimals)), true);
152
+ }
144
153
 
145
154
  if (address(prices) == address(0)) return (0, false);
146
155
 
147
- // forge-lint: disable-next-line(unsafe-typecast)
148
- uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
149
156
  value = mulDiv({
150
157
  x: amountValue,
151
158
  y: 10 ** pricingDecimals,
@@ -331,15 +338,18 @@ library JB721TiersHookLib {
331
338
  if (convertedMetadata.length != 0) {
332
339
  (uint16[] memory tierIds, uint256[] memory amounts) =
333
340
  abi.decode(convertedMetadata, (uint16[], uint256[]));
341
+ uint256 uncappedTotal = convertedTotal;
342
+ convertedTotal = 0;
334
343
  for (uint256 i; i < amounts.length; i++) {
335
344
  // Scale down: amount * amountValue / originalTotal.
336
- amounts[i] = mulDiv({x: amounts[i], y: amountValue, denominator: convertedTotal});
345
+ amounts[i] = mulDiv({x: amounts[i], y: amountValue, denominator: uncappedTotal});
346
+ convertedTotal += amounts[i];
337
347
  }
338
348
  convertedMetadata = abi.encode(tierIds, amounts);
349
+ } else {
350
+ // Clamp the total to the payment value.
351
+ convertedTotal = amountValue;
339
352
  }
340
-
341
- // Clamp the total to the payment value.
342
- convertedTotal = amountValue;
343
353
  }
344
354
  }
345
355
 
@@ -395,7 +405,12 @@ library JB721TiersHookLib {
395
405
  {
396
406
  // For ERC20 tokens, pull from terminal using the allowance it granted via _beforeTransferTo.
397
407
  if (token != JBConstants.NATIVE_TOKEN) {
408
+ uint256 balanceBefore = IERC20(token).balanceOf(address(this));
398
409
  SafeERC20.safeTransferFrom({token: IERC20(token), from: msg.sender, to: address(this), value: amount});
410
+ uint256 receivedAmount = IERC20(token).balanceOf(address(this)) - balanceBefore;
411
+ if (receivedAmount != amount) {
412
+ revert JB721TiersHookLib_TokenTransferAmountMismatch(amount, receivedAmount);
413
+ }
399
414
  }
400
415
 
401
416
  (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(encodedSplitData, (uint16[], uint256[]));
@@ -451,6 +466,7 @@ library JB721TiersHookLib {
451
466
  // On failure, don't re-add to leftoverAmount — this prevents inflating later recipients.
452
467
  // Failed amounts accumulate as the gap between `amount` and `leftoverAmount + total sent`.
453
468
  // After the loop, we re-add leftoverPercentage-based residual naturally.
469
+ // slither-disable-next-line calls-loop,reentrancy-no-eth,reentrancy-benign,reentrancy-events
454
470
  if (!_sendPayoutToSplit({
455
471
  directory: directory,
456
472
  split: tierSplits[j],
@@ -493,6 +509,7 @@ library JB721TiersHookLib {
493
509
  metadata: bytes("")
494
510
  }) {}
495
511
  catch (bytes memory reason) {
512
+ // slither-disable-next-line reentrancy-events
496
513
  emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
497
514
  }
498
515
  } else {
@@ -509,6 +526,7 @@ library JB721TiersHookLib {
509
526
  catch (bytes memory reason) {
510
527
  // Reset approval on failure.
511
528
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
529
+ // slither-disable-next-line reentrancy-events
512
530
  emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
513
531
  }
514
532
  }
@@ -541,9 +559,11 @@ library JB721TiersHookLib {
541
559
  if (isNativeToken) {
542
560
  // Wrap in try-catch so a reverting hook doesn't brick all project payments.
543
561
  // On revert, ETH stays with the caller and we return false.
562
+ // slither-disable-next-line calls-loop,reentrancy-no-eth,reentrancy-events
544
563
  try split.hook.processSplitWith{value: amount}(context) {
545
564
  return true;
546
565
  } catch (bytes memory reason) {
566
+ // slither-disable-next-line reentrancy-events
547
567
  emit SplitPayoutReverted({
548
568
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
549
569
  });
@@ -556,8 +576,10 @@ library JB721TiersHookLib {
556
576
  // cause the caller to skip subtracting this amount from leftoverAmount, leading
557
577
  // to a double-spend when the leftover is later sent to the project's balance.
558
578
  SafeERC20.safeTransfer({token: IERC20(token), to: address(split.hook), value: amount});
579
+ // slither-disable-next-line calls-loop,reentrancy-no-eth,reentrancy-events
559
580
  try split.hook.processSplitWith(context) {}
560
581
  catch (bytes memory reason) {
582
+ // slither-disable-next-line reentrancy-events
561
583
  emit SplitPayoutReverted({
562
584
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
563
585
  });
@@ -572,7 +594,7 @@ library JB721TiersHookLib {
572
594
  // Wrap terminal calls in try-catch to prevent a failing terminal from bricking payments.
573
595
  if (split.preferAddToBalance) {
574
596
  if (isNativeToken) {
575
- // slither-disable-next-line arbitrary-send-eth,calls-loop
597
+ // slither-disable-next-line arbitrary-send-eth,calls-loop,reentrancy-no-eth,reentrancy-events
576
598
  try terminal.addToBalanceOf{value: amount}({
577
599
  projectId: split.projectId,
578
600
  token: token,
@@ -583,6 +605,7 @@ library JB721TiersHookLib {
583
605
  }) {
584
606
  return true;
585
607
  } catch (bytes memory reason) {
608
+ // slither-disable-next-line reentrancy-events
586
609
  emit SplitPayoutReverted({
587
610
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
588
611
  });
@@ -590,7 +613,7 @@ library JB721TiersHookLib {
590
613
  }
591
614
  } else {
592
615
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: amount});
593
- // slither-disable-next-line calls-loop
616
+ // slither-disable-next-line calls-loop,reentrancy-no-eth,reentrancy-events
594
617
  try terminal.addToBalanceOf({
595
618
  projectId: split.projectId,
596
619
  token: token,
@@ -603,6 +626,7 @@ library JB721TiersHookLib {
603
626
  } catch (bytes memory reason) {
604
627
  // Reset approval on failure so tokens aren't left approved to the terminal.
605
628
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
629
+ // slither-disable-next-line reentrancy-events
606
630
  emit SplitPayoutReverted({
607
631
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
608
632
  });
@@ -611,7 +635,7 @@ library JB721TiersHookLib {
611
635
  }
612
636
  } else {
613
637
  if (isNativeToken) {
614
- // slither-disable-next-line arbitrary-send-eth,unused-return,calls-loop
638
+ // slither-disable-next-line arbitrary-send-eth,unused-return,calls-loop,reentrancy-events
615
639
  try terminal.pay{value: amount}({
616
640
  projectId: split.projectId,
617
641
  token: token,
@@ -623,6 +647,7 @@ library JB721TiersHookLib {
623
647
  }) {
624
648
  return true;
625
649
  } catch (bytes memory reason) {
650
+ // slither-disable-next-line reentrancy-events
626
651
  emit SplitPayoutReverted({
627
652
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
628
653
  });
@@ -630,7 +655,7 @@ library JB721TiersHookLib {
630
655
  }
631
656
  } else {
632
657
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: amount});
633
- // slither-disable-next-line unused-return,calls-loop
658
+ // slither-disable-next-line unused-return,calls-loop,reentrancy-no-eth,reentrancy-events
634
659
  try terminal.pay({
635
660
  projectId: split.projectId,
636
661
  token: token,
@@ -644,6 +669,7 @@ library JB721TiersHookLib {
644
669
  } catch (bytes memory reason) {
645
670
  // Reset approval on failure so tokens aren't left approved to the terminal.
646
671
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
672
+ // slither-disable-next-line reentrancy-events
647
673
  emit SplitPayoutReverted({
648
674
  projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
649
675
  });
@@ -661,6 +687,7 @@ library JB721TiersHookLib {
661
687
  // false on failure instead of reverting. This handles non-standard tokens (e.g. USDT)
662
688
  // that return void, while routing failed transfers to the project's balance instead
663
689
  // of bricking all payments.
690
+ // slither-disable-next-line calls-loop
664
691
  (bool callSuccess, bytes memory returndata) =
665
692
  address(token).call(abi.encodeCall(IERC20.transfer, (split.beneficiary, amount)));
666
693
  if (!callSuccess || (returndata.length != 0 && !abi.decode(returndata, (bool)))) return false;
@@ -112,8 +112,8 @@ contract Test_AuditFixes_Unit is UnitTestSetup {
112
112
 
113
113
  (, JBPayHookSpecification[] memory specs) = testHook.beforePayRecordedWith(context);
114
114
 
115
- // The total forwarded amount must be capped at 2 ETH (the actual payment value).
116
- assertEq(specs[0].amount, 2 ether, "Total split should be capped at payment value");
115
+ // The total forwarded amount must be capped at the payment value (may be up to 1 wei less due to rounding).
116
+ assertApproxEqAbs(specs[0].amount, 2 ether, 1, "Total split should be capped at payment value");
117
117
 
118
118
  // Decode the per-tier breakdown from hookMetadata.
119
119
  (uint16[] memory resultTierIds, uint256[] memory resultAmounts) =