@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 +1 -1
- package/README.md +1 -0
- package/RISKS.md +4 -0
- package/package.json +4 -4
- package/src/JB721TiersHook.sol +3 -1
- package/src/libraries/JB721TiersHookLib.sol +39 -12
- package/test/unit/AuditFixes_Unit.t.sol +2 -2
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
|
|
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.
|
|
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.
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
22
|
-
"@bananapus/ownable-v6": "^0.0.
|
|
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",
|
package/src/JB721TiersHook.sol
CHANGED
|
@@ -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`:
|
|
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
|
-
|
|
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:
|
|
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
|
|
116
|
-
|
|
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) =
|