@bananapus/721-hook-v6 0.0.31 → 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/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/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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.31",
3
+ "version": "0.0.32",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@bananapus/address-registry-v6": "^0.0.17",
21
- "@bananapus/core-v6": "^0.0.31",
21
+ "@bananapus/core-v6": "^0.0.32",
22
22
  "@bananapus/ownable-v6": "^0.0.17",
23
23
  "@bananapus/permission-ids-v6": "^0.0.15",
24
24
  "@openzeppelin/contracts": "^5.6.1",
@@ -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
 
@@ -768,9 +790,10 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
768
790
  /// @param to The address the NFT is being transferred to.
769
791
  /// @param tokenId The token ID of the NFT being transferred.
770
792
  function _update(address to, uint256 tokenId, address auth) internal virtual override returns (address from) {
771
- // Get a reference to the tier.
793
+ // Get only the tier ID and transfersPausable flag (lightweight — avoids full struct construction).
772
794
  // slither-disable-next-line calls-loop
773
- 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});
774
797
 
775
798
  // Record the transfers and keep a reference to where the token is coming from.
776
799
  from = super._update({to: to, tokenId: tokenId, auth: auth});
@@ -778,7 +801,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
778
801
  // Transfers must not be paused (when not minting or burning).
779
802
  if (from != address(0)) {
780
803
  // If transfers are pausable, check if they're paused.
781
- if (tier.flags.transfersPausable) {
804
+ if (transfersPausable) {
782
805
  // Get a reference to the project's current ruleset.
783
806
  JBRuleset memory ruleset = _currentRulesetOf(PROJECT_ID);
784
807
 
@@ -798,6 +821,6 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
798
821
 
799
822
  // Record the transfer.
800
823
  // slither-disable-next-line reentrency-events,calls-loop
801
- STORE.recordTransferForTier({tierId: tier.id, from: from, to: to});
824
+ STORE.recordTransferForTier({tierId: tierId, from: from, to: to});
802
825
  }
803
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,9 +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);
29
+ error JB721TiersHookLib_SplitFallbackFailed(uint256 projectId, address token, uint256 amount, bytes reason);
30
30
  error JB721TiersHookLib_TokenTransferAmountMismatch(uint256 expectedAmount, uint256 receivedAmount);
31
- // Events mirrored from IJB721TiersHook (emitted via DELEGATECALL from the hook's context).
32
- event AddToBalanceReverted(uint256 indexed projectId, address token, uint256 amount, bytes reason);
33
31
  event AddTier(uint256 indexed tierId, JB721TierConfig tier, address caller);
34
32
  event RemoveTier(uint256 indexed tierId, address caller);
35
33
  event SplitPayoutReverted(uint256 indexed projectId, JBSplit split, uint256 amount, bytes reason, address caller);
@@ -56,8 +54,12 @@ library JB721TiersHookLib {
56
54
  {
57
55
  // Remove tiers.
58
56
  if (tierIdsToRemove.length != 0) {
59
- for (uint256 i; i < tierIdsToRemove.length; i++) {
57
+ for (uint256 i; i < tierIdsToRemove.length;) {
60
58
  emit RemoveTier({tierId: tierIdsToRemove[i], caller: caller});
59
+
60
+ unchecked {
61
+ ++i;
62
+ }
61
63
  }
62
64
  // slither-disable-next-line reentrancy-events
63
65
  store.recordRemoveTierIds(tierIdsToRemove);
@@ -68,8 +70,12 @@ library JB721TiersHookLib {
68
70
  uint256[] memory tierIdsAdded = store.recordAddTiers(tiersToAdd);
69
71
 
70
72
  // slither-disable-next-line reentrancy-events
71
- for (uint256 i; i < tiersToAdd.length; i++) {
73
+ for (uint256 i; i < tiersToAdd.length;) {
72
74
  emit AddTier({tierId: tierIdsAdded[i], tier: tiersToAdd[i], caller: caller});
75
+
76
+ unchecked {
77
+ ++i;
78
+ }
73
79
  }
74
80
 
75
81
  // Set split groups for tiers that have splits configured.
@@ -103,9 +109,13 @@ library JB721TiersHookLib {
103
109
  {
104
110
  uint256[] memory tierIdsAdded = store.recordAddTiers(tiersToAdd);
105
111
 
106
- for (uint256 i; i < tiersToAdd.length; i++) {
112
+ for (uint256 i; i < tiersToAdd.length;) {
107
113
  // slither-disable-next-line reentrancy-events
108
114
  emit AddTier({tierId: tierIdsAdded[i], tier: tiersToAdd[i], caller: caller});
115
+
116
+ unchecked {
117
+ ++i;
118
+ }
109
119
  }
110
120
 
111
121
  // Set split groups for tiers that have splits configured.
@@ -183,22 +193,27 @@ library JB721TiersHookLib {
183
193
  view
184
194
  returns (uint256 totalSplitAmount, bytes memory hookMetadata)
185
195
  {
186
- (bool found, bytes memory data) = JBMetadataResolver.getDataFor({
187
- id: JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget}), metadata: metadata
188
- });
189
- if (!found) return (0, bytes(""));
190
-
191
- (, 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
+ }
192
205
  if (tierIdsToMint.length == 0) return (0, bytes(""));
193
206
 
194
207
  uint16[] memory splitTierIds = new uint16[](tierIdsToMint.length);
195
208
  uint256[] memory splitAmounts = new uint256[](tierIdsToMint.length);
196
209
  uint256 splitTierCount;
197
210
 
198
- 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).
199
213
  // slither-disable-next-line calls-loop
200
- JB721Tier memory tier = store.tierOf({hook: hook, id: tierIdsToMint[i], includeResolvedUri: false});
201
- if (tier.splitPercent != 0) {
214
+ (uint104 tierPrice, uint32 tierSplitPercent, uint8 tierDiscountPercent) =
215
+ store.tierPricingOf({hook: hook, id: tierIdsToMint[i]});
216
+ if (tierSplitPercent != 0) {
202
217
  // Apply discount to tier price to match the discounted price that recordMint charges.
203
218
  // Note on discount semantics: `discountPercent` uses a denominator of 200 (JB721Constants
204
219
  // .DISCOUNT_DENOMINATOR), so a value of 200 represents a 100% discount (free mint). Even with a
@@ -206,18 +221,22 @@ library JB721TiersHookLib {
206
221
  // in `cashOutWeightOf`. This means free/discounted mints still carry their full cashout weight
207
222
  // value. Project owners should be aware that discounted mints dilute the cashout pool at full
208
223
  // weight while contributing less (or no) payment to the treasury.
209
- uint256 effectivePrice = tier.price;
210
- if (tier.discountPercent > 0) {
224
+ uint256 effectivePrice = tierPrice;
225
+ if (tierDiscountPercent > 0) {
211
226
  effectivePrice -= mulDiv({
212
- x: effectivePrice, y: tier.discountPercent, denominator: JB721Constants.DISCOUNT_DENOMINATOR
227
+ x: effectivePrice, y: tierDiscountPercent, denominator: JB721Constants.DISCOUNT_DENOMINATOR
213
228
  });
214
229
  }
215
230
  splitTierIds[splitTierCount] = tierIdsToMint[i];
216
231
  splitAmounts[splitTierCount] =
217
- mulDiv({x: effectivePrice, y: tier.splitPercent, denominator: JBConstants.SPLITS_TOTAL_PERCENT});
232
+ mulDiv({x: effectivePrice, y: tierSplitPercent, denominator: JBConstants.SPLITS_TOTAL_PERCENT});
218
233
  totalSplitAmount += splitAmounts[splitTierCount];
219
234
  splitTierCount++;
220
235
  }
236
+
237
+ unchecked {
238
+ ++i;
239
+ }
221
240
  }
222
241
 
223
242
  if (splitTierCount != 0) {
@@ -319,10 +338,14 @@ library JB721TiersHookLib {
319
338
 
320
339
  // Re-accumulate the total from converted amounts to avoid rounding drift.
321
340
  convertedTotal = 0;
322
- for (uint256 i; i < amounts.length; i++) {
341
+ for (uint256 i; i < amounts.length;) {
323
342
  // Convert this tier's amount: amount * ratio / 10^pricingDecimals.
324
343
  amounts[i] = mulDiv({x: amounts[i], y: ratio, denominator: denom});
325
344
  convertedTotal += amounts[i];
345
+
346
+ unchecked {
347
+ ++i;
348
+ }
326
349
  }
327
350
 
328
351
  // Re-encode with the converted amounts.
@@ -340,10 +363,14 @@ library JB721TiersHookLib {
340
363
  abi.decode(convertedMetadata, (uint16[], uint256[]));
341
364
  uint256 uncappedTotal = convertedTotal;
342
365
  convertedTotal = 0;
343
- for (uint256 i; i < amounts.length; i++) {
366
+ for (uint256 i; i < amounts.length;) {
344
367
  // Scale down: amount * amountValue / originalTotal.
345
368
  amounts[i] = mulDiv({x: amounts[i], y: amountValue, denominator: uncappedTotal});
346
369
  convertedTotal += amounts[i];
370
+
371
+ unchecked {
372
+ ++i;
373
+ }
347
374
  }
348
375
  convertedMetadata = abi.encode(tierIds, amounts);
349
376
  } else {
@@ -364,20 +391,28 @@ library JB721TiersHookLib {
364
391
  private
365
392
  {
366
393
  uint256 splitGroupCount;
367
- for (uint256 i; i < tiersToAdd.length; i++) {
394
+ for (uint256 i; i < tiersToAdd.length;) {
368
395
  if (tiersToAdd[i].splits.length != 0) splitGroupCount++;
396
+
397
+ unchecked {
398
+ ++i;
399
+ }
369
400
  }
370
401
  if (splitGroupCount == 0) return;
371
402
 
372
403
  JBSplitGroup[] memory splitGroups = new JBSplitGroup[](splitGroupCount);
373
404
  uint256 groupIndex;
374
- for (uint256 i; i < tiersToAdd.length; i++) {
405
+ for (uint256 i; i < tiersToAdd.length;) {
375
406
  if (tiersToAdd[i].splits.length != 0) {
376
407
  splitGroups[groupIndex] = JBSplitGroup({
377
408
  groupId: uint256(uint160(hookAddress)) | (tierIdsAdded[i] << 160), splits: tiersToAdd[i].splits
378
409
  });
379
410
  groupIndex++;
380
411
  }
412
+
413
+ unchecked {
414
+ ++i;
415
+ }
381
416
  }
382
417
  splits.setSplitGroupsOf({projectId: projectId, rulesetId: 0, splitGroups: splitGroups});
383
418
  }
@@ -415,8 +450,13 @@ library JB721TiersHookLib {
415
450
 
416
451
  (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(encodedSplitData, (uint16[], uint256[]));
417
452
 
418
- for (uint256 i; i < tierIds.length; i++) {
419
- 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
+ }
420
460
  uint256 groupId = uint256(uint160(hookAddress)) | (uint256(tierIds[i]) << 160);
421
461
  _distributeSingleSplit({
422
462
  directory: directory,
@@ -427,6 +467,10 @@ library JB721TiersHookLib {
427
467
  amount: amounts[i],
428
468
  decimals: decimals
429
469
  });
470
+
471
+ unchecked {
472
+ ++i;
473
+ }
430
474
  }
431
475
  }
432
476
 
@@ -456,7 +500,7 @@ library JB721TiersHookLib {
456
500
  uint256 leftoverAmount = amount;
457
501
  amount = 0;
458
502
 
459
- for (uint256 j; j < tierSplits.length; j++) {
503
+ for (uint256 j; j < tierSplits.length;) {
460
504
  uint256 payoutAmount =
461
505
  mulDiv({x: leftoverAmount, y: tierSplits[j].percent, denominator: leftoverPercentage});
462
506
  if (payoutAmount != 0) {
@@ -485,6 +529,7 @@ library JB721TiersHookLib {
485
529
  }
486
530
  unchecked {
487
531
  leftoverPercentage -= tierSplits[j].percent;
532
+ ++j;
488
533
  }
489
534
  }
490
535
 
@@ -509,8 +554,7 @@ library JB721TiersHookLib {
509
554
  metadata: bytes("")
510
555
  }) {}
511
556
  catch (bytes memory reason) {
512
- // slither-disable-next-line reentrancy-events
513
- emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
557
+ revert JB721TiersHookLib_SplitFallbackFailed(projectId, token, leftoverAmount, reason);
514
558
  }
515
559
  } else {
516
560
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: leftoverAmount});
@@ -526,8 +570,7 @@ library JB721TiersHookLib {
526
570
  catch (bytes memory reason) {
527
571
  // Reset approval on failure.
528
572
  SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
529
- // slither-disable-next-line reentrancy-events
530
- emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
573
+ revert JB721TiersHookLib_SplitFallbackFailed(projectId, token, leftoverAmount, reason);
531
574
  }
532
575
  }
533
576
  }
@@ -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).