@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 +2 -2
- package/RISKS.md +1 -1
- package/package.json +2 -2
- package/src/JB721TiersHook.sol +41 -18
- package/src/JB721TiersHookProjectDeployer.sol +15 -3
- package/src/JB721TiersHookStore.sol +119 -21
- package/src/abstract/JB721Hook.sol +5 -1
- package/src/interfaces/IJB721TiersHookStore.sol +27 -0
- package/src/libraries/JB721TiersHookLib.sol +73 -30
- package/src/libraries/JBIpfsDecoder.sol +17 -4
- package/test/regression/BrokenTerminalDoesNotDos.t.sol +11 -35
package/CHANGELOG.md
CHANGED
|
@@ -39,7 +39,7 @@ This file describes the verified change from `nana-721-hook-v5` to the current `
|
|
|
39
39
|
|
|
40
40
|
## Indexer impact
|
|
41
41
|
|
|
42
|
-
- New events: `AddToBalanceReverted
|
|
42
|
+
- New events: `AddToBalanceReverted` (declared but no longer emitted -- replaced by `JB721TiersHookLib_SplitFallbackFailed` revert error), `SetName`, `SetSymbol`, `SplitPayoutReverted`.
|
|
43
43
|
- Tier config decoding changed because `JB721TierConfig` is no longer v5-compatible.
|
|
44
44
|
- Collection metadata can now change after deployment, so one-time indexing of `name` and `symbol` is no longer sufficient.
|
|
45
45
|
|
|
@@ -58,7 +58,7 @@ This file describes the verified change from `nana-721-hook-v5` to the current `
|
|
|
58
58
|
- `pricingContext()`
|
|
59
59
|
- `setMetadata(...)`
|
|
60
60
|
- Added events
|
|
61
|
-
- `AddToBalanceReverted`
|
|
61
|
+
- `AddToBalanceReverted` (declared in interface but no longer emitted; the library now reverts with `JB721TiersHookLib_SplitFallbackFailed` instead)
|
|
62
62
|
- `SetName`
|
|
63
63
|
- `SetSymbol`
|
|
64
64
|
- `SplitPayoutReverted`
|
package/RISKS.md
CHANGED
|
@@ -46,7 +46,7 @@ This file focuses on the tiered-NFT accounting, reserve-mint, and cash-out risks
|
|
|
46
46
|
- **Split hook callbacks (`processSplitWith`).** During `afterPayRecordedWith` -> `_processPayment` -> `distributeAll`, the library calls `split.hook.processSplitWith{value}()` for each split with a hook. This executes arbitrary code. At callback time: NFTs already minted, `payCreditsOf` updated, `remainingSupply` decremented in the store. Reentering `afterPayRecordedWith` requires terminal authentication and processes as an independent payment. All split hook and terminal calls are wrapped in try-catch to prevent a single reverting recipient from bricking all payments to the project. For native token hooks, a revert returns false (ETH stays in the contract and routes to project balance). For ERC20 hooks, tokens are transferred before the callback; a revert still returns true because the tokens have already left the contract. Tested: `TestAuditGaps_Reentrancy` confirms reentrancy is blocked by terminal check.
|
|
47
47
|
- **Split beneficiary ETH sends.** `_sendPayoutToSplit` uses `beneficiary.call{value: amount}("")`. If the beneficiary reverts, the function returns `false` and the failed amount is accumulated separately, then routed to the project's balance after the distribution loop via `addToBalanceOf`. Later split recipients receive only their proportional share, not the failed recipient's share. Does not revert the entire payment.
|
|
48
48
|
- **Terminal `.pay()` / `.addToBalanceOf()` during split distribution.** For project-targeted splits, the library calls the target project's primary terminal via try-catch. A reverting terminal returns false, routing the funds to the project's balance instead. For ERC20 terminal calls, approval is reset to zero on failure to prevent dangling approvals. The target terminal could call back into the hook, but the hook's state is fully settled (supply, credits, mint state). Reentrancy through this path cannot double-mint or corrupt state.
|
|
49
|
-
- **`afterCashOutRecordedWith` execution order.** Burns tokens via `_burn()` -> `_update()` -> `STORE.
|
|
49
|
+
- **`afterCashOutRecordedWith` execution order.** Burns tokens via `_burn()` -> `_update()` -> `STORE.tierTransferInfoOfTokenId()` in a loop, then calls `STORE.recordBurn()`. ERC721 `_update` triggers the store's tier balance decrement. Burns go to `address(0)`, so no `onERC721Received` callback.
|
|
50
50
|
- **No `ReentrancyGuard`.** Protection relies on state ordering (all `STORE.record*` calls before external calls), terminal authentication checks, and try-catch wrapping of all external calls in `_sendPayoutToSplit`. `_mint()` uses the non-safe variant, avoiding `onERC721Received` callbacks during minting.
|
|
51
51
|
|
|
52
52
|
## 4. Gas/DoS Vectors
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/721-hook-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.32",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@bananapus/address-registry-v6": "^0.0.17",
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
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",
|
package/src/JB721TiersHook.sol
CHANGED
|
@@ -25,7 +25,6 @@ import {IJB721TokenUriResolver} from "./interfaces/IJB721TokenUriResolver.sol";
|
|
|
25
25
|
import {JB721TiersHookLib} from "./libraries/JB721TiersHookLib.sol";
|
|
26
26
|
import {JB721TiersRulesetMetadataResolver} from "./libraries/JB721TiersRulesetMetadataResolver.sol";
|
|
27
27
|
import {JB721InitTiersConfig} from "./structs/JB721InitTiersConfig.sol";
|
|
28
|
-
import {JB721Tier} from "./structs/JB721Tier.sol";
|
|
29
28
|
import {JB721TierConfig} from "./structs/JB721TierConfig.sol";
|
|
30
29
|
import {JB721TiersHookFlags} from "./structs/JB721TiersHookFlags.sol";
|
|
31
30
|
import {JB721TiersMintReservesConfig} from "./structs/JB721TiersMintReservesConfig.sol";
|
|
@@ -401,12 +400,16 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
401
400
|
/// @dev "Pending" means that the NFTs have been reserved, but have not been minted yet.
|
|
402
401
|
/// @param reserveMintConfigs Contains information about how many reserved tokens to mint for each tier.
|
|
403
402
|
function mintPendingReservesFor(JB721TiersMintReservesConfig[] calldata reserveMintConfigs) external override {
|
|
404
|
-
for (uint256 i; i < reserveMintConfigs.length;
|
|
403
|
+
for (uint256 i; i < reserveMintConfigs.length;) {
|
|
405
404
|
// Get a reference to the params being iterated upon.
|
|
406
405
|
JB721TiersMintReservesConfig memory params = reserveMintConfigs[i];
|
|
407
406
|
|
|
408
407
|
// Mint pending reserved NFTs from the tier.
|
|
409
408
|
mintPendingReservesFor({tierId: params.tierId, count: params.count});
|
|
409
|
+
|
|
410
|
+
unchecked {
|
|
411
|
+
++i;
|
|
412
|
+
}
|
|
410
413
|
}
|
|
411
414
|
}
|
|
412
415
|
|
|
@@ -432,11 +435,15 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
432
435
|
account: owner(), projectId: PROJECT_ID, permissionId: JBPermissionIds.SET_721_DISCOUNT_PERCENT
|
|
433
436
|
});
|
|
434
437
|
|
|
435
|
-
for (uint256 i; i < configs.length;
|
|
438
|
+
for (uint256 i; i < configs.length;) {
|
|
436
439
|
// Set the config being iterated on.
|
|
437
440
|
JB721TiersSetDiscountPercentConfig memory config = configs[i];
|
|
438
441
|
|
|
439
442
|
_setDiscountPercentOf({tierId: config.tierId, discountPercent: config.discountPercent});
|
|
443
|
+
|
|
444
|
+
unchecked {
|
|
445
|
+
++i;
|
|
446
|
+
}
|
|
440
447
|
}
|
|
441
448
|
}
|
|
442
449
|
|
|
@@ -470,25 +477,28 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
470
477
|
account: owner(), projectId: PROJECT_ID, permissionId: JBPermissionIds.SET_721_METADATA
|
|
471
478
|
});
|
|
472
479
|
|
|
480
|
+
// Cache _msgSender() at function entry to avoid repeated calls.
|
|
481
|
+
address caller = _msgSender();
|
|
482
|
+
|
|
473
483
|
if (bytes(name).length != 0) {
|
|
474
484
|
// Store the new collection name.
|
|
475
485
|
_setName(name);
|
|
476
|
-
emit SetName({name: name, caller:
|
|
486
|
+
emit SetName({name: name, caller: caller});
|
|
477
487
|
}
|
|
478
488
|
if (bytes(symbol).length != 0) {
|
|
479
489
|
// Store the new collection symbol.
|
|
480
490
|
_setSymbol(symbol);
|
|
481
|
-
emit SetSymbol({symbol: symbol, caller:
|
|
491
|
+
emit SetSymbol({symbol: symbol, caller: caller});
|
|
482
492
|
}
|
|
483
493
|
if (bytes(baseUri).length != 0) {
|
|
484
494
|
// Store the new base URI.
|
|
485
495
|
baseURI = baseUri;
|
|
486
|
-
emit SetBaseUri({baseUri: baseUri, caller:
|
|
496
|
+
emit SetBaseUri({baseUri: baseUri, caller: caller});
|
|
487
497
|
}
|
|
488
498
|
if (bytes(contractUri).length != 0) {
|
|
489
499
|
// Store the new contract URI.
|
|
490
500
|
contractURI = contractUri;
|
|
491
|
-
emit SetContractUri({uri: contractUri, caller:
|
|
501
|
+
emit SetContractUri({uri: contractUri, caller: caller});
|
|
492
502
|
}
|
|
493
503
|
|
|
494
504
|
// `address(this)` is the sentinel value meaning "leave unchanged" (since `address(0)` clears the resolver).
|
|
@@ -498,7 +508,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
498
508
|
_recordSetTokenUriResolver(tokenUriResolver);
|
|
499
509
|
}
|
|
500
510
|
if (encodedIPFSUriTierId != 0 && encodedIPFSUri != bytes32(0)) {
|
|
501
|
-
emit SetEncodedIPFSUri({tierId: encodedIPFSUriTierId, encodedUri: encodedIPFSUri, caller:
|
|
511
|
+
emit SetEncodedIPFSUri({tierId: encodedIPFSUriTierId, encodedUri: encodedIPFSUri, caller: caller});
|
|
502
512
|
|
|
503
513
|
// Store the new encoded IPFS URI.
|
|
504
514
|
STORE.recordSetEncodedIPFSUriOf({tierId: encodedIPFSUriTierId, encodedIPFSUri: encodedIPFSUri});
|
|
@@ -531,17 +541,22 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
531
541
|
// slither-disable-next-line calls-loop
|
|
532
542
|
address reserveBeneficiary = STORE.reserveBeneficiaryOf({hook: address(this), tierId: tierId});
|
|
533
543
|
|
|
534
|
-
|
|
544
|
+
// Cache _msgSender() before the loop to avoid repeated calls.
|
|
545
|
+
address caller = _msgSender();
|
|
546
|
+
|
|
547
|
+
for (uint256 i; i < count;) {
|
|
535
548
|
// Set the token ID.
|
|
536
549
|
uint256 tokenId = tokenIds[i];
|
|
537
550
|
|
|
538
|
-
emit MintReservedNft({
|
|
539
|
-
tokenId: tokenId, tierId: tierId, beneficiary: reserveBeneficiary, caller: _msgSender()
|
|
540
|
-
});
|
|
551
|
+
emit MintReservedNft({tokenId: tokenId, tierId: tierId, beneficiary: reserveBeneficiary, caller: caller});
|
|
541
552
|
|
|
542
553
|
// Mint the NFT.
|
|
543
554
|
// slither-disable-next-line reentrency-events
|
|
544
555
|
_mint({to: reserveBeneficiary, tokenId: tokenId});
|
|
556
|
+
|
|
557
|
+
unchecked {
|
|
558
|
+
++i;
|
|
559
|
+
}
|
|
545
560
|
}
|
|
546
561
|
}
|
|
547
562
|
|
|
@@ -598,17 +613,24 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
|
|
|
598
613
|
)
|
|
599
614
|
internal
|
|
600
615
|
{
|
|
601
|
-
|
|
616
|
+
// Cache _msgSender() before the loop to avoid repeated calls.
|
|
617
|
+
address caller = _msgSender();
|
|
618
|
+
|
|
619
|
+
for (uint256 i; i < tokenIds.length;) {
|
|
602
620
|
emit Mint({
|
|
603
621
|
tokenId: tokenIds[i],
|
|
604
622
|
tierId: tierIds[i],
|
|
605
623
|
beneficiary: beneficiary,
|
|
606
624
|
totalAmountPaid: totalAmountPaid,
|
|
607
|
-
caller:
|
|
625
|
+
caller: caller
|
|
608
626
|
});
|
|
609
627
|
|
|
610
628
|
// slither-disable-next-line reentrancy-events
|
|
611
629
|
_mint({to: beneficiary, tokenId: tokenIds[i]});
|
|
630
|
+
|
|
631
|
+
unchecked {
|
|
632
|
+
++i;
|
|
633
|
+
}
|
|
612
634
|
}
|
|
613
635
|
}
|
|
614
636
|
|
|
@@ -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
|
|
793
|
+
// Get only the tier ID and transfersPausable flag (lightweight — avoids full struct construction).
|
|
772
794
|
// slither-disable-next-line calls-loop
|
|
773
|
-
|
|
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 (
|
|
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:
|
|
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;
|
|
242
|
+
for (uint256 i; i < launchProjectConfig.rulesetConfigurations.length;) {
|
|
243
243
|
// Set the pay data ruleset config being iterated on.
|
|
244
244
|
JBPayDataHookRulesetConfig memory payDataRulesetConfig = launchProjectConfig.rulesetConfigurations[i];
|
|
245
245
|
|
|
@@ -274,6 +274,10 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
274
274
|
splitGroups: payDataRulesetConfig.splitGroups,
|
|
275
275
|
fundAccessLimitGroups: payDataRulesetConfig.fundAccessLimitGroups
|
|
276
276
|
});
|
|
277
|
+
|
|
278
|
+
unchecked {
|
|
279
|
+
++i;
|
|
280
|
+
}
|
|
277
281
|
}
|
|
278
282
|
|
|
279
283
|
// Launch the project.
|
|
@@ -307,7 +311,7 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
307
311
|
new JBRulesetConfig[](launchRulesetsConfig.rulesetConfigurations.length);
|
|
308
312
|
|
|
309
313
|
// Set the data hook to be active for pay transactions for each ruleset configuration.
|
|
310
|
-
for (uint256 i; i < launchRulesetsConfig.rulesetConfigurations.length;
|
|
314
|
+
for (uint256 i; i < launchRulesetsConfig.rulesetConfigurations.length;) {
|
|
311
315
|
// Set the pay data ruleset config being iterated on.
|
|
312
316
|
JBPayDataHookRulesetConfig memory payDataRulesetConfig = launchRulesetsConfig.rulesetConfigurations[i];
|
|
313
317
|
|
|
@@ -342,6 +346,10 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
342
346
|
splitGroups: payDataRulesetConfig.splitGroups,
|
|
343
347
|
fundAccessLimitGroups: payDataRulesetConfig.fundAccessLimitGroups
|
|
344
348
|
});
|
|
349
|
+
|
|
350
|
+
unchecked {
|
|
351
|
+
++i;
|
|
352
|
+
}
|
|
345
353
|
}
|
|
346
354
|
|
|
347
355
|
// Launch the rulesets.
|
|
@@ -373,7 +381,7 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
373
381
|
new JBRulesetConfig[](queueRulesetsConfig.rulesetConfigurations.length);
|
|
374
382
|
|
|
375
383
|
// Set the data hook to be active for pay transactions for each ruleset configuration.
|
|
376
|
-
for (uint256 i; i < queueRulesetsConfig.rulesetConfigurations.length;
|
|
384
|
+
for (uint256 i; i < queueRulesetsConfig.rulesetConfigurations.length;) {
|
|
377
385
|
// Set the pay data ruleset config being iterated on.
|
|
378
386
|
JBPayDataHookRulesetConfig memory payDataRulesetConfig = queueRulesetsConfig.rulesetConfigurations[i];
|
|
379
387
|
|
|
@@ -408,6 +416,10 @@ contract JB721TiersHookProjectDeployer is ERC2771Context, JBPermissioned, IJB721
|
|
|
408
416
|
splitGroups: payDataRulesetConfig.splitGroups,
|
|
409
417
|
fundAccessLimitGroups: payDataRulesetConfig.fundAccessLimitGroups
|
|
410
418
|
});
|
|
419
|
+
|
|
420
|
+
unchecked {
|
|
421
|
+
++i;
|
|
422
|
+
}
|
|
411
423
|
}
|
|
412
424
|
|
|
413
425
|
// Queue the rulesets.
|
|
@@ -214,6 +214,47 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
/// @notice Get only the pricing fields for a tier, avoiding full struct construction.
|
|
218
|
+
/// @param hook The 721 contract that the tier belongs to.
|
|
219
|
+
/// @param id The tier ID.
|
|
220
|
+
/// @return price The tier price.
|
|
221
|
+
/// @return splitPercent The split percent.
|
|
222
|
+
/// @return discountPercent The discount percent.
|
|
223
|
+
function tierPricingOf(
|
|
224
|
+
address hook,
|
|
225
|
+
uint256 id
|
|
226
|
+
)
|
|
227
|
+
external
|
|
228
|
+
view
|
|
229
|
+
override
|
|
230
|
+
returns (uint104 price, uint32 splitPercent, uint8 discountPercent)
|
|
231
|
+
{
|
|
232
|
+
JBStored721Tier memory storedTier = _storedTierOf[hook][id];
|
|
233
|
+
price = storedTier.price;
|
|
234
|
+
splitPercent = storedTier.splitPercent;
|
|
235
|
+
discountPercent = storedTier.discountPercent;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// @notice Get only the tier ID and transfersPausable flag for a token, avoiding full struct construction.
|
|
239
|
+
/// @param hook The 721 hook address.
|
|
240
|
+
/// @param tokenId The token ID.
|
|
241
|
+
/// @return tierId The tier ID.
|
|
242
|
+
/// @return transfersPausable Whether transfers are paused for this tier.
|
|
243
|
+
function tierTransferInfoOfTokenId(
|
|
244
|
+
address hook,
|
|
245
|
+
uint256 tokenId
|
|
246
|
+
)
|
|
247
|
+
external
|
|
248
|
+
view
|
|
249
|
+
override
|
|
250
|
+
returns (uint256 tierId, bool transfersPausable)
|
|
251
|
+
{
|
|
252
|
+
tierId = tierIdOfToken(tokenId);
|
|
253
|
+
JBStored721Tier memory storedTier = _storedTierOf[hook][tierId];
|
|
254
|
+
// Bit 1 (0-indexed) of packedBools is transfersPausable.
|
|
255
|
+
transfersPausable = (storedTier.packedBools & 0x2) != 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
217
258
|
/// @notice Gets an array of currently active 721 tiers for the provided 721 contract.
|
|
218
259
|
/// @param hook The 721 contract to get the tiers of.
|
|
219
260
|
/// @param categories An array tier categories to get tiers from. Send an empty array to get all categories.
|
|
@@ -345,12 +386,19 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
345
386
|
// Keep a reference to the greatest tier ID.
|
|
346
387
|
uint256 maxTierId = maxTierIdOf[hook];
|
|
347
388
|
|
|
348
|
-
for (uint256 i = maxTierId; i != 0;
|
|
389
|
+
for (uint256 i = maxTierId; i != 0;) {
|
|
349
390
|
// Set the tier being iterated on.
|
|
350
391
|
JBStored721Tier memory storedTier = _storedTierOf[hook][i];
|
|
351
392
|
|
|
352
|
-
//
|
|
353
|
-
|
|
393
|
+
// Skip unminted tiers — they contribute zero supply.
|
|
394
|
+
if (storedTier.initialSupply != storedTier.remainingSupply) {
|
|
395
|
+
// Increment the total supply by the number of tokens already minted.
|
|
396
|
+
supply += storedTier.initialSupply - (storedTier.remainingSupply + numberOfBurnedFor[hook][i]);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
unchecked {
|
|
400
|
+
--i;
|
|
401
|
+
}
|
|
354
402
|
}
|
|
355
403
|
}
|
|
356
404
|
|
|
@@ -366,12 +414,17 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
366
414
|
uint256 maxTierId = maxTierIdOf[hook];
|
|
367
415
|
|
|
368
416
|
// Loop through all tiers.
|
|
369
|
-
for (uint256 i = maxTierId; i != 0;
|
|
417
|
+
for (uint256 i = maxTierId; i != 0;) {
|
|
370
418
|
// Get a reference to the account's balance in this tier.
|
|
371
419
|
uint256 balance = tierBalanceOf[hook][account][i];
|
|
372
420
|
|
|
373
421
|
// If the account has no balance, return.
|
|
374
|
-
if (balance == 0)
|
|
422
|
+
if (balance == 0) {
|
|
423
|
+
unchecked {
|
|
424
|
+
--i;
|
|
425
|
+
}
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
375
428
|
|
|
376
429
|
// Get the tier.
|
|
377
430
|
JBStored721Tier memory storedTier = _storedTierOf[hook][i];
|
|
@@ -382,6 +435,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
382
435
|
// Add the voting units for the address' balance in this tier.
|
|
383
436
|
// Use custom voting units if set. Otherwise, use the tier's price.
|
|
384
437
|
units += balance * (useVotingUnits ? _tierVotingUnitsOf[hook][i] : storedTier.price);
|
|
438
|
+
|
|
439
|
+
unchecked {
|
|
440
|
+
--i;
|
|
441
|
+
}
|
|
385
442
|
}
|
|
386
443
|
}
|
|
387
444
|
|
|
@@ -399,9 +456,13 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
399
456
|
uint256 maxTierId = maxTierIdOf[hook];
|
|
400
457
|
|
|
401
458
|
// Loop through all tiers.
|
|
402
|
-
for (uint256 i = maxTierId; i != 0;
|
|
459
|
+
for (uint256 i = maxTierId; i != 0;) {
|
|
403
460
|
// Get a reference to the account's balance within this tier.
|
|
404
461
|
balance += tierBalanceOf[hook][owner][i];
|
|
462
|
+
|
|
463
|
+
unchecked {
|
|
464
|
+
--i;
|
|
465
|
+
}
|
|
405
466
|
}
|
|
406
467
|
}
|
|
407
468
|
|
|
@@ -418,8 +479,12 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
418
479
|
// that affect the purchase price, but the NFT's weight in the cash out curve is always based on its
|
|
419
480
|
// tier's original price. This prevents discount changes from altering the cash out value of already-minted
|
|
420
481
|
// NFTs.
|
|
421
|
-
for (uint256 i; i < tokenIds.length;
|
|
482
|
+
for (uint256 i; i < tokenIds.length;) {
|
|
422
483
|
weight += _storedTierOf[hook][tierIdOfToken(tokenIds[i])].price;
|
|
484
|
+
|
|
485
|
+
unchecked {
|
|
486
|
+
++i;
|
|
487
|
+
}
|
|
423
488
|
}
|
|
424
489
|
}
|
|
425
490
|
|
|
@@ -474,18 +539,32 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
474
539
|
|
|
475
540
|
// Add each 721's original price (from its tier) to the weight.
|
|
476
541
|
// Uses the full tier price, not the discounted price — by design. See `cashOutWeightOf` for rationale.
|
|
477
|
-
for (uint256 i = 1; i <= maxTierId;
|
|
542
|
+
for (uint256 i = 1; i <= maxTierId;) {
|
|
478
543
|
// Keep a reference to the stored tier.
|
|
479
544
|
JBStored721Tier memory storedTier = _storedTierOf[hook][i];
|
|
480
545
|
|
|
546
|
+
// Skip empty tiers (zero mints and zero burns) — they contribute zero weight.
|
|
547
|
+
if (storedTier.initialSupply == storedTier.remainingSupply && numberOfBurnedFor[hook][i] == 0) {
|
|
548
|
+
unchecked {
|
|
549
|
+
++i;
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
481
554
|
// Add the tier's price multiplied by the number of minted NFTs plus pending reserves.
|
|
482
555
|
// Pending reserves are included by design — they represent committed obligations that will be
|
|
483
556
|
// minted to the reserve beneficiary. Including them in the denominator ensures cash-out values
|
|
484
557
|
// account for the full diluted supply, preventing early cashers from extracting more than their
|
|
485
558
|
// fair share before reserves are minted.
|
|
559
|
+
// Note: removed tiers are NOT skipped here because minted NFTs from removed tiers still carry
|
|
560
|
+
// cash-out weight, and their pending reserves can still be minted.
|
|
486
561
|
weight += storedTier.price
|
|
487
562
|
* ((storedTier.initialSupply - (storedTier.remainingSupply + numberOfBurnedFor[hook][i]))
|
|
488
563
|
+ _numberOfPendingReservesFor({hook: hook, tierId: i, storedTier: storedTier}));
|
|
564
|
+
|
|
565
|
+
unchecked {
|
|
566
|
+
++i;
|
|
567
|
+
}
|
|
489
568
|
}
|
|
490
569
|
}
|
|
491
570
|
|
|
@@ -831,7 +910,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
831
910
|
// Keep a reference to the 721 contract's flags.
|
|
832
911
|
JB721TiersHookFlags memory flags = _flagsOf[msg.sender];
|
|
833
912
|
|
|
834
|
-
for (uint256 i; i < tiersToAdd.length;
|
|
913
|
+
for (uint256 i; i < tiersToAdd.length;) {
|
|
835
914
|
// Set the tier being iterated upon.
|
|
836
915
|
JB721TierConfig memory tierToAdd = tiersToAdd[i];
|
|
837
916
|
|
|
@@ -841,17 +920,14 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
841
920
|
revert JB721TiersHookStore_InvalidQuantity(tierToAdd.initialSupply, _ONE_BILLION - 1);
|
|
842
921
|
}
|
|
843
922
|
|
|
844
|
-
// Keep a reference to the previous tier.
|
|
845
|
-
JB721TierConfig memory previousTier;
|
|
846
|
-
|
|
847
923
|
// Make sure the tier's category is greater than or equal to the previously added tier's category.
|
|
924
|
+
// Access calldata directly for the previous tier's category — it's only used once per iteration.
|
|
848
925
|
if (i != 0) {
|
|
849
|
-
|
|
850
|
-
previousTier = tiersToAdd[i - 1];
|
|
926
|
+
uint256 previousCategory = tiersToAdd[i - 1].category;
|
|
851
927
|
|
|
852
928
|
// Revert if the category is not equal or greater than the previously added tier's category.
|
|
853
|
-
if (tierToAdd.category <
|
|
854
|
-
revert JB721TiersHookStore_InvalidCategorySortOrder(tierToAdd.category,
|
|
929
|
+
if (tierToAdd.category < previousCategory) {
|
|
930
|
+
revert JB721TiersHookStore_InvalidCategorySortOrder(tierToAdd.category, previousCategory);
|
|
855
931
|
}
|
|
856
932
|
}
|
|
857
933
|
|
|
@@ -928,7 +1004,9 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
928
1004
|
// If this is the first tier in a new category, store it as the first tier in that category.
|
|
929
1005
|
// The `_startingTierIdOfCategory` of the category "0" will always be the same as the `_tierIdAfter` the 0th
|
|
930
1006
|
// tier.
|
|
931
|
-
|
|
1007
|
+
// Access the previous tier's category directly from calldata (0 when i == 0, matching the old
|
|
1008
|
+
// uninitialized-memory behavior).
|
|
1009
|
+
if ((i == 0 ? 0 : tiersToAdd[i - 1].category) != tierToAdd.category && tierToAdd.category != 0) {
|
|
932
1010
|
_startingTierIdOfCategory[msg.sender][tierToAdd.category] = tierId;
|
|
933
1011
|
}
|
|
934
1012
|
|
|
@@ -1028,6 +1106,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1028
1106
|
|
|
1029
1107
|
// Add the tier ID to the array being returned.
|
|
1030
1108
|
tierIds[i] = tierId;
|
|
1109
|
+
|
|
1110
|
+
unchecked {
|
|
1111
|
+
++i;
|
|
1112
|
+
}
|
|
1031
1113
|
}
|
|
1032
1114
|
|
|
1033
1115
|
// Update the maximum tier ID to include the new tiers.
|
|
@@ -1041,7 +1123,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1041
1123
|
/// @param tokenIds The token IDs of the NFTs to burn.
|
|
1042
1124
|
function recordBurn(uint256[] calldata tokenIds) external override {
|
|
1043
1125
|
// Iterate through all token IDs to increment the burn count.
|
|
1044
|
-
for (uint256 i; i < tokenIds.length;
|
|
1126
|
+
for (uint256 i; i < tokenIds.length;) {
|
|
1045
1127
|
// Set the 721's token ID.
|
|
1046
1128
|
uint256 tokenId = tokenIds[i];
|
|
1047
1129
|
|
|
@@ -1049,6 +1131,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1049
1131
|
|
|
1050
1132
|
// Increment the number of NFTs burned from the tier.
|
|
1051
1133
|
numberOfBurnedFor[msg.sender][tierId]++;
|
|
1134
|
+
|
|
1135
|
+
unchecked {
|
|
1136
|
+
++i;
|
|
1137
|
+
}
|
|
1052
1138
|
}
|
|
1053
1139
|
}
|
|
1054
1140
|
|
|
@@ -1090,7 +1176,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1090
1176
|
// Initialize a `JBBitmapWord` for checking whether tiers have been removed.
|
|
1091
1177
|
JBBitmapWord memory bitmapWord;
|
|
1092
1178
|
|
|
1093
|
-
for (uint256 i; i < numberOfTiers;
|
|
1179
|
+
for (uint256 i; i < numberOfTiers;) {
|
|
1094
1180
|
// Set the tier ID being iterated on.
|
|
1095
1181
|
uint256 tierId = tierIds[i];
|
|
1096
1182
|
|
|
@@ -1150,6 +1236,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1150
1236
|
) {
|
|
1151
1237
|
revert JB721TiersHookStore_InsufficientSupplyRemaining(tierId);
|
|
1152
1238
|
}
|
|
1239
|
+
|
|
1240
|
+
unchecked {
|
|
1241
|
+
++i;
|
|
1242
|
+
}
|
|
1153
1243
|
}
|
|
1154
1244
|
}
|
|
1155
1245
|
|
|
@@ -1181,11 +1271,15 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1181
1271
|
// Initialize an array for the token IDs to be returned.
|
|
1182
1272
|
tokenIds = new uint256[](count);
|
|
1183
1273
|
|
|
1184
|
-
for (uint256 i; i < count;
|
|
1274
|
+
for (uint256 i; i < count;) {
|
|
1185
1275
|
// Generate the NFTs.
|
|
1186
1276
|
tokenIds[i] = _generateTokenId({
|
|
1187
1277
|
tierId: tierId, tokenNumber: storedTier.initialSupply - --storedTier.remainingSupply
|
|
1188
1278
|
});
|
|
1279
|
+
|
|
1280
|
+
unchecked {
|
|
1281
|
+
++i;
|
|
1282
|
+
}
|
|
1189
1283
|
}
|
|
1190
1284
|
}
|
|
1191
1285
|
|
|
@@ -1194,7 +1288,7 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1194
1288
|
/// Call `cleanTiers()` after removing tiers to update the sorting sequence and prevent stale tier iteration.
|
|
1195
1289
|
/// @param tierIds The IDs of the tiers being removed.
|
|
1196
1290
|
function recordRemoveTierIds(uint256[] calldata tierIds) external override {
|
|
1197
|
-
for (uint256 i; i < tierIds.length;
|
|
1291
|
+
for (uint256 i; i < tierIds.length;) {
|
|
1198
1292
|
// Set the tier being iterated upon (0-indexed).
|
|
1199
1293
|
uint256 tierId = tierIds[i];
|
|
1200
1294
|
|
|
@@ -1209,6 +1303,10 @@ contract JB721TiersHookStore is IJB721TiersHookStore {
|
|
|
1209
1303
|
|
|
1210
1304
|
// Remove the tier by marking it as removed in the bitmap.
|
|
1211
1305
|
_removedTiersBitmapWordOf[msg.sender].removeTier(tierId);
|
|
1306
|
+
|
|
1307
|
+
unchecked {
|
|
1308
|
+
++i;
|
|
1309
|
+
}
|
|
1212
1310
|
}
|
|
1213
1311
|
}
|
|
1214
1312
|
|
|
@@ -209,7 +209,7 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
|
|
|
209
209
|
if (metadataExists) decodedTokenIds = abi.decode(metadata, (uint256[]));
|
|
210
210
|
|
|
211
211
|
// Iterate through the NFTs, burning them if the owner is correct.
|
|
212
|
-
for (uint256 i; i < decodedTokenIds.length;
|
|
212
|
+
for (uint256 i; i < decodedTokenIds.length;) {
|
|
213
213
|
// Set the current NFT's token ID.
|
|
214
214
|
uint256 tokenId = decodedTokenIds[i];
|
|
215
215
|
|
|
@@ -218,6 +218,10 @@ abstract contract JB721Hook is ERC721, IJB721Hook {
|
|
|
218
218
|
|
|
219
219
|
// Burn the token.
|
|
220
220
|
_burn(tokenId);
|
|
221
|
+
|
|
222
|
+
unchecked {
|
|
223
|
+
++i;
|
|
224
|
+
}
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
// Call the hook.
|
|
@@ -124,6 +124,33 @@ interface IJB721TiersHookStore {
|
|
|
124
124
|
view
|
|
125
125
|
returns (JB721Tier memory tier);
|
|
126
126
|
|
|
127
|
+
/// @notice Get only the pricing fields for a tier, avoiding full struct construction.
|
|
128
|
+
/// @param hook The 721 contract that the tier belongs to.
|
|
129
|
+
/// @param id The tier ID.
|
|
130
|
+
/// @return price The tier price.
|
|
131
|
+
/// @return splitPercent The split percent.
|
|
132
|
+
/// @return discountPercent The discount percent.
|
|
133
|
+
function tierPricingOf(
|
|
134
|
+
address hook,
|
|
135
|
+
uint256 id
|
|
136
|
+
)
|
|
137
|
+
external
|
|
138
|
+
view
|
|
139
|
+
returns (uint104 price, uint32 splitPercent, uint8 discountPercent);
|
|
140
|
+
|
|
141
|
+
/// @notice Get only the tier ID and transfersPausable flag for a token, avoiding full struct construction.
|
|
142
|
+
/// @param hook The 721 hook address.
|
|
143
|
+
/// @param tokenId The token ID.
|
|
144
|
+
/// @return tierId The tier ID.
|
|
145
|
+
/// @return transfersPausable Whether transfers are paused for this tier.
|
|
146
|
+
function tierTransferInfoOfTokenId(
|
|
147
|
+
address hook,
|
|
148
|
+
uint256 tokenId
|
|
149
|
+
)
|
|
150
|
+
external
|
|
151
|
+
view
|
|
152
|
+
returns (uint256 tierId, bool transfersPausable);
|
|
153
|
+
|
|
127
154
|
/// @notice Get an array of currently active 721 tiers for the provided 721 contract.
|
|
128
155
|
/// @param hook The 721 contract to get the tiers of.
|
|
129
156
|
/// @param categories An array of tier categories to get tiers from. Empty for all categories.
|
|
@@ -18,7 +18,6 @@ import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
|
|
|
18
18
|
|
|
19
19
|
import {IJB721TiersHookStore} from "../interfaces/IJB721TiersHookStore.sol";
|
|
20
20
|
import {IJB721TokenUriResolver} from "../interfaces/IJB721TokenUriResolver.sol";
|
|
21
|
-
import {JB721Tier} from "../structs/JB721Tier.sol";
|
|
22
21
|
import {JB721TierConfig} from "../structs/JB721TierConfig.sol";
|
|
23
22
|
import {JB721Constants} from "./JB721Constants.sol";
|
|
24
23
|
import {JBIpfsDecoder} from "./JBIpfsDecoder.sol";
|
|
@@ -27,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;
|
|
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;
|
|
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;
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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;
|
|
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
|
-
|
|
201
|
-
|
|
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 =
|
|
210
|
-
if (
|
|
224
|
+
uint256 effectivePrice = tierPrice;
|
|
225
|
+
if (tierDiscountPercent > 0) {
|
|
211
226
|
effectivePrice -= mulDiv({
|
|
212
|
-
x: effectivePrice, y:
|
|
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:
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
419
|
-
if (amounts[i] == 0)
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
44
|
+
for (uint256 i; i < sourceLength;) {
|
|
45
45
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
46
46
|
uint256 carry = uint8(source[i]);
|
|
47
47
|
|
|
@@ -64,14 +64,22 @@ library JBIpfsDecoder {
|
|
|
64
64
|
}
|
|
65
65
|
carry = carry / 58;
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
unchecked {
|
|
69
|
+
++i;
|
|
70
|
+
}
|
|
67
71
|
}
|
|
68
72
|
return string(_toAlphabet(_reverse(_truncate({array: digits, length: digitlength}))));
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
function _truncate(uint8[] memory array, uint8 length) private pure returns (uint8[] memory) {
|
|
72
76
|
uint8[] memory output = new uint8[](length);
|
|
73
|
-
for (uint256 i; i < length;
|
|
77
|
+
for (uint256 i; i < length;) {
|
|
74
78
|
output[i] = array[i];
|
|
79
|
+
|
|
80
|
+
unchecked {
|
|
81
|
+
++i;
|
|
82
|
+
}
|
|
75
83
|
}
|
|
76
84
|
return output;
|
|
77
85
|
}
|
|
@@ -79,9 +87,10 @@ library JBIpfsDecoder {
|
|
|
79
87
|
function _reverse(uint8[] memory input) private pure returns (uint8[] memory) {
|
|
80
88
|
uint256 inputLength = input.length;
|
|
81
89
|
uint8[] memory output = new uint8[](inputLength);
|
|
82
|
-
for (uint256 i; i < inputLength;
|
|
90
|
+
for (uint256 i; i < inputLength;) {
|
|
83
91
|
unchecked {
|
|
84
92
|
output[i] = input[input.length - 1 - i];
|
|
93
|
+
++i;
|
|
85
94
|
}
|
|
86
95
|
}
|
|
87
96
|
return output;
|
|
@@ -90,8 +99,12 @@ library JBIpfsDecoder {
|
|
|
90
99
|
function _toAlphabet(uint8[] memory indices) private pure returns (bytes memory) {
|
|
91
100
|
uint256 indicesLength = indices.length;
|
|
92
101
|
bytes memory output = new bytes(indicesLength);
|
|
93
|
-
for (uint256 i; i < indicesLength;
|
|
102
|
+
for (uint256 i; i < indicesLength;) {
|
|
94
103
|
output[i] = ALPHABET[indices[i]];
|
|
104
|
+
|
|
105
|
+
unchecked {
|
|
106
|
+
++i;
|
|
107
|
+
}
|
|
95
108
|
}
|
|
96
109
|
return output;
|
|
97
110
|
}
|
|
@@ -11,10 +11,9 @@ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
|
11
11
|
import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
|
|
12
12
|
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
13
13
|
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
14
|
-
import {IJB721TiersHook} from "../../src/interfaces/IJB721TiersHook.sol";
|
|
15
14
|
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
16
15
|
|
|
17
|
-
/// @notice Regression tests: a broken project terminal in _addToBalance
|
|
16
|
+
/// @notice Regression tests: a broken project terminal in _addToBalance now reverts the payment (M-4 fix).
|
|
18
17
|
contract Test_BrokenTerminalDoesNotDos is UnitTestSetup {
|
|
19
18
|
using stdStorage for StdStorage;
|
|
20
19
|
|
|
@@ -76,15 +75,13 @@ contract Test_BrokenTerminalDoesNotDos is UnitTestSetup {
|
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
// ──────────────────────────────────────────────────────────────────────
|
|
79
|
-
// ETH: broken own-project terminal in _addToBalance should
|
|
78
|
+
// ETH: broken own-project terminal in _addToBalance should revert (M-4)
|
|
80
79
|
// ──────────────────────────────────────────────────────────────────────
|
|
81
80
|
|
|
82
81
|
/// @notice When a split has no valid recipient (projectId==0, beneficiary==address(0)),
|
|
83
82
|
/// funds route to the project's own terminal via _addToBalance. If that terminal reverts,
|
|
84
|
-
/// the
|
|
85
|
-
|
|
86
|
-
/// the hook contract).
|
|
87
|
-
function test_brokenOwnTerminal_eth_doesNotDosPayments() public {
|
|
83
|
+
/// the payment now reverts with JB721TiersHookLib_SplitFallbackFailed (M-4 fix).
|
|
84
|
+
function test_brokenOwnTerminal_eth_reverts() public {
|
|
88
85
|
ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
|
|
89
86
|
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
90
87
|
|
|
@@ -172,26 +169,19 @@ contract Test_BrokenTerminalDoesNotDos is UnitTestSetup {
|
|
|
172
169
|
|
|
173
170
|
vm.deal(mockTerminalAddress, 2 ether);
|
|
174
171
|
|
|
175
|
-
// Expect AddToBalanceReverted event when the broken terminal reverts.
|
|
176
|
-
vm.expectEmit(true, false, false, false);
|
|
177
|
-
emit IJB721TiersHook.AddToBalanceReverted(projectId, JBConstants.NATIVE_TOKEN, 1 ether, "");
|
|
178
|
-
|
|
179
172
|
vm.prank(mockTerminalAddress);
|
|
180
|
-
//
|
|
181
|
-
|
|
173
|
+
// The payment should now revert when the fallback addToBalanceOf call fails (M-4).
|
|
174
|
+
vm.expectRevert();
|
|
182
175
|
testHook.afterPayRecordedWith{value: 1 ether}(payContext);
|
|
183
|
-
|
|
184
|
-
// The ETH should remain in the hook contract since the terminal call failed.
|
|
185
|
-
assertGe(address(testHook).balance, 1 ether, "ETH should stay in hook when terminal reverts");
|
|
186
176
|
}
|
|
187
177
|
|
|
188
178
|
// ──────────────────────────────────────────────────────────────────────
|
|
189
|
-
// ERC-20: broken own-project terminal in _addToBalance should
|
|
179
|
+
// ERC-20: broken own-project terminal in _addToBalance should revert (M-4)
|
|
190
180
|
// ──────────────────────────────────────────────────────────────────────
|
|
191
181
|
|
|
192
182
|
/// @notice Same scenario as above but with ERC-20 tokens. On terminal failure, the
|
|
193
|
-
/// approval
|
|
194
|
-
function
|
|
183
|
+
/// approval is reset to 0 for safety and the payment now reverts (M-4 fix).
|
|
184
|
+
function test_brokenOwnTerminal_erc20_reverts() public {
|
|
195
185
|
BrokenTerminalERC20 usdc = new BrokenTerminalERC20("USD Coin", "USDC", 6);
|
|
196
186
|
uint32 usdcCurrency = uint32(uint160(address(usdc)));
|
|
197
187
|
|
|
@@ -217,24 +207,10 @@ contract Test_BrokenTerminalDoesNotDos is UnitTestSetup {
|
|
|
217
207
|
vm.prank(mockTerminalAddress);
|
|
218
208
|
usdc.approve(address(testHook), 100e6);
|
|
219
209
|
|
|
220
|
-
address brokenTerminal = makeAddr("brokenTerminal");
|
|
221
|
-
|
|
222
|
-
// Expect AddToBalanceReverted event when the broken terminal reverts.
|
|
223
|
-
vm.expectEmit(true, false, false, false);
|
|
224
|
-
emit IJB721TiersHook.AddToBalanceReverted(projectId, address(usdc), 100e6, "");
|
|
225
|
-
|
|
226
210
|
vm.prank(mockTerminalAddress);
|
|
227
|
-
//
|
|
228
|
-
|
|
211
|
+
// The payment should now revert when the fallback addToBalanceOf call fails (M-4).
|
|
212
|
+
vm.expectRevert();
|
|
229
213
|
testHook.afterPayRecordedWith(payContext);
|
|
230
|
-
|
|
231
|
-
// Tokens should remain in the hook (terminal call failed).
|
|
232
|
-
assertEq(usdc.balanceOf(address(testHook)), 100e6, "ERC20 should stay in hook when terminal reverts");
|
|
233
|
-
|
|
234
|
-
// Approval to the broken terminal should have been reset to 0.
|
|
235
|
-
assertEq(
|
|
236
|
-
usdc.allowance(address(testHook), brokenTerminal), 0, "Approval to broken terminal should be reset to 0"
|
|
237
|
-
);
|
|
238
214
|
}
|
|
239
215
|
|
|
240
216
|
/// @notice Sets up mocks for the ERC-20 broken terminal test (extracted to avoid stack-too-deep).
|