@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 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/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`, `SetName`, `SetSymbol`, `SplitPayoutReverted`.
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.recordTransferForTier()` 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.
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.30",
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.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.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",
@@ -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; i++) {
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; i++) {
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: _msgSender()});
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: _msgSender()});
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: _msgSender()});
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: _msgSender()});
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: _msgSender()});
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
- for (uint256 i; i < count; i++) {
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
- for (uint256 i; i < tokenIds.length; i++) {
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: _msgSender()
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`: restricted tiers must be fully covered by fresh payment (not credits).
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 a reference to the tier.
793
+ // Get only the tier ID and transfersPausable flag (lightweight — avoids full struct construction).
770
794
  // slither-disable-next-line calls-loop
771
- JB721Tier memory tier = STORE.tierOfTokenId({hook: address(this), tokenId: tokenId, includeResolvedUri: false});
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 (tier.flags.transfersPausable) {
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: tier.id, from: from, to: to});
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; i++) {
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; i++) {
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; i++) {
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; i--) {
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
- // Increment the total supply by the number of tokens already minted.
353
- supply += storedTier.initialSupply - (storedTier.remainingSupply + numberOfBurnedFor[hook][i]);
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; i--) {
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) continue;
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; i--) {
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; i++) {
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; i++) {
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; i++) {
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
- // Set the reference to the previously added tier.
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 < previousTier.category) {
854
- revert JB721TiersHookStore_InvalidCategorySortOrder(tierToAdd.category, previousTier.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
- if (previousTier.category != tierToAdd.category && tierToAdd.category != 0) {
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; i++) {
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; i++) {
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; i++) {
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; i++) {
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; i++) {
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
- // Events mirrored from IJB721TiersHook (emitted via DELEGATECALL from the hook's context).
31
- event AddToBalanceReverted(uint256 indexed projectId, address token, uint256 amount, bytes reason);
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; i++) {
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; i++) {
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
- // slither-disable-next-line reentrancy-events
106
- for (uint256 i; i < tiersToAdd.length; i++) {
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
- if (amountCurrency == pricingCurrency) return (amountValue, true);
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
- (bool found, bytes memory data) = JBMetadataResolver.getDataFor({
180
- id: JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget}), metadata: metadata
181
- });
182
- if (!found) return (0, bytes(""));
183
-
184
- (, uint16[] memory tierIdsToMint) = abi.decode(data, (bool, uint16[]));
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; i++) {
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
- JB721Tier memory tier = store.tierOf({hook: hook, id: tierIdsToMint[i], includeResolvedUri: false});
194
- if (tier.splitPercent != 0) {
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 = tier.price;
203
- if (tier.discountPercent > 0) {
224
+ uint256 effectivePrice = tierPrice;
225
+ if (tierDiscountPercent > 0) {
204
226
  effectivePrice -= mulDiv({
205
- x: effectivePrice, y: tier.discountPercent, denominator: JB721Constants.DISCOUNT_DENOMINATOR
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: tier.splitPercent, denominator: JBConstants.SPLITS_TOTAL_PERCENT});
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; i++) {
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
- for (uint256 i; i < amounts.length; i++) {
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: convertedTotal});
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; i++) {
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; i++) {
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; i++) {
404
- if (amounts[i] == 0) continue;
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; j++) {
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
- emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
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
- emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
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; i++) {
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; i++) {
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; i++) {
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; i++) {
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 should not DOS payments.
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 not revert
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 old code would propagate the revert and DOS the entire payment. With the try-catch
85
- /// fix, the function silently catches the failure and the payment succeeds (funds stay in
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
- // Before the fix, this would revert with "terminal broken".
181
- // With the try-catch, it succeeds, emits the event, and the ETH stays in the hook contract.
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 not revert
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 should be reset to 0 for safety, and the payment should not revert.
194
- function test_brokenOwnTerminal_erc20_doesNotDosPayments() public {
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
- // Before the fix, this would revert with "terminal broken".
228
- // With the try-catch, it succeeds and emits the event.
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 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) =