@bananapus/721-hook-v6 0.0.30 → 0.0.32
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/CHANGELOG.md +2 -2
- package/README.md +1 -0
- package/RISKS.md +5 -1
- package/package.json +4 -4
- package/src/JB721TiersHook.sol +44 -19
- package/src/JB721TiersHookProjectDeployer.sol +15 -3
- package/src/JB721TiersHookStore.sol +119 -21
- package/src/abstract/JB721Hook.sol +5 -1
- package/src/interfaces/IJB721TiersHookStore.sol +27 -0
- package/src/libraries/JB721TiersHookLib.sol +110 -40
- package/src/libraries/JBIpfsDecoder.sol +17 -4
- package/test/regression/BrokenTerminalDoesNotDos.t.sol +11 -35
- 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/CHANGELOG.md
CHANGED
|
@@ -39,7 +39,7 @@ This file describes the verified change from `nana-721-hook-v5` to the current `
|
|
|
39
39
|
|
|
40
40
|
## Indexer impact
|
|
41
41
|
|
|
42
|
-
- New events: `AddToBalanceReverted
|
|
42
|
+
- New events: `AddToBalanceReverted` (declared but no longer emitted -- replaced by `JB721TiersHookLib_SplitFallbackFailed` revert error), `SetName`, `SetSymbol`, `SplitPayoutReverted`.
|
|
43
43
|
- Tier config decoding changed because `JB721TierConfig` is no longer v5-compatible.
|
|
44
44
|
- Collection metadata can now change after deployment, so one-time indexing of `name` and `symbol` is no longer sufficient.
|
|
45
45
|
|
|
@@ -58,7 +58,7 @@ This file describes the verified change from `nana-721-hook-v5` to the current `
|
|
|
58
58
|
- `pricingContext()`
|
|
59
59
|
- `setMetadata(...)`
|
|
60
60
|
- Added events
|
|
61
|
-
- `AddToBalanceReverted`
|
|
61
|
+
- `AddToBalanceReverted` (declared in interface but no longer emitted; the library now reverts with `JB721TiersHookLib_SplitFallbackFailed` instead)
|
|
62
62
|
- `SetName`
|
|
63
63
|
- `SetSymbol`
|
|
64
64
|
- `SplitPayoutReverted`
|
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
|
@@ -46,7 +46,7 @@ This file focuses on the tiered-NFT accounting, reserve-mint, and cash-out risks
|
|
|
46
46
|
- **Split hook callbacks (`processSplitWith`).** During `afterPayRecordedWith` -> `_processPayment` -> `distributeAll`, the library calls `split.hook.processSplitWith{value}()` for each split with a hook. This executes arbitrary code. At callback time: NFTs already minted, `payCreditsOf` updated, `remainingSupply` decremented in the store. Reentering `afterPayRecordedWith` requires terminal authentication and processes as an independent payment. All split hook and terminal calls are wrapped in try-catch to prevent a single reverting recipient from bricking all payments to the project. For native token hooks, a revert returns false (ETH stays in the contract and routes to project balance). For ERC20 hooks, tokens are transferred before the callback; a revert still returns true because the tokens have already left the contract. Tested: `TestAuditGaps_Reentrancy` confirms reentrancy is blocked by terminal check.
|
|
47
47
|
- **Split beneficiary ETH sends.** `_sendPayoutToSplit` uses `beneficiary.call{value: amount}("")`. If the beneficiary reverts, the function returns `false` and the failed amount is accumulated separately, then routed to the project's balance after the distribution loop via `addToBalanceOf`. Later split recipients receive only their proportional share, not the failed recipient's share. Does not revert the entire payment.
|
|
48
48
|
- **Terminal `.pay()` / `.addToBalanceOf()` during split distribution.** For project-targeted splits, the library calls the target project's primary terminal via try-catch. A reverting terminal returns false, routing the funds to the project's balance instead. For ERC20 terminal calls, approval is reset to zero on failure to prevent dangling approvals. The target terminal could call back into the hook, but the hook's state is fully settled (supply, credits, mint state). Reentrancy through this path cannot double-mint or corrupt state.
|
|
49
|
-
- **`afterCashOutRecordedWith` execution order.** Burns tokens via `_burn()` -> `_update()` -> `STORE.
|
|
49
|
+
- **`afterCashOutRecordedWith` execution order.** Burns tokens via `_burn()` -> `_update()` -> `STORE.tierTransferInfoOfTokenId()` in a loop, then calls `STORE.recordBurn()`. ERC721 `_update` triggers the store's tier balance decrement. Burns go to `address(0)`, so no `onERC721Received` callback.
|
|
50
50
|
- **No `ReentrancyGuard`.** Protection relies on state ordering (all `STORE.record*` calls before external calls), terminal authentication checks, and try-catch wrapping of all external calls in `_sendPayoutToSplit`. `_mint()` uses the non-safe variant, avoiding `onERC721Received` callbacks during minting.
|
|
51
51
|
|
|
52
52
|
## 4. Gas/DoS Vectors
|
|
@@ -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.32",
|
|
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.32",
|
|
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
|
@@ -25,7 +25,6 @@ import {IJB721TokenUriResolver} from "./interfaces/IJB721TokenUriResolver.sol";
|
|
|
25
25
|
import {JB721TiersHookLib} from "./libraries/JB721TiersHookLib.sol";
|
|
26
26
|
import {JB721TiersRulesetMetadataResolver} from "./libraries/JB721TiersRulesetMetadataResolver.sol";
|
|
27
27
|
import {JB721InitTiersConfig} from "./structs/JB721InitTiersConfig.sol";
|
|
28
|
-
import {JB721Tier} from "./structs/JB721Tier.sol";
|
|
29
28
|
import {JB721TierConfig} from "./structs/JB721TierConfig.sol";
|
|
30
29
|
import {JB721TiersHookFlags} from "./structs/JB721TiersHookFlags.sol";
|
|
31
30
|
import {JB721TiersMintReservesConfig} from "./structs/JB721TiersMintReservesConfig.sol";
|
|
@@ -401,12 +400,16 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
401
400
|
/// @dev "Pending" means that the NFTs have been reserved, but have not been minted yet.
|
|
402
401
|
/// @param reserveMintConfigs Contains information about how many reserved tokens to mint for each tier.
|
|
403
402
|
function mintPendingReservesFor(JB721TiersMintReservesConfig[] calldata reserveMintConfigs) external override {
|
|
404
|
-
for (uint256 i; i < reserveMintConfigs.length;
|
|
403
|
+
for (uint256 i; i < reserveMintConfigs.length;) {
|
|
405
404
|
// Get a reference to the params being iterated upon.
|
|
406
405
|
JB721TiersMintReservesConfig memory params = reserveMintConfigs[i];
|
|
407
406
|
|
|
408
407
|
// Mint pending reserved NFTs from the tier.
|
|
409
408
|
mintPendingReservesFor({tierId: params.tierId, count: params.count});
|
|
409
|
+
|
|
410
|
+
unchecked {
|
|
411
|
+
++i;
|
|
412
|
+
}
|
|
410
413
|
}
|
|
411
414
|
}
|
|
412
415
|
|
|
@@ -432,11 +435,15 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
432
435
|
account: owner(), projectId: PROJECT_ID, permissionId: JBPermissionIds.SET_721_DISCOUNT_PERCENT
|
|
433
436
|
});
|
|
434
437
|
|
|
435
|
-
for (uint256 i; i < configs.length;
|
|
438
|
+
for (uint256 i; i < configs.length;) {
|
|
436
439
|
// Set the config being iterated on.
|
|
437
440
|
JB721TiersSetDiscountPercentConfig memory config = configs[i];
|
|
438
441
|
|
|
439
442
|
_setDiscountPercentOf({tierId: config.tierId, discountPercent: config.discountPercent});
|
|
443
|
+
|
|
444
|
+
unchecked {
|
|
445
|
+
++i;
|
|
446
|
+
}
|
|
440
447
|
}
|
|
441
448
|
}
|
|
442
449
|
|
|
@@ -470,25 +477,28 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
470
477
|
account: owner(), projectId: PROJECT_ID, permissionId: JBPermissionIds.SET_721_METADATA
|
|
471
478
|
});
|
|
472
479
|
|
|
480
|
+
// Cache _msgSender() at function entry to avoid repeated calls.
|
|
481
|
+
address caller = _msgSender();
|
|
482
|
+
|
|
473
483
|
if (bytes(name).length != 0) {
|
|
474
484
|
// Store the new collection name.
|
|
475
485
|
_setName(name);
|
|
476
|
-
emit SetName({name: name, caller:
|
|
486
|
+
emit SetName({name: name, caller: caller});
|
|
477
487
|
}
|
|
478
488
|
if (bytes(symbol).length != 0) {
|
|
479
489
|
// Store the new collection symbol.
|
|
480
490
|
_setSymbol(symbol);
|
|
481
|
-
emit SetSymbol({symbol: symbol, caller:
|
|
491
|
+
emit SetSymbol({symbol: symbol, caller: caller});
|
|
482
492
|
}
|
|
483
493
|
if (bytes(baseUri).length != 0) {
|
|
484
494
|
// Store the new base URI.
|
|
485
495
|
baseURI = baseUri;
|
|
486
|
-
emit SetBaseUri({baseUri: baseUri, caller:
|
|
496
|
+
emit SetBaseUri({baseUri: baseUri, caller: caller});
|
|
487
497
|
}
|
|
488
498
|
if (bytes(contractUri).length != 0) {
|
|
489
499
|
// Store the new contract URI.
|
|
490
500
|
contractURI = contractUri;
|
|
491
|
-
emit SetContractUri({uri: contractUri, caller:
|
|
501
|
+
emit SetContractUri({uri: contractUri, caller: caller});
|
|
492
502
|
}
|
|
493
503
|
|
|
494
504
|
// `address(this)` is the sentinel value meaning "leave unchanged" (since `address(0)` clears the resolver).
|
|
@@ -498,7 +508,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
498
508
|
_recordSetTokenUriResolver(tokenUriResolver);
|
|
499
509
|
}
|
|
500
510
|
if (encodedIPFSUriTierId != 0 && encodedIPFSUri != bytes32(0)) {
|
|
501
|
-
emit SetEncodedIPFSUri({tierId: encodedIPFSUriTierId, encodedUri: encodedIPFSUri, caller:
|
|
511
|
+
emit SetEncodedIPFSUri({tierId: encodedIPFSUriTierId, encodedUri: encodedIPFSUri, caller: caller});
|
|
502
512
|
|
|
503
513
|
// Store the new encoded IPFS URI.
|
|
504
514
|
STORE.recordSetEncodedIPFSUriOf({tierId: encodedIPFSUriTierId, encodedIPFSUri: encodedIPFSUri});
|
|
@@ -531,17 +541,22 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
531
541
|
// slither-disable-next-line calls-loop
|
|
532
542
|
address reserveBeneficiary = STORE.reserveBeneficiaryOf({hook: address(this), tierId: tierId});
|
|
533
543
|
|
|
534
|
-
|
|
544
|
+
// Cache _msgSender() before the loop to avoid repeated calls.
|
|
545
|
+
address caller = _msgSender();
|
|
546
|
+
|
|
547
|
+
for (uint256 i; i < count;) {
|
|
535
548
|
// Set the token ID.
|
|
536
549
|
uint256 tokenId = tokenIds[i];
|
|
537
550
|
|
|
538
|
-
emit MintReservedNft({
|
|
539
|
-
tokenId: tokenId, tierId: tierId, beneficiary: reserveBeneficiary, caller: _msgSender()
|
|
540
|
-
});
|
|
551
|
+
emit MintReservedNft({tokenId: tokenId, tierId: tierId, beneficiary: reserveBeneficiary, caller: caller});
|
|
541
552
|
|
|
542
553
|
// Mint the NFT.
|
|
543
554
|
// slither-disable-next-line reentrency-events
|
|
544
555
|
_mint({to: reserveBeneficiary, tokenId: tokenId});
|
|
556
|
+
|
|
557
|
+
unchecked {
|
|
558
|
+
++i;
|
|
559
|
+
}
|
|
545
560
|
}
|
|
546
561
|
}
|
|
547
562
|
|
|
@@ -598,17 +613,24 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
598
613
|
)
|
|
599
614
|
internal
|
|
600
615
|
{
|
|
601
|
-
|
|
616
|
+
// Cache _msgSender() before the loop to avoid repeated calls.
|
|
617
|
+
address caller = _msgSender();
|
|
618
|
+
|
|
619
|
+
for (uint256 i; i < tokenIds.length;) {
|
|
602
620
|
emit Mint({
|
|
603
621
|
tokenId: tokenIds[i],
|
|
604
622
|
tierId: tierIds[i],
|
|
605
623
|
beneficiary: beneficiary,
|
|
606
624
|
totalAmountPaid: totalAmountPaid,
|
|
607
|
-
caller:
|
|
625
|
+
caller: caller
|
|
608
626
|
});
|
|
609
627
|
|
|
610
628
|
// slither-disable-next-line reentrancy-events
|
|
611
629
|
_mint({to: beneficiary, tokenId: tokenIds[i]});
|
|
630
|
+
|
|
631
|
+
unchecked {
|
|
632
|
+
++i;
|
|
633
|
+
}
|
|
612
634
|
}
|
|
613
635
|
}
|
|
614
636
|
|
|
@@ -666,7 +688,9 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
666
688
|
(tokenIds, leftoverAmount, restrictedCost) =
|
|
667
689
|
STORE.recordMint({amount: leftoverAmount, tierIds: tierIdsToMint, isOwnerMint: false});
|
|
668
690
|
|
|
669
|
-
// Enforce `cantBuyWithCredits`:
|
|
691
|
+
// Enforce `cantBuyWithCredits`: only tiers explicitly configured as credit-restricted must be fully
|
|
692
|
+
// covered by fresh payment (not stored credits). Split-bearing tiers are not automatically restricted;
|
|
693
|
+
// deployers must set that flag in tier configuration when they need that invariant.
|
|
670
694
|
if (restrictedCost > value) revert JB721TiersHook_CantBuyWithCredits();
|
|
671
695
|
|
|
672
696
|
// Mint each token.
|
|
@@ -766,9 +790,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
766
790
|
/// @param to The address the NFT is being transferred to.
|
|
767
791
|
/// @param tokenId The token ID of the NFT being transferred.
|
|
768
792
|
function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address from) {
|
|
769
|
-
// Get
|
|
793
|
+
// Get only the tier ID and transfersPausable flag (lightweight — avoids full struct construction).
|
|
770
794
|
// slither-disable-next-line calls-loop
|
|
771
|
-
|
|
795
|
+
(uint256 tierId, bool transfersPausable) =
|
|
796
|
+
STORE.tierTransferInfoOfTokenId({hook: address(this), tokenId: tokenId});
|
|
772
797
|
|
|
773
798
|
// Record the transfers and keep a reference to where the token is coming from.
|
|
774
799
|
from = super._update({to: to, tokenId: tokenId, auth: auth});
|
|
@@ -776,7 +801,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
776
801
|
// Transfers must not be paused (when not minting or burning).
|
|
777
802
|
if (from != address(0)) {
|
|
778
803
|
// If transfers are pausable, check if they're paused.
|
|
779
|
-
if (
|
|
804
|
+
if (transfersPausable) {
|
|
780
805
|
// Get a reference to the project's current ruleset.
|
|
781
806
|
JBRuleset memory ruleset = _currentRulesetOf(PROJECT_ID);
|
|
782
807
|
|
|
@@ -796,6 +821,6 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
796
821
|
|
|
797
822
|
// Record the transfer.
|
|
798
823
|
// slither-disable-next-line reentrency-events,calls-loop
|
|
799
|
-
STORE.recordTransferForTier({tierId:
|
|
824
|
+
STORE.recordTransferForTier({tierId: tierId, from: from, to: to});
|
|
800
825
|
}
|
|
801
826
|
}
|
|
@@ -239,7 +239,7 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
239
239
|
new JBRulesetConfig[](launchProjectConfig.rulesetConfigurations.length);
|
|
240
240
|
|
|
241
241
|
// Set the data hook to be active for pay transactions for each ruleset configuration.
|
|
242
|
-
for (uint256 i; i < launchProjectConfig.rulesetConfigurations.length;
|
|
242
|
+
for (uint256 i; i < launchProjectConfig.rulesetConfigurations.length;) {
|
|
243
243
|
// Set the pay data ruleset config being iterated on.
|
|
244
244
|
JBPayDataHookRulesetConfig memory payDataRulesetConfig = launchProjectConfig.rulesetConfigurations[i];
|
|
245
245
|
|
|
@@ -274,6 +274,10 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
274
274
|
splitGroups: payDataRulesetConfig.splitGroups,
|
|
275
275
|
fundAccessLimitGroups: payDataRulesetConfig.fundAccessLimitGroups
|
|
276
276
|
});
|
|
277
|
+
|
|
278
|
+
unchecked {
|
|
279
|
+
++i;
|
|
280
|
+
}
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
// Launch the project.
|
|
@@ -307,7 +311,7 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
307
311
|
new JBRulesetConfig[](launchRulesetsConfig.rulesetConfigurations.length);
|
|
308
312
|
|
|
309
313
|
// Set the data hook to be active for pay transactions for each ruleset configuration.
|
|
310
|
-
for (uint256 i; i < launchRulesetsConfig.rulesetConfigurations.length;
|
|
314
|
+
for (uint256 i; i < launchRulesetsConfig.rulesetConfigurations.length;) {
|
|
311
315
|
// Set the pay data ruleset config being iterated on.
|
|
312
316
|
JBPayDataHookRulesetConfig memory payDataRulesetConfig = launchRulesetsConfig.rulesetConfigurations[i];
|
|
313
317
|
|
|
@@ -342,6 +346,10 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
342
346
|
splitGroups: payDataRulesetConfig.splitGroups,
|
|
343
347
|
fundAccessLimitGroups: payDataRulesetConfig.fundAccessLimitGroups
|
|
344
348
|
});
|
|
349
|
+
|
|
350
|
+
unchecked {
|
|
351
|
+
++i;
|
|
352
|
+
}
|
|
345
353
|
}
|
|
346
354
|
|
|
347
355
|
// Launch the rulesets.
|
|
@@ -373,7 +381,7 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
373
381
|
new JBRulesetConfig[](queueRulesetsConfig.rulesetConfigurations.length);
|
|
374
382
|
|
|
375
383
|
// Set the data hook to be active for pay transactions for each ruleset configuration.
|
|
376
|
-
for (uint256 i; i < queueRulesetsConfig.rulesetConfigurations.length;
|
|
384
|
+
for (uint256 i; i < queueRulesetsConfig.rulesetConfigurations.length;) {
|
|
377
385
|
// Set the pay data ruleset config being iterated on.
|
|
378
386
|
JBPayDataHookRulesetConfig memory payDataRulesetConfig = queueRulesetsConfig.rulesetConfigurations[i];
|
|
379
387
|
|
|
@@ -408,6 +416,10 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
408
416
|
splitGroups: payDataRulesetConfig.splitGroups,
|
|
409
417
|
fundAccessLimitGroups: payDataRulesetConfig.fundAccessLimitGroups
|
|
410
418
|
});
|
|
419
|
+
|
|
420
|
+
unchecked {
|
|
421
|
+
++i;
|
|
422
|
+
}
|
|
411
423
|
}
|
|
412
424
|
|
|
413
425
|
// Queue the rulesets.
|
|
@@ -214,6 +214,47 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/// @notice Get only the pricing fields for a tier, avoiding full struct construction.
|
|
218
|
+
/// @param hook The 721 contract that the tier belongs to.
|
|
219
|
+
/// @param id The tier ID.
|
|
220
|
+
/// @return price The tier price.
|
|
221
|
+
/// @return splitPercent The split percent.
|
|
222
|
+
/// @return discountPercent The discount percent.
|
|
223
|
+
function tierPricingOf(
|
|
224
|
+
address hook,
|
|
225
|
+
uint256 id
|
|
226
|
+
)
|
|
227
|
+
external
|
|
228
|
+
view
|
|
229
|
+
override
|
|
230
|
+
returns (uint104 price, uint32 splitPercent, uint8 discountPercent)
|
|
231
|
+
{
|
|
232
|
+
JBStored721Tier memory storedTier = _storedTierOf[hook][id];
|
|
233
|
+
price = storedTier.price;
|
|
234
|
+
splitPercent = storedTier.splitPercent;
|
|
235
|
+
discountPercent = storedTier.discountPercent;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// @notice Get only the tier ID and transfersPausable flag for a token, avoiding full struct construction.
|
|
239
|
+
/// @param hook The 721 hook address.
|
|
240
|
+
/// @param tokenId The token ID.
|
|
241
|
+
/// @return tierId The tier ID.
|
|
242
|
+
/// @return transfersPausable Whether transfers are paused for this tier.
|
|
243
|
+
function tierTransferInfoOfTokenId(
|
|
244
|
+
address hook,
|
|
245
|
+
uint256 tokenId
|
|
246
|
+
)
|
|
247
|
+
external
|
|
248
|
+
view
|
|
249
|
+
override
|
|
250
|
+
returns (uint256 tierId, bool transfersPausable)
|
|
251
|
+
{
|
|
252
|
+
tierId = tierIdOfToken(tokenId);
|
|
253
|
+
JBStored721Tier memory storedTier = _storedTierOf[hook][tierId];
|
|
254
|
+
// Bit 1 (0-indexed) of packedBools is transfersPausable.
|
|
255
|
+
transfersPausable = (storedTier.packedBools & 0x2) != 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
217
258
|
/// @notice Gets an array of currently active 721 tiers for the provided 721 contract.
|
|
218
259
|
/// @param hook The 721 contract to get the tiers of.
|
|
219
260
|
/// @param categories An array tier categories to get tiers from. Send an empty array to get all categories.
|
|
@@ -345,12 +386,19 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
345
386
|
// Keep a reference to the greatest tier ID.
|
|
346
387
|
uint256 maxTierId = maxTierIdOf[hook];
|
|
347
388
|
|
|
348
|
-
for (uint256 i = maxTierId; i != 0;
|
|
389
|
+
for (uint256 i = maxTierId; i != 0;) {
|
|
349
390
|
// Set the tier being iterated on.
|
|
350
391
|
JBStored721Tier memory storedTier = _storedTierOf[hook][i];
|
|
351
392
|
|
|
352
|
-
//
|
|
353
|
-
|
|
393
|
+
// Skip unminted tiers — they contribute zero supply.
|
|
394
|
+
if (storedTier.initialSupply != storedTier.remainingSupply) {
|
|
395
|
+
// Increment the total supply by the number of tokens already minted.
|
|
396
|
+
supply += storedTier.initialSupply - (storedTier.remainingSupply + numberOfBurnedFor[hook][i]);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
unchecked {
|
|
400
|
+
--i;
|
|
401
|
+
}
|
|
354
402
|
}
|
|
355
403
|
}
|
|
356
404
|
|
|
@@ -366,12 +414,17 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
366
414
|
uint256 maxTierId = maxTierIdOf[hook];
|
|
367
415
|
|
|
368
416
|
// Loop through all tiers.
|
|
369
|
-
for (uint256 i = maxTierId; i != 0;
|
|
417
|
+
for (uint256 i = maxTierId; i != 0;) {
|
|
370
418
|
// Get a reference to the account's balance in this tier.
|
|
371
419
|
uint256 balance = tierBalanceOf[hook][account][i];
|
|
372
420
|
|
|
373
421
|
// If the account has no balance, return.
|
|
374
|
-
if (balance == 0)
|
|
422
|
+
if (balance == 0) {
|
|
423
|
+
unchecked {
|
|
424
|
+
--i;
|
|
425
|
+
}
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
375
428
|
|
|
376
429
|
// Get the tier.
|
|
377
430
|
JBStored721Tier memory storedTier = _storedTierOf[hook][i];
|
|
@@ -382,6 +435,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
382
435
|
// Add the voting units for the address' balance in this tier.
|
|
383
436
|
// Use custom voting units if set. Otherwise, use the tier's price.
|
|
384
437
|
units += balance * (useVotingUnits ? _tierVotingUnitsOf[hook][i] : storedTier.price);
|
|
438
|
+
|
|
439
|
+
unchecked {
|
|
440
|
+
--i;
|
|
441
|
+
}
|
|
385
442
|
}
|
|
386
443
|
}
|
|
387
444
|
|
|
@@ -399,9 +456,13 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
399
456
|
uint256 maxTierId = maxTierIdOf[hook];
|
|
400
457
|
|
|
401
458
|
// Loop through all tiers.
|
|
402
|
-
for (uint256 i = maxTierId; i != 0;
|
|
459
|
+
for (uint256 i = maxTierId; i != 0;) {
|
|
403
460
|
// Get a reference to the account's balance within this tier.
|
|
404
461
|
balance += tierBalanceOf[hook][owner][i];
|
|
462
|
+
|
|
463
|
+
unchecked {
|
|
464
|
+
--i;
|
|
465
|
+
}
|
|
405
466
|
}
|
|
406
467
|
}
|
|
407
468
|
|
|
@@ -418,8 +479,12 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
418
479
|
// that affect the purchase price, but the NFT's weight in the cash out curve is always based on its
|
|
419
480
|
// tier's original price. This prevents discount changes from altering the cash out value of already-minted
|
|
420
481
|
// NFTs.
|
|
421
|
-
for (uint256 i; i < tokenIds.length;
|
|
482
|
+
for (uint256 i; i < tokenIds.length;) {
|
|
422
483
|
weight += _storedTierOf[hook][tierIdOfToken(tokenIds[i])].price;
|
|
484
|
+
|
|
485
|
+
unchecked {
|
|
486
|
+
++i;
|
|
487
|
+
}
|
|
423
488
|
}
|
|
424
489
|
}
|
|
425
490
|
|
|
@@ -474,18 +539,32 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
474
539
|
|
|
475
540
|
// Add each 721's original price (from its tier) to the weight.
|
|
476
541
|
// Uses the full tier price, not the discounted price — by design. See `cashOutWeightOf` for rationale.
|
|
477
|
-
for (uint256 i = 1; i <= maxTierId;
|
|
542
|
+
for (uint256 i = 1; i <= maxTierId;) {
|
|
478
543
|
// Keep a reference to the stored tier.
|
|
479
544
|
JBStored721Tier memory storedTier = _storedTierOf[hook][i];
|
|
480
545
|
|
|
546
|
+
// Skip empty tiers (zero mints and zero burns) — they contribute zero weight.
|
|
547
|
+
if (storedTier.initialSupply == storedTier.remainingSupply && numberOfBurnedFor[hook][i] == 0) {
|
|
548
|
+
unchecked {
|
|
549
|
+
++i;
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
481
554
|
// Add the tier's price multiplied by the number of minted NFTs plus pending reserves.
|
|
482
555
|
// Pending reserves are included by design — they represent committed obligations that will be
|
|
483
556
|
// minted to the reserve beneficiary. Including them in the denominator ensures cash-out values
|
|
484
557
|
// account for the full diluted supply, preventing early cashers from extracting more than their
|
|
485
558
|
// fair share before reserves are minted.
|
|
559
|
+
// Note: removed tiers are NOT skipped here because minted NFTs from removed tiers still carry
|
|
560
|
+
// cash-out weight, and their pending reserves can still be minted.
|
|
486
561
|
weight += storedTier.price
|
|
487
562
|
* ((storedTier.initialSupply - (storedTier.remainingSupply + numberOfBurnedFor[hook][i]))
|
|
488
563
|
+ _numberOfPendingReservesFor({hook: hook, tierId: i, storedTier: storedTier}));
|
|
564
|
+
|
|
565
|
+
unchecked {
|
|
566
|
+
++i;
|
|
567
|
+
}
|
|
489
568
|
}
|
|
490
569
|
}
|
|
491
570
|
|
|
@@ -831,7 +910,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
831
910
|
// Keep a reference to the 721 contract's flags.
|
|
832
911
|
JB721TiersHookFlags memory flags = _flagsOf[msg.sender];
|
|
833
912
|
|
|
834
|
-
for (uint256 i; i < tiersToAdd.length;
|
|
913
|
+
for (uint256 i; i < tiersToAdd.length;) {
|
|
835
914
|
// Set the tier being iterated upon.
|
|
836
915
|
JB721TierConfig memory tierToAdd = tiersToAdd[i];
|
|
837
916
|
|
|
@@ -841,17 +920,14 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
841
920
|
revert JB721TiersHookStore_InvalidQuantity(tierToAdd.initialSupply, _ONE_BILLION - 1);
|
|
842
921
|
}
|
|
843
922
|
|
|
844
|
-
// Keep a reference to the previous tier.
|
|
845
|
-
JB721TierConfig memory previousTier;
|
|
846
|
-
|
|
847
923
|
// Make sure the tier's category is greater than or equal to the previously added tier's category.
|
|
924
|
+
// Access calldata directly for the previous tier's category — it's only used once per iteration.
|
|
848
925
|
if (i != 0) {
|
|
849
|
-
|
|
850
|
-
previousTier = tiersToAdd[i - 1];
|
|
926
|
+
uint256 previousCategory = tiersToAdd[i - 1].category;
|
|
851
927
|
|
|
852
928
|
// Revert if the category is not equal or greater than the previously added tier's category.
|
|
853
|
-
if (tierToAdd.category <
|
|
854
|
-
revert JB721TiersHookStore_InvalidCategorySortOrder(tierToAdd.category,
|
|
929
|
+
if (tierToAdd.category < previousCategory) {
|
|
930
|
+
revert JB721TiersHookStore_InvalidCategorySortOrder(tierToAdd.category, previousCategory);
|
|
855
931
|
}
|
|
856
932
|
}
|
|
857
933
|
|
|
@@ -928,7 +1004,9 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
928
1004
|
// If this is the first tier in a new category, store it as the first tier in that category.
|
|
929
1005
|
// The `_startingTierIdOfCategory` of the category "0" will always be the same as the `_tierIdAfter` the 0th
|
|
930
1006
|
// tier.
|
|
931
|
-
|
|
1007
|
+
// Access the previous tier's category directly from calldata (0 when i == 0, matching the old
|
|
1008
|
+
// uninitialized-memory behavior).
|
|
1009
|
+
if ((i == 0 ? 0 : tiersToAdd[i - 1].category) != tierToAdd.category && tierToAdd.category != 0) {
|
|
932
1010
|
_startingTierIdOfCategory[msg.sender][tierToAdd.category] = tierId;
|
|
933
1011
|
}
|
|
934
1012
|
|
|
@@ -1028,6 +1106,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1028
1106
|
|
|
1029
1107
|
// Add the tier ID to the array being returned.
|
|
1030
1108
|
tierIds[i] = tierId;
|
|
1109
|
+
|
|
1110
|
+
unchecked {
|
|
1111
|
+
++i;
|
|
1112
|
+
}
|
|
1031
1113
|
}
|
|
1032
1114
|
|
|
1033
1115
|
// Update the maximum tier ID to include the new tiers.
|
|
@@ -1041,7 +1123,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1041
1123
|
/// @param tokenIds The token IDs of the NFTs to burn.
|
|
1042
1124
|
function recordBurn(uint256[] calldata tokenIds) external override {
|
|
1043
1125
|
// Iterate through all token IDs to increment the burn count.
|
|
1044
|
-
for (uint256 i; i < tokenIds.length;
|
|
1126
|
+
for (uint256 i; i < tokenIds.length;) {
|
|
1045
1127
|
// Set the 721's token ID.
|
|
1046
1128
|
uint256 tokenId = tokenIds[i];
|
|
1047
1129
|
|
|
@@ -1049,6 +1131,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1049
1131
|
|
|
1050
1132
|
// Increment the number of NFTs burned from the tier.
|
|
1051
1133
|
numberOfBurnedFor[msg.sender][tierId]++;
|
|
1134
|
+
|
|
1135
|
+
unchecked {
|
|
1136
|
+
++i;
|
|
1137
|
+
}
|
|
1052
1138
|
}
|
|
1053
1139
|
}
|
|
1054
1140
|
|
|
@@ -1090,7 +1176,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1090
1176
|
// Initialize a `JBBitmapWord` for checking whether tiers have been removed.
|
|
1091
1177
|
JBBitmapWord memory bitmapWord;
|
|
1092
1178
|
|
|
1093
|
-
for (uint256 i; i < numberOfTiers;
|
|
1179
|
+
for (uint256 i; i < numberOfTiers;) {
|
|
1094
1180
|
// Set the tier ID being iterated on.
|
|
1095
1181
|
uint256 tierId = tierIds[i];
|
|
1096
1182
|
|
|
@@ -1150,6 +1236,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1150
1236
|
) {
|
|
1151
1237
|
revert JB721TiersHookStore_InsufficientSupplyRemaining(tierId);
|
|
1152
1238
|
}
|
|
1239
|
+
|
|
1240
|
+
unchecked {
|
|
1241
|
+
++i;
|
|
1242
|
+
}
|
|
1153
1243
|
}
|
|
1154
1244
|
}
|
|
1155
1245
|
|
|
@@ -1181,11 +1271,15 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1181
1271
|
// Initialize an array for the token IDs to be returned.
|
|
1182
1272
|
tokenIds = new uint256[](count);
|
|
1183
1273
|
|
|
1184
|
-
for (uint256 i; i < count;
|
|
1274
|
+
for (uint256 i; i < count;) {
|
|
1185
1275
|
// Generate the NFTs.
|
|
1186
1276
|
tokenIds[i] = _generateTokenId({
|
|
1187
1277
|
tierId: tierId, tokenNumber: storedTier.initialSupply - --storedTier.remainingSupply
|
|
1188
1278
|
});
|
|
1279
|
+
|
|
1280
|
+
unchecked {
|
|
1281
|
+
++i;
|
|
1282
|
+
}
|
|
1189
1283
|
}
|
|
1190
1284
|
}
|
|
1191
1285
|
|
|
@@ -1194,7 +1288,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1194
1288
|
/// Call `cleanTiers()` after removing tiers to update the sorting sequence and prevent stale tier iteration.
|
|
1195
1289
|
/// @param tierIds The IDs of the tiers being removed.
|
|
1196
1290
|
function recordRemoveTierIds(uint256[] calldata tierIds) external override {
|
|
1197
|
-
for (uint256 i; i < tierIds.length;
|
|
1291
|
+
for (uint256 i; i < tierIds.length;) {
|
|
1198
1292
|
// Set the tier being iterated upon (0-indexed).
|
|
1199
1293
|
uint256 tierId = tierIds[i];
|
|
1200
1294
|
|
|
@@ -1209,6 +1303,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1209
1303
|
|
|
1210
1304
|
// Remove the tier by marking it as removed in the bitmap.
|
|
1211
1305
|
_removedTiersBitmapWordOf[msg.sender].removeTier(tierId);
|
|
1306
|
+
|
|
1307
|
+
unchecked {
|
|
1308
|
+
++i;
|
|
1309
|
+
}
|
|
1212
1310
|
}
|
|
1213
1311
|
}
|
|
1214
1312
|
|
|
@@ -209,7 +209,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
|
|
|
209
209
|
if (metadataExists) decodedTokenIds = abi.decode(metadata, (uint256[]));
|
|
210
210
|
|
|
211
211
|
// Iterate through the NFTs, burning them if the owner is correct.
|
|
212
|
-
for (uint256 i; i < decodedTokenIds.length;
|
|
212
|
+
for (uint256 i; i < decodedTokenIds.length;) {
|
|
213
213
|
// Set the current NFT's token ID.
|
|
214
214
|
uint256 tokenId = decodedTokenIds[i];
|
|
215
215
|
|
|
@@ -218,6 +218,10 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
|
|
|
218
218
|
|
|
219
219
|
// Burn the token.
|
|
220
220
|
_burn(tokenId);
|
|
221
|
+
|
|
222
|
+
unchecked {
|
|
223
|
+
++i;
|
|
224
|
+
}
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
// Call the hook.
|
|
@@ -124,6 +124,33 @@ interface IJB721TiersHookStore {
|
|
|
124
124
|
view
|
|
125
125
|
returns (JB721Tier memory tier);
|
|
126
126
|
|
|
127
|
+
/// @notice Get only the pricing fields for a tier, avoiding full struct construction.
|
|
128
|
+
/// @param hook The 721 contract that the tier belongs to.
|
|
129
|
+
/// @param id The tier ID.
|
|
130
|
+
/// @return price The tier price.
|
|
131
|
+
/// @return splitPercent The split percent.
|
|
132
|
+
/// @return discountPercent The discount percent.
|
|
133
|
+
function tierPricingOf(
|
|
134
|
+
address hook,
|
|
135
|
+
uint256 id
|
|
136
|
+
)
|
|
137
|
+
external
|
|
138
|
+
view
|
|
139
|
+
returns (uint104 price, uint32 splitPercent, uint8 discountPercent);
|
|
140
|
+
|
|
141
|
+
/// @notice Get only the tier ID and transfersPausable flag for a token, avoiding full struct construction.
|
|
142
|
+
/// @param hook The 721 hook address.
|
|
143
|
+
/// @param tokenId The token ID.
|
|
144
|
+
/// @return tierId The tier ID.
|
|
145
|
+
/// @return transfersPausable Whether transfers are paused for this tier.
|
|
146
|
+
function tierTransferInfoOfTokenId(
|
|
147
|
+
address hook,
|
|
148
|
+
uint256 tokenId
|
|
149
|
+
)
|
|
150
|
+
external
|
|
151
|
+
view
|
|
152
|
+
returns (uint256 tierId, bool transfersPausable);
|
|
153
|
+
|
|
127
154
|
/// @notice Get an array of currently active 721 tiers for the provided 721 contract.
|
|
128
155
|
/// @param hook The 721 contract to get the tiers of.
|
|
129
156
|
/// @param categories An array of tier categories to get tiers from. Empty for all categories.
|
|
@@ -18,7 +18,6 @@ import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
|
|
|
18
18
|
|
|
19
19
|
import {IJB721TiersHookStore} from "../interfaces/IJB721TiersHookStore.sol";
|
|
20
20
|
import {IJB721TokenUriResolver} from "../interfaces/IJB721TokenUriResolver.sol";
|
|
21
|
-
import {JB721Tier} from "../structs/JB721Tier.sol";
|
|
22
21
|
import {JB721TierConfig} from "../structs/JB721TierConfig.sol";
|
|
23
22
|
import {JB721Constants} from "./JB721Constants.sol";
|
|
24
23
|
import {JBIpfsDecoder} from "./JBIpfsDecoder.sol";
|
|
@@ -27,8 +26,8 @@ import {JBIpfsDecoder} from "./JBIpfsDecoder.sol";
|
|
|
27
26
|
/// @dev Handles tier adjustments, split calculations, price normalization, and split fund distribution.
|
|
28
27
|
library JB721TiersHookLib {
|
|
29
28
|
error JB721TiersHookLib_NoTerminalForLeftover(uint256 projectId, address token, uint256 leftoverAmount);
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
error JB721TiersHookLib_SplitFallbackFailed(uint256 projectId, address token, uint256 amount, bytes reason);
|
|
30
|
+
error JB721TiersHookLib_TokenTransferAmountMismatch(uint256 expectedAmount, uint256 receivedAmount);
|
|
32
31
|
event AddTier(uint256 indexed tierId, JB721TierConfig tier, address caller);
|
|
33
32
|
event RemoveTier(uint256 indexed tierId, address caller);
|
|
34
33
|
event SplitPayoutReverted(uint256 indexed projectId, JBSplit split, uint256 amount, bytes reason, address caller);
|
|
@@ -55,8 +54,12 @@ library JB721TiersHookLib {
|
|
|
55
54
|
{
|
|
56
55
|
// Remove tiers.
|
|
57
56
|
if (tierIdsToRemove.length != 0) {
|
|
58
|
-
for (uint256 i; i < tierIdsToRemove.length;
|
|
57
|
+
for (uint256 i; i < tierIdsToRemove.length;) {
|
|
59
58
|
emit RemoveTier({tierId: tierIdsToRemove[i], caller: caller});
|
|
59
|
+
|
|
60
|
+
unchecked {
|
|
61
|
+
++i;
|
|
62
|
+
}
|
|
60
63
|
}
|
|
61
64
|
// slither-disable-next-line reentrancy-events
|
|
62
65
|
store.recordRemoveTierIds(tierIdsToRemove);
|
|
@@ -67,8 +70,12 @@ library JB721TiersHookLib {
|
|
|
67
70
|
uint256[] memory tierIdsAdded = store.recordAddTiers(tiersToAdd);
|
|
68
71
|
|
|
69
72
|
// slither-disable-next-line reentrancy-events
|
|
70
|
-
for (uint256 i; i < tiersToAdd.length;
|
|
73
|
+
for (uint256 i; i < tiersToAdd.length;) {
|
|
71
74
|
emit AddTier({tierId: tierIdsAdded[i], tier: tiersToAdd[i], caller: caller});
|
|
75
|
+
|
|
76
|
+
unchecked {
|
|
77
|
+
++i;
|
|
78
|
+
}
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
// Set split groups for tiers that have splits configured.
|
|
@@ -102,9 +109,13 @@ library JB721TiersHookLib {
|
|
|
102
109
|
{
|
|
103
110
|
uint256[] memory tierIdsAdded = store.recordAddTiers(tiersToAdd);
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
for (uint256 i; i < tiersToAdd.length;) {
|
|
113
|
+
// slither-disable-next-line reentrancy-events
|
|
107
114
|
emit AddTier({tierId: tierIdsAdded[i], tier: tiersToAdd[i], caller: caller});
|
|
115
|
+
|
|
116
|
+
unchecked {
|
|
117
|
+
++i;
|
|
118
|
+
}
|
|
108
119
|
}
|
|
109
120
|
|
|
110
121
|
// Set split groups for tiers that have splits configured.
|
|
@@ -140,12 +151,18 @@ library JB721TiersHookLib {
|
|
|
140
151
|
{
|
|
141
152
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
142
153
|
uint256 pricingCurrency = uint256(uint32(packedPricingContext));
|
|
143
|
-
|
|
154
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
155
|
+
uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
|
|
156
|
+
if (amountCurrency == pricingCurrency) {
|
|
157
|
+
if (amountDecimals == pricingDecimals) return (amountValue, true);
|
|
158
|
+
if (amountDecimals > pricingDecimals) {
|
|
159
|
+
return (amountValue / (10 ** (amountDecimals - pricingDecimals)), true);
|
|
160
|
+
}
|
|
161
|
+
return (amountValue * (10 ** (pricingDecimals - amountDecimals)), true);
|
|
162
|
+
}
|
|
144
163
|
|
|
145
164
|
if (address(prices) == address(0)) return (0, false);
|
|
146
165
|
|
|
147
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
148
|
-
uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
|
|
149
166
|
value = mulDiv({
|
|
150
167
|
x: amountValue,
|
|
151
168
|
y: 10 ** pricingDecimals,
|
|
@@ -176,22 +193,27 @@ library JB721TiersHookLib {
|
|
|
176
193
|
view
|
|
177
194
|
returns (uint256 totalSplitAmount, bytes memory hookMetadata)
|
|
178
195
|
{
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
196
|
+
// Decode tier IDs from metadata within a scope to free stack slots for the loop below.
|
|
197
|
+
uint16[] memory tierIdsToMint;
|
|
198
|
+
{
|
|
199
|
+
(bool found, bytes memory data) = JBMetadataResolver.getDataFor({
|
|
200
|
+
id: JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget}), metadata: metadata
|
|
201
|
+
});
|
|
202
|
+
if (!found) return (0, bytes(""));
|
|
203
|
+
(, tierIdsToMint) = abi.decode(data, (bool, uint16[]));
|
|
204
|
+
}
|
|
185
205
|
if (tierIdsToMint.length == 0) return (0, bytes(""));
|
|
186
206
|
|
|
187
207
|
uint16[] memory splitTierIds = new uint16[](tierIdsToMint.length);
|
|
188
208
|
uint256[] memory splitAmounts = new uint256[](tierIdsToMint.length);
|
|
189
209
|
uint256 splitTierCount;
|
|
190
210
|
|
|
191
|
-
for (uint256 i; i < tierIdsToMint.length;
|
|
211
|
+
for (uint256 i; i < tierIdsToMint.length;) {
|
|
212
|
+
// Get only the pricing fields (lightweight — avoids full struct construction).
|
|
192
213
|
// slither-disable-next-line calls-loop
|
|
193
|
-
|
|
194
|
-
|
|
214
|
+
(uint104 tierPrice, uint32 tierSplitPercent, uint8 tierDiscountPercent) =
|
|
215
|
+
store.tierPricingOf({hook: hook, id: tierIdsToMint[i]});
|
|
216
|
+
if (tierSplitPercent != 0) {
|
|
195
217
|
// Apply discount to tier price to match the discounted price that recordMint charges.
|
|
196
218
|
// Note on discount semantics: `discountPercent` uses a denominator of 200 (JB721Constants
|
|
197
219
|
// .DISCOUNT_DENOMINATOR), so a value of 200 represents a 100% discount (free mint). Even with a
|
|
@@ -199,18 +221,22 @@ library JB721TiersHookLib {
|
|
|
199
221
|
// in `cashOutWeightOf`. This means free/discounted mints still carry their full cashout weight
|
|
200
222
|
// value. Project owners should be aware that discounted mints dilute the cashout pool at full
|
|
201
223
|
// weight while contributing less (or no) payment to the treasury.
|
|
202
|
-
uint256 effectivePrice =
|
|
203
|
-
if (
|
|
224
|
+
uint256 effectivePrice = tierPrice;
|
|
225
|
+
if (tierDiscountPercent > 0) {
|
|
204
226
|
effectivePrice -= mulDiv({
|
|
205
|
-
x: effectivePrice, y:
|
|
227
|
+
x: effectivePrice, y: tierDiscountPercent, denominator: JB721Constants.DISCOUNT_DENOMINATOR
|
|
206
228
|
});
|
|
207
229
|
}
|
|
208
230
|
splitTierIds[splitTierCount] = tierIdsToMint[i];
|
|
209
231
|
splitAmounts[splitTierCount] =
|
|
210
|
-
mulDiv({x: effectivePrice, y:
|
|
232
|
+
mulDiv({x: effectivePrice, y: tierSplitPercent, denominator: JBConstants.SPLITS_TOTAL_PERCENT});
|
|
211
233
|
totalSplitAmount += splitAmounts[splitTierCount];
|
|
212
234
|
splitTierCount++;
|
|
213
235
|
}
|
|
236
|
+
|
|
237
|
+
unchecked {
|
|
238
|
+
++i;
|
|
239
|
+
}
|
|
214
240
|
}
|
|
215
241
|
|
|
216
242
|
if (splitTierCount != 0) {
|
|
@@ -312,10 +338,14 @@ library JB721TiersHookLib {
|
|
|
312
338
|
|
|
313
339
|
// Re-accumulate the total from converted amounts to avoid rounding drift.
|
|
314
340
|
convertedTotal = 0;
|
|
315
|
-
for (uint256 i; i < amounts.length;
|
|
341
|
+
for (uint256 i; i < amounts.length;) {
|
|
316
342
|
// Convert this tier's amount: amount * ratio / 10^pricingDecimals.
|
|
317
343
|
amounts[i] = mulDiv({x: amounts[i], y: ratio, denominator: denom});
|
|
318
344
|
convertedTotal += amounts[i];
|
|
345
|
+
|
|
346
|
+
unchecked {
|
|
347
|
+
++i;
|
|
348
|
+
}
|
|
319
349
|
}
|
|
320
350
|
|
|
321
351
|
// Re-encode with the converted amounts.
|
|
@@ -331,15 +361,22 @@ library JB721TiersHookLib {
|
|
|
331
361
|
if (convertedMetadata.length != 0) {
|
|
332
362
|
(uint16[] memory tierIds, uint256[] memory amounts) =
|
|
333
363
|
abi.decode(convertedMetadata, (uint16[], uint256[]));
|
|
334
|
-
|
|
364
|
+
uint256 uncappedTotal = convertedTotal;
|
|
365
|
+
convertedTotal = 0;
|
|
366
|
+
for (uint256 i; i < amounts.length;) {
|
|
335
367
|
// Scale down: amount * amountValue / originalTotal.
|
|
336
|
-
amounts[i] = mulDiv({x: amounts[i], y: amountValue, denominator:
|
|
368
|
+
amounts[i] = mulDiv({x: amounts[i], y: amountValue, denominator: uncappedTotal});
|
|
369
|
+
convertedTotal += amounts[i];
|
|
370
|
+
|
|
371
|
+
unchecked {
|
|
372
|
+
++i;
|
|
373
|
+
}
|
|
337
374
|
}
|
|
338
375
|
convertedMetadata = abi.encode(tierIds, amounts);
|
|
376
|
+
} else {
|
|
377
|
+
// Clamp the total to the payment value.
|
|
378
|
+
convertedTotal = amountValue;
|
|
339
379
|
}
|
|
340
|
-
|
|
341
|
-
// Clamp the total to the payment value.
|
|
342
|
-
convertedTotal = amountValue;
|
|
343
380
|
}
|
|
344
381
|
}
|
|
345
382
|
|
|
@@ -354,20 +391,28 @@ library JB721TiersHookLib {
|
|
|
354
391
|
private
|
|
355
392
|
{
|
|
356
393
|
uint256 splitGroupCount;
|
|
357
|
-
for (uint256 i; i < tiersToAdd.length;
|
|
394
|
+
for (uint256 i; i < tiersToAdd.length;) {
|
|
358
395
|
if (tiersToAdd[i].splits.length != 0) splitGroupCount++;
|
|
396
|
+
|
|
397
|
+
unchecked {
|
|
398
|
+
++i;
|
|
399
|
+
}
|
|
359
400
|
}
|
|
360
401
|
if (splitGroupCount == 0) return;
|
|
361
402
|
|
|
362
403
|
JBSplitGroup[] memory splitGroups = new JBSplitGroup[](splitGroupCount);
|
|
363
404
|
uint256 groupIndex;
|
|
364
|
-
for (uint256 i; i < tiersToAdd.length;
|
|
405
|
+
for (uint256 i; i < tiersToAdd.length;) {
|
|
365
406
|
if (tiersToAdd[i].splits.length != 0) {
|
|
366
407
|
splitGroups[groupIndex] = JBSplitGroup({
|
|
367
408
|
groupId: uint256(uint160(hookAddress)) | (tierIdsAdded[i] << 160), splits: tiersToAdd[i].splits
|
|
368
409
|
});
|
|
369
410
|
groupIndex++;
|
|
370
411
|
}
|
|
412
|
+
|
|
413
|
+
unchecked {
|
|
414
|
+
++i;
|
|
415
|
+
}
|
|
371
416
|
}
|
|
372
417
|
splits.setSplitGroupsOf({projectId: projectId, rulesetId: 0, splitGroups: splitGroups});
|
|
373
418
|
}
|
|
@@ -395,13 +440,23 @@ library JB721TiersHookLib {
|
|
|
395
440
|
{
|
|
396
441
|
// For ERC20 tokens, pull from terminal using the allowance it granted via _beforeTransferTo.
|
|
397
442
|
if (token != JBConstants.NATIVE_TOKEN) {
|
|
443
|
+
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
|
|
398
444
|
SafeERC20.safeTransferFrom({token: IERC20(token), from: msg.sender, to: address(this), value: amount});
|
|
445
|
+
uint256 receivedAmount = IERC20(token).balanceOf(address(this)) - balanceBefore;
|
|
446
|
+
if (receivedAmount != amount) {
|
|
447
|
+
revert JB721TiersHookLib_TokenTransferAmountMismatch(amount, receivedAmount);
|
|
448
|
+
}
|
|
399
449
|
}
|
|
400
450
|
|
|
401
451
|
(uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(encodedSplitData, (uint16[], uint256[]));
|
|
402
452
|
|
|
403
|
-
for (uint256 i; i < tierIds.length;
|
|
404
|
-
if (amounts[i] == 0)
|
|
453
|
+
for (uint256 i; i < tierIds.length;) {
|
|
454
|
+
if (amounts[i] == 0) {
|
|
455
|
+
unchecked {
|
|
456
|
+
++i;
|
|
457
|
+
}
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
405
460
|
uint256 groupId = uint256(uint160(hookAddress)) | (uint256(tierIds[i]) << 160);
|
|
406
461
|
_distributeSingleSplit({
|
|
407
462
|
directory: directory,
|
|
@@ -412,6 +467,10 @@ library JB721TiersHookLib {
|
|
|
412
467
|
amount: amounts[i],
|
|
413
468
|
decimals: decimals
|
|
414
469
|
});
|
|
470
|
+
|
|
471
|
+
unchecked {
|
|
472
|
+
++i;
|
|
473
|
+
}
|
|
415
474
|
}
|
|
416
475
|
}
|
|
417
476
|
|
|
@@ -441,7 +500,7 @@ library JB721TiersHookLib {
|
|
|
441
500
|
uint256 leftoverAmount = amount;
|
|
442
501
|
amount = 0;
|
|
443
502
|
|
|
444
|
-
for (uint256 j; j < tierSplits.length;
|
|
503
|
+
for (uint256 j; j < tierSplits.length;) {
|
|
445
504
|
uint256 payoutAmount =
|
|
446
505
|
mulDiv({x: leftoverAmount, y: tierSplits[j].percent, denominator: leftoverPercentage});
|
|
447
506
|
if (payoutAmount != 0) {
|
|
@@ -451,6 +510,7 @@ library JB721TiersHookLib {
|
|
|
451
510
|
// On failure, don't re-add to leftoverAmount — this prevents inflating later recipients.
|
|
452
511
|
// Failed amounts accumulate as the gap between `amount` and `leftoverAmount + total sent`.
|
|
453
512
|
// After the loop, we re-add leftoverPercentage-based residual naturally.
|
|
513
|
+
// slither-disable-next-line calls-loop,reentrancy-no-eth,reentrancy-benign,reentrancy-events
|
|
454
514
|
if (!_sendPayoutToSplit({
|
|
455
515
|
directory: directory,
|
|
456
516
|
split: tierSplits[j],
|
|
@@ -469,6 +529,7 @@ library JB721TiersHookLib {
|
|
|
469
529
|
}
|
|
470
530
|
unchecked {
|
|
471
531
|
leftoverPercentage -= tierSplits[j].percent;
|
|
532
|
+
++j;
|
|
472
533
|
}
|
|
473
534
|
}
|
|
474
535
|
|
|
@@ -493,7 +554,7 @@ library JB721TiersHookLib {
|
|
|
493
554
|
metadata: bytes("")
|
|
494
555
|
}) {}
|
|
495
556
|
catch (bytes memory reason) {
|
|
496
|
-
|
|
557
|
+
revert JB721TiersHookLib_SplitFallbackFailed(projectId, token, leftoverAmount, reason);
|
|
497
558
|
}
|
|
498
559
|
} else {
|
|
499
560
|
SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: leftoverAmount});
|
|
@@ -509,7 +570,7 @@ library JB721TiersHookLib {
|
|
|
509
570
|
catch (bytes memory reason) {
|
|
510
571
|
// Reset approval on failure.
|
|
511
572
|
SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
|
|
512
|
-
|
|
573
|
+
revert JB721TiersHookLib_SplitFallbackFailed(projectId, token, leftoverAmount, reason);
|
|
513
574
|
}
|
|
514
575
|
}
|
|
515
576
|
}
|
|
@@ -541,9 +602,11 @@ library JB721TiersHookLib {
|
|
|
541
602
|
if (isNativeToken) {
|
|
542
603
|
// Wrap in try-catch so a reverting hook doesn't brick all project payments.
|
|
543
604
|
// On revert, ETH stays with the caller and we return false.
|
|
605
|
+
// slither-disable-next-line calls-loop,reentrancy-no-eth,reentrancy-events
|
|
544
606
|
try split.hook.processSplitWith{value: amount}(context) {
|
|
545
607
|
return true;
|
|
546
608
|
} catch (bytes memory reason) {
|
|
609
|
+
// slither-disable-next-line reentrancy-events
|
|
547
610
|
emit SplitPayoutReverted({
|
|
548
611
|
projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
|
|
549
612
|
});
|
|
@@ -556,8 +619,10 @@ library JB721TiersHookLib {
|
|
|
556
619
|
// cause the caller to skip subtracting this amount from leftoverAmount, leading
|
|
557
620
|
// to a double-spend when the leftover is later sent to the project's balance.
|
|
558
621
|
SafeERC20.safeTransfer({token: IERC20(token), to: address(split.hook), value: amount});
|
|
622
|
+
// slither-disable-next-line calls-loop,reentrancy-no-eth,reentrancy-events
|
|
559
623
|
try split.hook.processSplitWith(context) {}
|
|
560
624
|
catch (bytes memory reason) {
|
|
625
|
+
// slither-disable-next-line reentrancy-events
|
|
561
626
|
emit SplitPayoutReverted({
|
|
562
627
|
projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
|
|
563
628
|
});
|
|
@@ -572,7 +637,7 @@ library JB721TiersHookLib {
|
|
|
572
637
|
// Wrap terminal calls in try-catch to prevent a failing terminal from bricking payments.
|
|
573
638
|
if (split.preferAddToBalance) {
|
|
574
639
|
if (isNativeToken) {
|
|
575
|
-
// slither-disable-next-line arbitrary-send-eth,calls-loop
|
|
640
|
+
// slither-disable-next-line arbitrary-send-eth,calls-loop,reentrancy-no-eth,reentrancy-events
|
|
576
641
|
try terminal.addToBalanceOf{value: amount}({
|
|
577
642
|
projectId: split.projectId,
|
|
578
643
|
token: token,
|
|
@@ -583,6 +648,7 @@ library JB721TiersHookLib {
|
|
|
583
648
|
}) {
|
|
584
649
|
return true;
|
|
585
650
|
} catch (bytes memory reason) {
|
|
651
|
+
// slither-disable-next-line reentrancy-events
|
|
586
652
|
emit SplitPayoutReverted({
|
|
587
653
|
projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
|
|
588
654
|
});
|
|
@@ -590,7 +656,7 @@ library JB721TiersHookLib {
|
|
|
590
656
|
}
|
|
591
657
|
} else {
|
|
592
658
|
SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: amount});
|
|
593
|
-
// slither-disable-next-line calls-loop
|
|
659
|
+
// slither-disable-next-line calls-loop,reentrancy-no-eth,reentrancy-events
|
|
594
660
|
try terminal.addToBalanceOf({
|
|
595
661
|
projectId: split.projectId,
|
|
596
662
|
token: token,
|
|
@@ -603,6 +669,7 @@ library JB721TiersHookLib {
|
|
|
603
669
|
} catch (bytes memory reason) {
|
|
604
670
|
// Reset approval on failure so tokens aren't left approved to the terminal.
|
|
605
671
|
SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
|
|
672
|
+
// slither-disable-next-line reentrancy-events
|
|
606
673
|
emit SplitPayoutReverted({
|
|
607
674
|
projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
|
|
608
675
|
});
|
|
@@ -611,7 +678,7 @@ library JB721TiersHookLib {
|
|
|
611
678
|
}
|
|
612
679
|
} else {
|
|
613
680
|
if (isNativeToken) {
|
|
614
|
-
// slither-disable-next-line arbitrary-send-eth,unused-return,calls-loop
|
|
681
|
+
// slither-disable-next-line arbitrary-send-eth,unused-return,calls-loop,reentrancy-events
|
|
615
682
|
try terminal.pay{value: amount}({
|
|
616
683
|
projectId: split.projectId,
|
|
617
684
|
token: token,
|
|
@@ -623,6 +690,7 @@ library JB721TiersHookLib {
|
|
|
623
690
|
}) {
|
|
624
691
|
return true;
|
|
625
692
|
} catch (bytes memory reason) {
|
|
693
|
+
// slither-disable-next-line reentrancy-events
|
|
626
694
|
emit SplitPayoutReverted({
|
|
627
695
|
projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
|
|
628
696
|
});
|
|
@@ -630,7 +698,7 @@ library JB721TiersHookLib {
|
|
|
630
698
|
}
|
|
631
699
|
} else {
|
|
632
700
|
SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: amount});
|
|
633
|
-
// slither-disable-next-line unused-return,calls-loop
|
|
701
|
+
// slither-disable-next-line unused-return,calls-loop,reentrancy-no-eth,reentrancy-events
|
|
634
702
|
try terminal.pay({
|
|
635
703
|
projectId: split.projectId,
|
|
636
704
|
token: token,
|
|
@@ -644,6 +712,7 @@ library JB721TiersHookLib {
|
|
|
644
712
|
} catch (bytes memory reason) {
|
|
645
713
|
// Reset approval on failure so tokens aren't left approved to the terminal.
|
|
646
714
|
SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
|
|
715
|
+
// slither-disable-next-line reentrancy-events
|
|
647
716
|
emit SplitPayoutReverted({
|
|
648
717
|
projectId: projectId, split: split, amount: amount, reason: reason, caller: msg.sender
|
|
649
718
|
});
|
|
@@ -661,6 +730,7 @@ library JB721TiersHookLib {
|
|
|
661
730
|
// false on failure instead of reverting. This handles non-standard tokens (e.g. USDT)
|
|
662
731
|
// that return void, while routing failed transfers to the project's balance instead
|
|
663
732
|
// of bricking all payments.
|
|
733
|
+
// slither-disable-next-line calls-loop
|
|
664
734
|
(bool callSuccess, bytes memory returndata) =
|
|
665
735
|
address(token).call(abi.encodeCall(IERC20.transfer, (split.beneficiary, amount)));
|
|
666
736
|
if (!callSuccess || (returndata.length != 0 && !abi.decode(returndata, (bool)))) return false;
|
|
@@ -41,7 +41,7 @@ library JBIpfsDecoder {
|
|
|
41
41
|
uint8 digitlength = 1;
|
|
42
42
|
uint256 sourceLength = source.length;
|
|
43
43
|
|
|
44
|
-
for (uint256 i; i < sourceLength;
|
|
44
|
+
for (uint256 i; i < sourceLength;) {
|
|
45
45
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
46
46
|
uint256 carry = uint8(source[i]);
|
|
47
47
|
|
|
@@ -64,14 +64,22 @@ library JBIpfsDecoder {
|
|
|
64
64
|
}
|
|
65
65
|
carry = carry / 58;
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
unchecked {
|
|
69
|
+
++i;
|
|
70
|
+
}
|
|
67
71
|
}
|
|
68
72
|
return string(_toAlphabet(_reverse(_truncate({array: digits, length: digitlength}))));
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
function _truncate(uint8[] memory array, uint8 length) private pure returns (uint8[] memory) {
|
|
72
76
|
uint8[] memory output = new uint8[](length);
|
|
73
|
-
for (uint256 i; i < length;
|
|
77
|
+
for (uint256 i; i < length;) {
|
|
74
78
|
output[i] = array[i];
|
|
79
|
+
|
|
80
|
+
unchecked {
|
|
81
|
+
++i;
|
|
82
|
+
}
|
|
75
83
|
}
|
|
76
84
|
return output;
|
|
77
85
|
}
|
|
@@ -79,9 +87,10 @@ library JBIpfsDecoder {
|
|
|
79
87
|
function _reverse(uint8[] memory input) private pure returns (uint8[] memory) {
|
|
80
88
|
uint256 inputLength = input.length;
|
|
81
89
|
uint8[] memory output = new uint8[](inputLength);
|
|
82
|
-
for (uint256 i; i < inputLength;
|
|
90
|
+
for (uint256 i; i < inputLength;) {
|
|
83
91
|
unchecked {
|
|
84
92
|
output[i] = input[input.length - 1 - i];
|
|
93
|
+
++i;
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
return output;
|
|
@@ -90,8 +99,12 @@ library JBIpfsDecoder {
|
|
|
90
99
|
function _toAlphabet(uint8[] memory indices) private pure returns (bytes memory) {
|
|
91
100
|
uint256 indicesLength = indices.length;
|
|
92
101
|
bytes memory output = new bytes(indicesLength);
|
|
93
|
-
for (uint256 i; i < indicesLength;
|
|
102
|
+
for (uint256 i; i < indicesLength;) {
|
|
94
103
|
output[i] = ALPHABET[indices[i]];
|
|
104
|
+
|
|
105
|
+
unchecked {
|
|
106
|
+
++i;
|
|
107
|
+
}
|
|
95
108
|
}
|
|
96
109
|
return output;
|
|
97
110
|
}
|
|
@@ -11,10 +11,9 @@ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
|
11
11
|
import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
|
|
12
12
|
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
13
13
|
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
14
|
-
import {IJB721TiersHook} from "../../src/interfaces/IJB721TiersHook.sol";
|
|
15
14
|
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
16
15
|
|
|
17
|
-
/// @notice Regression tests: a broken project terminal in _addToBalance
|
|
16
|
+
/// @notice Regression tests: a broken project terminal in _addToBalance now reverts the payment (M-4 fix).
|
|
18
17
|
contract Test_BrokenTerminalDoesNotDos is UnitTestSetup {
|
|
19
18
|
using stdStorage for StdStorage;
|
|
20
19
|
|
|
@@ -76,15 +75,13 @@ contract Test_BrokenTerminalDoesNotDos is UnitTestSetup {
|
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
// ──────────────────────────────────────────────────────────────────────
|
|
79
|
-
// ETH: broken own-project terminal in _addToBalance should
|
|
78
|
+
// ETH: broken own-project terminal in _addToBalance should revert (M-4)
|
|
80
79
|
// ──────────────────────────────────────────────────────────────────────
|
|
81
80
|
|
|
82
81
|
/// @notice When a split has no valid recipient (projectId==0, beneficiary==address(0)),
|
|
83
82
|
/// funds route to the project's own terminal via _addToBalance. If that terminal reverts,
|
|
84
|
-
/// the
|
|
85
|
-
|
|
86
|
-
/// the hook contract).
|
|
87
|
-
function test_brokenOwnTerminal_eth_doesNotDosPayments() public {
|
|
83
|
+
/// the payment now reverts with JB721TiersHookLib_SplitFallbackFailed (M-4 fix).
|
|
84
|
+
function test_brokenOwnTerminal_eth_reverts() public {
|
|
88
85
|
ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
|
|
89
86
|
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
90
87
|
|
|
@@ -172,26 +169,19 @@ contract Test_BrokenTerminalDoesNotDos is UnitTestSetup {
|
|
|
172
169
|
|
|
173
170
|
vm.deal(mockTerminalAddress, 2 ether);
|
|
174
171
|
|
|
175
|
-
// Expect AddToBalanceReverted event when the broken terminal reverts.
|
|
176
|
-
vm.expectEmit(true, false, false, false);
|
|
177
|
-
emit IJB721TiersHook.AddToBalanceReverted(projectId, JBConstants.NATIVE_TOKEN, 1 ether, "");
|
|
178
|
-
|
|
179
172
|
vm.prank(mockTerminalAddress);
|
|
180
|
-
//
|
|
181
|
-
|
|
173
|
+
// The payment should now revert when the fallback addToBalanceOf call fails (M-4).
|
|
174
|
+
vm.expectRevert();
|
|
182
175
|
testHook.afterPayRecordedWith{value: 1 ether}(payContext);
|
|
183
|
-
|
|
184
|
-
// The ETH should remain in the hook contract since the terminal call failed.
|
|
185
|
-
assertGe(address(testHook).balance, 1 ether, "ETH should stay in hook when terminal reverts");
|
|
186
176
|
}
|
|
187
177
|
|
|
188
178
|
// ──────────────────────────────────────────────────────────────────────
|
|
189
|
-
// ERC-20: broken own-project terminal in _addToBalance should
|
|
179
|
+
// ERC-20: broken own-project terminal in _addToBalance should revert (M-4)
|
|
190
180
|
// ──────────────────────────────────────────────────────────────────────
|
|
191
181
|
|
|
192
182
|
/// @notice Same scenario as above but with ERC-20 tokens. On terminal failure, the
|
|
193
|
-
/// approval
|
|
194
|
-
function
|
|
183
|
+
/// approval is reset to 0 for safety and the payment now reverts (M-4 fix).
|
|
184
|
+
function test_brokenOwnTerminal_erc20_reverts() public {
|
|
195
185
|
BrokenTerminalERC20 usdc = new BrokenTerminalERC20("USD Coin", "USDC", 6);
|
|
196
186
|
uint32 usdcCurrency = uint32(uint160(address(usdc)));
|
|
197
187
|
|
|
@@ -217,24 +207,10 @@ contract Test_BrokenTerminalDoesNotDos is UnitTestSetup {
|
|
|
217
207
|
vm.prank(mockTerminalAddress);
|
|
218
208
|
usdc.approve(address(testHook), 100e6);
|
|
219
209
|
|
|
220
|
-
address brokenTerminal = makeAddr("brokenTerminal");
|
|
221
|
-
|
|
222
|
-
// Expect AddToBalanceReverted event when the broken terminal reverts.
|
|
223
|
-
vm.expectEmit(true, false, false, false);
|
|
224
|
-
emit IJB721TiersHook.AddToBalanceReverted(projectId, address(usdc), 100e6, "");
|
|
225
|
-
|
|
226
210
|
vm.prank(mockTerminalAddress);
|
|
227
|
-
//
|
|
228
|
-
|
|
211
|
+
// The payment should now revert when the fallback addToBalanceOf call fails (M-4).
|
|
212
|
+
vm.expectRevert();
|
|
229
213
|
testHook.afterPayRecordedWith(payContext);
|
|
230
|
-
|
|
231
|
-
// Tokens should remain in the hook (terminal call failed).
|
|
232
|
-
assertEq(usdc.balanceOf(address(testHook)), 100e6, "ERC20 should stay in hook when terminal reverts");
|
|
233
|
-
|
|
234
|
-
// Approval to the broken terminal should have been reset to 0.
|
|
235
|
-
assertEq(
|
|
236
|
-
usdc.allowance(address(testHook), brokenTerminal), 0, "Approval to broken terminal should be reset to 0"
|
|
237
|
-
);
|
|
238
214
|
}
|
|
239
215
|
|
|
240
216
|
/// @notice Sets up mocks for the ERC-20 broken terminal test (extracted to avoid stack-too-deep).
|
|
@@ -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) =
|