@bananapus/721-hook-v6 0.0.1

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.
Files changed (100) hide show
  1. package/.gas-snapshot +152 -0
  2. package/LICENSE +21 -0
  3. package/README.md +253 -0
  4. package/SKILLS.md +140 -0
  5. package/docs/book.css +13 -0
  6. package/docs/book.toml +12 -0
  7. package/docs/solidity.min.js +74 -0
  8. package/docs/src/README.md +253 -0
  9. package/docs/src/SUMMARY.md +38 -0
  10. package/docs/src/src/JB721TiersHook.sol/contract.JB721TiersHook.md +645 -0
  11. package/docs/src/src/JB721TiersHookDeployer.sol/contract.JB721TiersHookDeployer.md +99 -0
  12. package/docs/src/src/JB721TiersHookProjectDeployer.sol/contract.JB721TiersHookProjectDeployer.md +288 -0
  13. package/docs/src/src/JB721TiersHookStore.sol/contract.JB721TiersHookStore.md +1096 -0
  14. package/docs/src/src/README.md +11 -0
  15. package/docs/src/src/abstract/ERC721.sol/abstract.ERC721.md +430 -0
  16. package/docs/src/src/abstract/JB721Hook.sol/abstract.JB721Hook.md +309 -0
  17. package/docs/src/src/abstract/README.md +5 -0
  18. package/docs/src/src/interfaces/IJB721Hook.sol/interface.IJB721Hook.md +29 -0
  19. package/docs/src/src/interfaces/IJB721TiersHook.sol/interface.IJB721TiersHook.md +203 -0
  20. package/docs/src/src/interfaces/IJB721TiersHookDeployer.sol/interface.IJB721TiersHookDeployer.md +25 -0
  21. package/docs/src/src/interfaces/IJB721TiersHookProjectDeployer.sol/interface.IJB721TiersHookProjectDeployer.md +64 -0
  22. package/docs/src/src/interfaces/IJB721TiersHookStore.sol/interface.IJB721TiersHookStore.md +265 -0
  23. package/docs/src/src/interfaces/IJB721TokenUriResolver.sol/interface.IJB721TokenUriResolver.md +12 -0
  24. package/docs/src/src/interfaces/README.md +9 -0
  25. package/docs/src/src/libraries/JB721Constants.sol/library.JB721Constants.md +14 -0
  26. package/docs/src/src/libraries/JB721TiersRulesetMetadataResolver.sol/library.JB721TiersRulesetMetadataResolver.md +68 -0
  27. package/docs/src/src/libraries/JBBitmap.sol/library.JBBitmap.md +82 -0
  28. package/docs/src/src/libraries/JBIpfsDecoder.sol/library.JBIpfsDecoder.md +61 -0
  29. package/docs/src/src/libraries/README.md +7 -0
  30. package/docs/src/src/structs/JB721InitTiersConfig.sol/struct.JB721InitTiersConfig.md +27 -0
  31. package/docs/src/src/structs/JB721Tier.sol/struct.JB721Tier.md +59 -0
  32. package/docs/src/src/structs/JB721TierConfig.sol/struct.JB721TierConfig.md +60 -0
  33. package/docs/src/src/structs/JB721TiersHookFlags.sol/struct.JB721TiersHookFlags.md +26 -0
  34. package/docs/src/src/structs/JB721TiersMintReservesConfig.sol/struct.JB721TiersMintReservesConfig.md +16 -0
  35. package/docs/src/src/structs/JB721TiersRulesetMetadata.sol/struct.JB721TiersRulesetMetadata.md +20 -0
  36. package/docs/src/src/structs/JB721TiersSetDiscountPercentConfig.sol/struct.JB721TiersSetDiscountPercentConfig.md +16 -0
  37. package/docs/src/src/structs/JBBitmapWord.sol/struct.JBBitmapWord.md +19 -0
  38. package/docs/src/src/structs/JBDeploy721TiersHookConfig.sol/struct.JBDeploy721TiersHookConfig.md +34 -0
  39. package/docs/src/src/structs/JBLaunchProjectConfig.sol/struct.JBLaunchProjectConfig.md +23 -0
  40. package/docs/src/src/structs/JBLaunchRulesetsConfig.sol/struct.JBLaunchRulesetsConfig.md +22 -0
  41. package/docs/src/src/structs/JBPayDataHookRulesetConfig.sol/struct.JBPayDataHookRulesetConfig.md +51 -0
  42. package/docs/src/src/structs/JBPayDataHookRulesetMetadata.sol/struct.JBPayDataHookRulesetMetadata.md +66 -0
  43. package/docs/src/src/structs/JBQueueRulesetsConfig.sol/struct.JBQueueRulesetsConfig.md +21 -0
  44. package/docs/src/src/structs/JBStored721Tier.sol/struct.JBStored721Tier.md +42 -0
  45. package/docs/src/src/structs/README.md +18 -0
  46. package/foundry.lock +11 -0
  47. package/foundry.toml +22 -0
  48. package/package.json +31 -0
  49. package/remappings.txt +1 -0
  50. package/script/Deploy.s.sol +140 -0
  51. package/script/helpers/Hook721DeploymentLib.sol +81 -0
  52. package/slither-ci.config.json +10 -0
  53. package/sphinx.lock +476 -0
  54. package/src/JB721TiersHook.sol +765 -0
  55. package/src/JB721TiersHookDeployer.sol +114 -0
  56. package/src/JB721TiersHookProjectDeployer.sol +413 -0
  57. package/src/JB721TiersHookStore.sol +1195 -0
  58. package/src/abstract/ERC721.sol +484 -0
  59. package/src/abstract/JB721Hook.sol +279 -0
  60. package/src/interfaces/IJB721Hook.sol +21 -0
  61. package/src/interfaces/IJB721TiersHook.sol +135 -0
  62. package/src/interfaces/IJB721TiersHookDeployer.sol +22 -0
  63. package/src/interfaces/IJB721TiersHookProjectDeployer.sol +76 -0
  64. package/src/interfaces/IJB721TiersHookStore.sol +220 -0
  65. package/src/interfaces/IJB721TokenUriResolver.sol +10 -0
  66. package/src/libraries/JB721Constants.sol +7 -0
  67. package/src/libraries/JB721TiersRulesetMetadataResolver.sol +44 -0
  68. package/src/libraries/JBBitmap.sol +57 -0
  69. package/src/libraries/JBIpfsDecoder.sol +95 -0
  70. package/src/structs/JB721InitTiersConfig.sol +20 -0
  71. package/src/structs/JB721Tier.sol +39 -0
  72. package/src/structs/JB721TierConfig.sol +40 -0
  73. package/src/structs/JB721TiersHookFlags.sol +17 -0
  74. package/src/structs/JB721TiersMintReservesConfig.sol +9 -0
  75. package/src/structs/JB721TiersRulesetMetadata.sol +12 -0
  76. package/src/structs/JB721TiersSetDiscountPercentConfig.sol +9 -0
  77. package/src/structs/JBBitmapWord.sol +11 -0
  78. package/src/structs/JBDeploy721TiersHookConfig.sol +25 -0
  79. package/src/structs/JBLaunchProjectConfig.sol +18 -0
  80. package/src/structs/JBLaunchRulesetsConfig.sol +17 -0
  81. package/src/structs/JBPayDataHookRulesetConfig.sol +44 -0
  82. package/src/structs/JBPayDataHookRulesetMetadata.sol +46 -0
  83. package/src/structs/JBQueueRulesetsConfig.sol +13 -0
  84. package/src/structs/JBStored721Tier.sol +24 -0
  85. package/test/721HookAttacks.t.sol +396 -0
  86. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +944 -0
  87. package/test/invariants/TierLifecycleInvariant.t.sol +187 -0
  88. package/test/invariants/TieredHookStoreInvariant.t.sol +81 -0
  89. package/test/invariants/handlers/TierLifecycleHandler.sol +262 -0
  90. package/test/invariants/handlers/TierStoreHandler.sol +155 -0
  91. package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +141 -0
  92. package/test/unit/JBBitmap.t.sol +169 -0
  93. package/test/unit/JBIpfsDecoder.t.sol +131 -0
  94. package/test/unit/M6_TierSupplyCheck.t.sol +220 -0
  95. package/test/unit/adjustTier_Unit.t.sol +1740 -0
  96. package/test/unit/deployer_Unit.t.sol +103 -0
  97. package/test/unit/getters_constructor_Unit.t.sol +548 -0
  98. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +443 -0
  99. package/test/unit/pay_Unit.t.sol +1537 -0
  100. package/test/unit/redeem_Unit.t.sol +459 -0
@@ -0,0 +1,17 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
5
+
6
+ import {JBPayDataHookRulesetConfig} from "./JBPayDataHookRulesetConfig.sol";
7
+
8
+ /// @custom:member projectId The ID of the project to launch rulesets for.
9
+ /// @custom:member rulesetConfigurations The ruleset configurations to queue.
10
+ /// @custom:member terminalConfigurations The terminal configurations to add for the project.
11
+ /// @custom:member memo A memo to pass along to the emitted event.
12
+ struct JBLaunchRulesetsConfig {
13
+ uint56 projectId;
14
+ JBPayDataHookRulesetConfig[] rulesetConfigurations;
15
+ JBTerminalConfig[] terminalConfigurations;
16
+ string memo;
17
+ }
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
5
+ import {JBFundAccessLimitGroup} from "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
6
+ import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
7
+
8
+ import {JBPayDataHookRulesetMetadata} from "./JBPayDataHookRulesetMetadata.sol";
9
+
10
+ /// @custom:member mustStartAtOrAfter The earliest time the ruleset can start.
11
+ /// @custom:member duration The number of seconds the ruleset lasts for, after which a new ruleset will start. A
12
+ /// duration of 0 means that the ruleset will stay active until the project owner explicitly issues a reconfiguration,
13
+ /// at which point a new ruleset will immediately start with the updated properties. If the duration is greater than 0,
14
+ /// a project owner cannot make changes to a ruleset's parameters while it is active – any proposed changes will apply
15
+ /// to the subsequent ruleset. If no changes are proposed, a ruleset rolls over to another one with the same properties
16
+ /// but new `start` timestamp and a decayed `weight`.
17
+ /// @custom:member weight A fixed point number with 18 decimals that contracts can use to base arbitrary calculations
18
+ /// on. For example, payment terminals can use this to determine how many tokens should be minted when a payment is
19
+ /// received.
20
+ /// @custom:member weightCutPercent A percent by how much the `weight` of the subsequent ruleset should be reduced, if
21
+ /// the
22
+ /// project owner hasn't queued the subsequent ruleset with an explicit `weight`. If it's 0, each ruleset will have
23
+ /// equal weight. If the number is 90%, the next ruleset will have a 10% smaller weight. This weight is out of
24
+ /// `JBConstants.MAX_WEIGHT_CUT_PERCENT`.
25
+ /// @custom:member approvalHook An address of a contract that says whether a proposed ruleset should be accepted or
26
+ /// rejected. It
27
+ /// can be used to create rules around how a project owner can change ruleset parameters over time.
28
+ /// @custom:member metadata Metadata specifying the controller-specific parameters that a ruleset can have. These
29
+ /// properties cannot change until the next ruleset starts.
30
+ /// @custom:member splitGroups An array of splits to use for any number of groups while the ruleset is active.
31
+ /// @custom:member fundAccessLimitGroups An array of structs which dictate the amount of funds a project can access from
32
+ /// its balance in each payment terminal while the ruleset is active. Amounts are fixed point numbers using the same
33
+ /// number of decimals as the corresponding terminal. The `payoutLimit` and `surplusAllowance` parameters must fit in
34
+ /// a `uint232`.
35
+ struct JBPayDataHookRulesetConfig {
36
+ uint48 mustStartAtOrAfter;
37
+ uint32 duration;
38
+ uint112 weight;
39
+ uint32 weightCutPercent;
40
+ IJBRulesetApprovalHook approvalHook;
41
+ JBPayDataHookRulesetMetadata metadata;
42
+ JBSplitGroup[] splitGroups;
43
+ JBFundAccessLimitGroup[] fundAccessLimitGroups;
44
+ }
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ /// @custom:member reservedPercent The reserved percent of the ruleset. This number is a percentage calculated out of
5
+ /// `JBConstants.MAX_RESERVED_PERCENT`.
6
+ /// @custom:member cashOutTaxRate The cash out tax rate of the ruleset. This number is a percentage calculated out of
7
+ /// `JBConstants.MAX_CASH_OUT_TAX_RATE`.
8
+ /// @custom:member baseCurrency The currency on which to base the ruleset's weight.
9
+ /// @custom:member pausePay A flag indicating if the pay functionality should be paused during the ruleset.
10
+ /// @custom:member pauseCreditTransfers A flag indicating if the project token transfer functionality should be paused
11
+ /// during the funding cycle.
12
+ /// @custom:member allowOwnerMinting A flag indicating if the project owner or an operator with the `MINT_TOKENS`
13
+ /// permission from the owner should be allowed to mint project tokens on demand during this ruleset.
14
+ /// @custom:member allowTerminalMigration A flag indicating if migrating terminals should be allowed during this
15
+ /// ruleset.
16
+ /// @custom:member allowSetTerminals A flag indicating if a project's terminals can be added or removed.
17
+ /// @custom:member allowSetController A flag indicating if a project's controller can be changed.
18
+ /// @custom:member allowAddAccountingContext A flag indicating if a project can add new accounting contexts for its
19
+ /// terminals to use.
20
+ /// @custom:member allowAddPriceFeed A flag indicating if a project can add new price feeds to calculate exchange rates
21
+ /// between its tokens.
22
+ /// @custom:member holdFees A flag indicating if fees should be held during this ruleset.
23
+ /// @custom:member useTotalSurplusForCashOut A flag indicating if cash outs should use the project's balance held
24
+ /// in all terminals instead of the project's local terminal balance from which the cash out is being fulfilled.
25
+ /// @custom:member useDataHookForCashOuts A flag indicating if the data hook should be used for cash out transactions
26
+ /// during
27
+ /// this ruleset.
28
+ /// @custom:member metadata Metadata of the metadata, up to uint8 in size.
29
+ struct JBPayDataHookRulesetMetadata {
30
+ uint16 reservedPercent;
31
+ uint16 cashOutTaxRate;
32
+ uint32 baseCurrency;
33
+ bool pausePay;
34
+ bool pauseCreditTransfers;
35
+ bool allowOwnerMinting;
36
+ bool allowTerminalMigration;
37
+ bool allowSetTerminals;
38
+ bool allowSetController;
39
+ bool allowAddAccountingContext;
40
+ bool allowAddPriceFeed;
41
+ bool ownerMustSendPayouts;
42
+ bool holdFees;
43
+ bool useTotalSurplusForCashOuts;
44
+ bool useDataHookForCashOut;
45
+ uint16 metadata;
46
+ }
@@ -0,0 +1,13 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ import {JBPayDataHookRulesetConfig} from "./JBPayDataHookRulesetConfig.sol";
5
+
6
+ /// @custom:member projectId The ID of the project to queue rulesets for.
7
+ /// @custom:member rulesetConfigurations The ruleset configurations to queue.
8
+ /// @custom:member memo A memo to pass along to the emitted event.
9
+ struct JBQueueRulesetsConfig {
10
+ uint56 projectId;
11
+ JBPayDataHookRulesetConfig[] rulesetConfigurations;
12
+ string memo;
13
+ }
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ /// @custom:member price The price to buy an NFT in this tier, in terms of the currency in its `JBInitTiersConfig`.
5
+ /// @custom:member remainingSupply The remaining number of NFTs which can be minted from this tier.
6
+ /// @custom:member initialSupply The total number of NFTs which can be minted from this tier.
7
+ /// @custom:member votingUnits The number of votes that each NFT in this tier gets.
8
+ /// @custom:member category The category that NFTs in this tier belongs to. Used to group NFT tiers.
9
+ /// @custom:member discountPercent The discount that should be applied to the tier.
10
+ /// @custom:member reserveFrequency The frequency at which an extra NFT is minted for the `reserveBeneficiary` from this
11
+ /// tier. With a `reserveFrequency` of 5, an extra NFT will be minted for the `reserveBeneficiary` for every 5 NFTs
12
+ /// purchased.
13
+ /// @custom:member packedBools A packed uint8 containing boolean flags: bit 0 = allowOwnerMint, bit 1 =
14
+ /// transfersPausable, bit 2 = useVotingUnits, bit 3 = cannotBeRemoved, bit 4 = cannotIncreaseDiscountPercent.
15
+ struct JBStored721Tier {
16
+ uint104 price;
17
+ uint32 remainingSupply;
18
+ uint32 initialSupply;
19
+ uint32 votingUnits;
20
+ uint24 category;
21
+ uint8 discountPercent;
22
+ uint16 reserveFrequency;
23
+ uint8 packedBools;
24
+ }
@@ -0,0 +1,396 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.23;
3
+
4
+ import "./utils/UnitTestSetup.sol";
5
+
6
+ /// @title 721HookAttacks
7
+ /// @notice Adversarial security tests for JB721TiersHook and JB721TiersHookStore.
8
+ contract NFTHookAttacks is UnitTestSetup {
9
+ using stdStorage for StdStorage;
10
+
11
+ // =========================================================================
12
+ // Helpers
13
+ // =========================================================================
14
+
15
+ /// @dev Mock the directory to accept `mockTerminalAddress` as a terminal for `projectId`.
16
+ function _mockTerminalAuth() internal {
17
+ mockAndExpect(
18
+ mockJBDirectory,
19
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
20
+ abi.encode(true)
21
+ );
22
+ }
23
+
24
+ /// @dev Create a pay context that requests minting specific tier IDs.
25
+ function _buildPayContext(
26
+ address targetHook,
27
+ uint256 value,
28
+ uint16[] memory tierIds
29
+ )
30
+ internal
31
+ view
32
+ returns (JBAfterPayRecordedContext memory)
33
+ {
34
+ bytes[] memory data = new bytes[](1);
35
+ data[0] = abi.encode(false, tierIds);
36
+ bytes4[] memory ids = new bytes4[](1);
37
+ ids[0] = metadataHelper.getId("pay", targetHook);
38
+ bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
39
+
40
+ return JBAfterPayRecordedContext({
41
+ payer: beneficiary,
42
+ projectId: projectId,
43
+ rulesetId: 0,
44
+ amount: JBTokenAmount({
45
+ token: JBConstants.NATIVE_TOKEN,
46
+ value: value,
47
+ decimals: 18,
48
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
49
+ }),
50
+ forwardedAmount: JBTokenAmount({
51
+ token: JBConstants.NATIVE_TOKEN,
52
+ value: 0,
53
+ decimals: 18,
54
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
55
+ }),
56
+ weight: 10 ** 18,
57
+ newlyIssuedTokenCount: 0,
58
+ beneficiary: beneficiary,
59
+ hookMetadata: bytes(""),
60
+ payerMetadata: hookMetadata
61
+ });
62
+ }
63
+
64
+ // =========================================================================
65
+ // Test 1: Zero-price tier — can an attacker mint for free?
66
+ // =========================================================================
67
+ /// @notice Add a tier with price=0 via adjustTiers. Verify the hook handles it correctly.
68
+ function test_zeroPriceTier_mintBehavior() public {
69
+ // Create hook with 1 default tier (price=10).
70
+ ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
71
+
72
+ // Add a zero-price tier via adjustTiers (tier ID 2).
73
+ JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
74
+ newTiers[0] = JB721TierConfig({
75
+ price: 0,
76
+ initialSupply: 100,
77
+ votingUnits: 0,
78
+ reserveFrequency: 0,
79
+ reserveBeneficiary: reserveBeneficiary,
80
+ encodedIPFSUri: tokenUris[0],
81
+ category: 2,
82
+ discountPercent: 0,
83
+ allowOwnerMint: false,
84
+ useReserveBeneficiaryAsDefault: false,
85
+ transfersPausable: false,
86
+ cannotBeRemoved: false,
87
+ cannotIncreaseDiscountPercent: false,
88
+ useVotingUnits: false
89
+ });
90
+
91
+ vm.prank(owner);
92
+ targetHook.adjustTiers(newTiers, new uint256[](0));
93
+
94
+ _mockTerminalAuth();
95
+
96
+ // Try to mint tier 2 (price=0) with 0 value.
97
+ uint16[] memory tierIds = new uint16[](1);
98
+ tierIds[0] = 2;
99
+
100
+ JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 0, tierIds);
101
+
102
+ vm.prank(mockTerminalAddress);
103
+ targetHook.afterPayRecordedWith(ctx);
104
+
105
+ // Verify the NFT was minted to the beneficiary.
106
+ assertEq(targetHook.balanceOf(beneficiary), 1, "Should mint 1 NFT at price 0");
107
+ }
108
+
109
+ // =========================================================================
110
+ // Test 2: Discount percent at maximum — effective price becomes 0
111
+ // =========================================================================
112
+ /// @notice Set discount to 100%, verify the effective price for the tier.
113
+ function test_maxDiscountPercent_effectivePrice() public {
114
+ defaultTierConfig.discountPercent = 0;
115
+ defaultTierConfig.cannotIncreaseDiscountPercent = false;
116
+
117
+ JB721TiersHook targetHook = _initHookDefaultTiers(1);
118
+
119
+ // Owner sets discount to 100%.
120
+ vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
121
+
122
+ vm.prank(owner);
123
+ targetHook.setDiscountPercentOf(1, 100);
124
+
125
+ // Read the tier and verify the discount was applied.
126
+ JB721Tier memory tier = store.tierOf(address(targetHook), 1, false);
127
+ assertEq(tier.discountPercent, 100, "Discount should be 100%");
128
+ }
129
+
130
+ // =========================================================================
131
+ // Test 3: cannotIncreaseDiscountPercent flag enforcement
132
+ // =========================================================================
133
+ /// @notice Try to increase discount when the flag forbids it.
134
+ function test_cannotIncreaseDiscountPercent_enforcement() public {
135
+ defaultTierConfig.discountPercent = 10;
136
+ defaultTierConfig.cannotIncreaseDiscountPercent = true;
137
+
138
+ JB721TiersHook targetHook = _initHookDefaultTiers(1);
139
+
140
+ vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
141
+
142
+ // Try to increase discount from 10 to 50 — should revert.
143
+ vm.prank(owner);
144
+ vm.expectRevert();
145
+ targetHook.setDiscountPercentOf(1, 50);
146
+
147
+ // Decreasing should still work.
148
+ vm.prank(owner);
149
+ targetHook.setDiscountPercentOf(1, 5);
150
+
151
+ JB721Tier memory tier = store.tierOf(address(targetHook), 1, false);
152
+ assertEq(tier.discountPercent, 5, "Discount decrease should work");
153
+ }
154
+
155
+ // =========================================================================
156
+ // Test 4: Reserve minting drain — high reserve frequency
157
+ // =========================================================================
158
+ /// @notice With reserveFrequency=1 (reserve on every mint), mint 5 paid NFTs
159
+ /// then call mintPendingReservesFor to drain reserves.
160
+ function test_reserveDrain_highFrequency() public {
161
+ defaultTierConfig.initialSupply = 100;
162
+ defaultTierConfig.reserveFrequency = 1; // Reserve 1 per 1 paid mint.
163
+
164
+ ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
165
+ IJB721TiersHookStore hookStore = targetHook.STORE();
166
+
167
+ _mockTerminalAuth();
168
+
169
+ // Mint 5 paid NFTs from tier 1 (price=10 each, so value=50).
170
+ uint16[] memory tierIds = new uint16[](5);
171
+ for (uint256 i; i < 5; i++) {
172
+ tierIds[i] = 1;
173
+ }
174
+
175
+ JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 50, tierIds);
176
+
177
+ vm.prank(mockTerminalAddress);
178
+ targetHook.afterPayRecordedWith(ctx);
179
+
180
+ assertEq(targetHook.balanceOf(beneficiary), 5, "5 paid NFTs minted");
181
+
182
+ // Pending reserves should be 5 (1 per paid mint with frequency=1).
183
+ // With frequency=1: reserveCount = nftsMinted / frequency = 5, plus 1 if remainder > 0.
184
+ // 5/1 = 5, remainder 0, so pending = 5+1 = 6? Actually the formula is:
185
+ // numberOfPendingReservesFor = (numberOfMints + frequency - 1) / frequency - processedReserves
186
+ // Let's just check what the store reports.
187
+ uint256 pending = hookStore.numberOfPendingReservesFor(address(targetHook), 1);
188
+ assertTrue(pending > 0, "Should have pending reserves");
189
+
190
+ // Mint all pending reserves.
191
+ vm.prank(owner);
192
+ targetHook.mintPendingReservesFor(1, pending);
193
+
194
+ // After minting, pending should be 0.
195
+ uint256 pendingAfter = hookStore.numberOfPendingReservesFor(address(targetHook), 1);
196
+ assertEq(pendingAfter, 0, "No pending reserves after minting");
197
+
198
+ // Try to mint more reserves — should revert (nothing pending).
199
+ vm.prank(owner);
200
+ vm.expectRevert();
201
+ targetHook.mintPendingReservesFor(1, 1);
202
+ }
203
+
204
+ // =========================================================================
205
+ // Test 5: Cash-out weight after tier removal
206
+ // =========================================================================
207
+ /// @notice Mint NFTs from a tier, then remove the tier. Verify that
208
+ /// totalCashOutWeight still accounts for the minted tokens.
209
+ function test_cashOutWeight_afterTierRemoval() public {
210
+ defaultTierConfig.initialSupply = 100;
211
+ defaultTierConfig.votingUnits = 10;
212
+ defaultTierConfig.useVotingUnits = true;
213
+
214
+ ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
215
+ IJB721TiersHookStore hookStore = targetHook.STORE();
216
+
217
+ _mockTerminalAuth();
218
+
219
+ // Mint 3 NFTs from tier 1 (price=10 each, value=30).
220
+ uint16[] memory tierIds = new uint16[](3);
221
+ tierIds[0] = 1;
222
+ tierIds[1] = 1;
223
+ tierIds[2] = 1;
224
+
225
+ JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 30, tierIds);
226
+
227
+ vm.prank(mockTerminalAddress);
228
+ targetHook.afterPayRecordedWith(ctx);
229
+
230
+ assertEq(targetHook.balanceOf(beneficiary), 3, "3 NFTs minted");
231
+
232
+ // Get total cash-out weight before removal (from the store).
233
+ uint256 weightBefore = hookStore.totalCashOutWeight(address(targetHook));
234
+
235
+ // Remove tier 1.
236
+ vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
237
+
238
+ uint256[] memory tierIdsToRemove = new uint256[](1);
239
+ tierIdsToRemove[0] = 1;
240
+
241
+ vm.prank(owner);
242
+ targetHook.adjustTiers(new JB721TierConfig[](0), tierIdsToRemove);
243
+
244
+ // Verify the tier is removed.
245
+ assertTrue(hookStore.isTierRemoved(address(targetHook), 1), "Tier should be removed");
246
+
247
+ // Total cash-out weight should still include the minted tokens.
248
+ uint256 weightAfter = hookStore.totalCashOutWeight(address(targetHook));
249
+ assertEq(weightAfter, weightBefore, "Cash-out weight should be preserved after removal");
250
+ }
251
+
252
+ // =========================================================================
253
+ // Test 6: Invalid tier ID in pay metadata — reverts when overspending prevented
254
+ // =========================================================================
255
+ /// @notice Pass a tier ID that doesn't exist. With preventOverspending=true, must revert.
256
+ function test_invalidTierIdInMetadata_reverts() public {
257
+ // Use preventOverspending=true so invalid tiers cause a revert.
258
+ JB721TiersHook targetHook = _initHookDefaultTiers(1, true);
259
+
260
+ _mockTerminalAuth();
261
+
262
+ // Try to mint tier 999 which doesn't exist.
263
+ uint16[] memory tierIds = new uint16[](1);
264
+ tierIds[0] = 999;
265
+
266
+ JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 1 ether, tierIds);
267
+
268
+ vm.prank(mockTerminalAddress);
269
+ vm.expectRevert();
270
+ targetHook.afterPayRecordedWith(ctx);
271
+ }
272
+
273
+ // =========================================================================
274
+ // Test 7: Duplicate tier IDs in pay metadata — mints multiple NFTs
275
+ // =========================================================================
276
+ /// @notice Pass the same tier ID multiple times. Should mint multiple NFTs
277
+ /// from that tier.
278
+ function test_duplicateTierIdsInMetadata_mintsMultiple() public {
279
+ defaultTierConfig.initialSupply = 100;
280
+
281
+ ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
282
+ IJB721TiersHookStore hookStore = targetHook.STORE();
283
+
284
+ _mockTerminalAuth();
285
+
286
+ // Mint 3 of the same tier (price=10 each, value=30).
287
+ uint16[] memory tierIds = new uint16[](3);
288
+ tierIds[0] = 1;
289
+ tierIds[1] = 1;
290
+ tierIds[2] = 1;
291
+
292
+ JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 30, tierIds);
293
+
294
+ vm.prank(mockTerminalAddress);
295
+ targetHook.afterPayRecordedWith(ctx);
296
+
297
+ assertEq(targetHook.balanceOf(beneficiary), 3, "3 NFTs minted from same tier");
298
+
299
+ // Verify remaining supply decreased.
300
+ JB721Tier memory tier = hookStore.tierOf(address(targetHook), 1, false);
301
+ assertEq(tier.remainingSupply, 97, "Supply should decrease by 3");
302
+ }
303
+
304
+ // =========================================================================
305
+ // Test 8: Supply exhaustion — no additional NFTs minted after supply drained
306
+ // =========================================================================
307
+ /// @notice Mint the entire supply of a tier, then verify no more can be minted.
308
+ function test_supplyExhaustion_noOvermint() public {
309
+ defaultTierConfig.initialSupply = 3; // Only 3 available.
310
+ defaultTierConfig.reserveFrequency = 0; // No reserves to keep it simple.
311
+
312
+ ForTest_JB721TiersHook targetHook = _initializeForTestHook(1);
313
+ IJB721TiersHookStore hookStore = targetHook.STORE();
314
+
315
+ _mockTerminalAuth();
316
+
317
+ // Mint all 3 (price=10 each, value=30).
318
+ uint16[] memory tierIds = new uint16[](3);
319
+ tierIds[0] = 1;
320
+ tierIds[1] = 1;
321
+ tierIds[2] = 1;
322
+
323
+ JBAfterPayRecordedContext memory ctx = _buildPayContext(address(targetHook), 30, tierIds);
324
+
325
+ vm.prank(mockTerminalAddress);
326
+ targetHook.afterPayRecordedWith(ctx);
327
+
328
+ assertEq(targetHook.balanceOf(beneficiary), 3, "All 3 minted");
329
+
330
+ // Verify supply is exhausted.
331
+ JB721Tier memory tier = hookStore.tierOf(address(targetHook), 1, false);
332
+ assertEq(tier.remainingSupply, 0, "No remaining supply");
333
+
334
+ // Try to mint one more — store enforces supply limit and reverts.
335
+ uint16[] memory oneMore = new uint16[](1);
336
+ oneMore[0] = 1;
337
+
338
+ JBAfterPayRecordedContext memory ctx2 = _buildPayContext(address(targetHook), 10, oneMore);
339
+
340
+ vm.prank(mockTerminalAddress);
341
+ vm.expectRevert();
342
+ targetHook.afterPayRecordedWith(ctx2);
343
+ }
344
+
345
+ // =========================================================================
346
+ // Test 9: adjustTiers without permission — must revert
347
+ // =========================================================================
348
+ /// @notice Non-owner without ADJUST_721_TIERS permission tries to add/remove tiers.
349
+ function test_adjustTiers_noPermission_reverts() public {
350
+ JB721TiersHook targetHook = _initHookDefaultTiers(1);
351
+
352
+ // Mock permissions to return false.
353
+ vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(false));
354
+
355
+ address attacker = makeAddr("attacker");
356
+
357
+ // Try to add a new tier.
358
+ JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
359
+ newTiers[0] = JB721TierConfig({
360
+ price: 1,
361
+ initialSupply: type(uint32).max,
362
+ votingUnits: 0,
363
+ reserveFrequency: 0,
364
+ reserveBeneficiary: attacker,
365
+ encodedIPFSUri: tokenUris[0],
366
+ category: 1,
367
+ discountPercent: 0,
368
+ allowOwnerMint: true,
369
+ useReserveBeneficiaryAsDefault: false,
370
+ transfersPausable: false,
371
+ cannotBeRemoved: false,
372
+ cannotIncreaseDiscountPercent: false,
373
+ useVotingUnits: false
374
+ });
375
+
376
+ vm.prank(attacker);
377
+ vm.expectRevert();
378
+ targetHook.adjustTiers(newTiers, new uint256[](0));
379
+ }
380
+
381
+ // =========================================================================
382
+ // Test 10: Tier with max supply — verify no overflow
383
+ // =========================================================================
384
+ /// @notice Add a tier with initialSupply = 999_999_999 (store maximum).
385
+ /// Verify it's created correctly and doesn't overflow.
386
+ function test_maxSupplyTier_noOverflow() public {
387
+ defaultTierConfig.initialSupply = 999_999_999; // Store maximum
388
+
389
+ JB721TiersHook targetHook = _initHookDefaultTiers(1);
390
+
391
+ // Read the tier to verify the supply was stored correctly.
392
+ JB721Tier memory tier = store.tierOf(address(targetHook), 1, false);
393
+ assertEq(tier.initialSupply, 999_999_999, "Initial supply should be 999_999_999");
394
+ assertEq(tier.remainingSupply, 999_999_999, "Remaining supply should be 999_999_999");
395
+ }
396
+ }