@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,187 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.23;
3
+
4
+ import "forge-std/StdInvariant.sol";
5
+ import "../utils/UnitTestSetup.sol";
6
+ import "./handlers/TierLifecycleHandler.sol";
7
+
8
+ /// @title TierLifecycleInvariant
9
+ /// @notice State machine fuzzing for 721 tier lifecycle.
10
+ /// 6 invariants covering supply, cash out weight, credits, reserves, removal, and discounts.
11
+ contract TierLifecycleInvariant_Local is StdInvariant, UnitTestSetup {
12
+ TierLifecycleHandler public handler;
13
+
14
+ function setUp() public override {
15
+ super.setUp();
16
+
17
+ handler = new TierLifecycleHandler(hook, store, owner, mockJBController);
18
+
19
+ bytes4[] memory selectors = new bytes4[](10);
20
+ selectors[0] = TierLifecycleHandler.payAndMintNFT.selector;
21
+ selectors[1] = TierLifecycleHandler.cashOutNFT.selector;
22
+ selectors[2] = TierLifecycleHandler.addTier.selector;
23
+ selectors[3] = TierLifecycleHandler.removeTier.selector;
24
+ selectors[4] = TierLifecycleHandler.mintReserves.selector;
25
+ selectors[5] = TierLifecycleHandler.setDiscount.selector;
26
+ selectors[6] = TierLifecycleHandler.ownerMint.selector;
27
+ selectors[7] = TierLifecycleHandler.advanceTime.selector;
28
+ // Double-weight common operations
29
+ selectors[8] = TierLifecycleHandler.payAndMintNFT.selector;
30
+ selectors[9] = TierLifecycleHandler.payAndMintNFT.selector;
31
+
32
+ targetSelector(FuzzSelector({addr: address(handler), selectors: selectors}));
33
+ targetContract(address(handler));
34
+ }
35
+
36
+ // =========================================================================
37
+ // INV-721-1: Per-tier supply accounting
38
+ // =========================================================================
39
+ /// @notice For each active tier: remaining + minted == initialSupply.
40
+ /// minted = initialSupply - remaining (from store), should match ghost tracking.
41
+ function invariant_721_1_perTierSupplyAccounting() public {
42
+ uint256 maxTierId = store.maxTierIdOf(address(hook));
43
+
44
+ for (uint256 tierId = 1; tierId <= maxTierId; tierId++) {
45
+ // Get tier info
46
+ uint256[] memory categories = new uint256[](0);
47
+ JB721Tier[] memory allTiers = store.tiersOf(address(hook), categories, false, 0, 100);
48
+
49
+ for (uint256 i = 0; i < allTiers.length; i++) {
50
+ if (allTiers[i].id == tierId) {
51
+ uint256 initial = allTiers[i].initialSupply;
52
+ uint256 remaining = allTiers[i].remainingSupply;
53
+ uint256 burned = store.numberOfBurnedFor(address(hook), tierId);
54
+
55
+ // remaining + (minted including burned) should relate to initial
56
+ // minted = initial - remaining (total ever minted)
57
+ uint256 totalMinted = initial - remaining;
58
+ // totalMinted >= burned (can't burn more than minted)
59
+ assertGe(totalMinted, burned, "INV-721-1: Cannot burn more NFTs than were minted from tier");
60
+
61
+ // Outstanding = totalMinted - burned
62
+ uint256 outstanding = totalMinted - burned;
63
+
64
+ // Verify: remaining + outstanding + burned == initial
65
+ assertEq(remaining + outstanding + burned, initial, "INV-721-1: Supply accounting mismatch");
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ // =========================================================================
73
+ // INV-721-2: Total cash out weight consistency
74
+ // =========================================================================
75
+ /// @notice totalCashOutWeight should equal sum(tier.price * outstanding) for all tiers.
76
+ function invariant_721_2_totalCashOutWeightConsistency() public {
77
+ uint256 totalWeight = store.totalCashOutWeight(address(hook));
78
+
79
+ uint256 maxTierId = store.maxTierIdOf(address(hook));
80
+ uint256 computedWeight = 0;
81
+
82
+ uint256[] memory categories = new uint256[](0);
83
+ JB721Tier[] memory allTiers = store.tiersOf(address(hook), categories, false, 0, 100);
84
+
85
+ for (uint256 i = 0; i < allTiers.length; i++) {
86
+ uint256 tierId = allTiers[i].id;
87
+ uint256 initial = allTiers[i].initialSupply;
88
+ uint256 remaining = allTiers[i].remainingSupply;
89
+ uint256 burned = store.numberOfBurnedFor(address(hook), tierId);
90
+ uint256 price = allTiers[i].price;
91
+
92
+ // Outstanding = minted - burned
93
+ uint256 minted = initial - remaining;
94
+ uint256 outstanding = minted > burned ? minted - burned : 0;
95
+
96
+ // Pending reserves are also counted in totalCashOutWeight
97
+ // (included in the store's calculation)
98
+ computedWeight += price * outstanding;
99
+ }
100
+
101
+ // totalWeight >= computedWeight (it also includes pending reserves)
102
+ assertGe(totalWeight, computedWeight, "INV-721-2: totalCashOutWeight must >= sum(price * outstanding)");
103
+ }
104
+
105
+ // =========================================================================
106
+ // INV-721-3: Pay credits non-negative
107
+ // =========================================================================
108
+ /// @notice Pay credits for each actor should be >= 0 (trivially true for uint,
109
+ /// but verifies no underflow/corruption).
110
+ function invariant_721_3_payCreditsNonNegative() public {
111
+ for (uint256 i = 0; i < handler.NUM_ACTORS(); i++) {
112
+ address actor = handler.getActor(i);
113
+ uint256 credits = hook.payCreditsOf(actor);
114
+ // uint256 is always >= 0, but this validates the slot isn't corrupted
115
+ assertGe(credits, 0, "INV-721-3: Pay credits should be non-negative");
116
+ }
117
+ }
118
+
119
+ // =========================================================================
120
+ // INV-721-4: Reserve mints bounded by frequency
121
+ // =========================================================================
122
+ /// @notice For each tier with reserve frequency > 0:
123
+ /// reservesMinted <= ceil(totalMinted / reserveFrequency).
124
+ function invariant_721_4_reserveMintsBounded() public {
125
+ uint256[] memory categories = new uint256[](0);
126
+ JB721Tier[] memory allTiers = store.tiersOf(address(hook), categories, false, 0, 100);
127
+
128
+ for (uint256 i = 0; i < allTiers.length; i++) {
129
+ uint256 tierId = allTiers[i].id;
130
+ uint16 reserveFreq = allTiers[i].reserveFrequency;
131
+
132
+ if (reserveFreq == 0) continue;
133
+
134
+ uint256 reservesMinted = store.numberOfReservesMintedFor(address(hook), tierId);
135
+ uint256 initial = allTiers[i].initialSupply;
136
+ uint256 remaining = allTiers[i].remainingSupply;
137
+ uint256 totalMinted = initial - remaining;
138
+
139
+ // Non-reserve mints = totalMinted - reservesMinted
140
+ uint256 nonReserveMints = totalMinted > reservesMinted ? totalMinted - reservesMinted : 0;
141
+
142
+ // Max allowed reserves = ceil(nonReserveMints / reserveFrequency)
143
+ uint256 maxReserves = 0;
144
+ if (nonReserveMints > 0) {
145
+ maxReserves = (nonReserveMints + reserveFreq - 1) / reserveFreq;
146
+ }
147
+
148
+ assertLe(reservesMinted, maxReserves, "INV-721-4: Reserve mints exceed allowed maximum");
149
+ }
150
+ }
151
+
152
+ // =========================================================================
153
+ // INV-721-5: Removed tiers tracked correctly
154
+ // =========================================================================
155
+ /// @notice Removed tiers should not appear in tiersOf() listing.
156
+ function invariant_721_5_removedTiersExcluded() public {
157
+ uint256[] memory categories = new uint256[](0);
158
+ JB721Tier[] memory activeTiers = store.tiersOf(address(hook), categories, false, 0, 200);
159
+
160
+ for (uint256 i = 0; i < activeTiers.length; i++) {
161
+ // If handler tracked this tier as removed, it should NOT appear in active list
162
+ assertFalse(
163
+ handler.ghost_tierRemoved(activeTiers[i].id),
164
+ "INV-721-5: Removed tier should not appear in active tiers list"
165
+ );
166
+ }
167
+ }
168
+
169
+ // =========================================================================
170
+ // INV-721-6: Cash out weight bounded after discount
171
+ // =========================================================================
172
+ /// @notice After setDiscount, the per-token cash out weight should be <= original price.
173
+ function invariant_721_6_cashOutWeightBoundedByPrice() public {
174
+ uint256[] memory categories = new uint256[](0);
175
+ JB721Tier[] memory allTiers = store.tiersOf(address(hook), categories, false, 0, 100);
176
+
177
+ for (uint256 i = 0; i < allTiers.length; i++) {
178
+ uint256 tierId = allTiers[i].id;
179
+ uint256 price = allTiers[i].price;
180
+
181
+ // Cash out weight per token for this tier is just `price` (from the store)
182
+ // The discount only affects the mint price, not the cash out weight
183
+ // But verify the store returns a non-corrupt price
184
+ assertGt(price, 0, "INV-721-6: Tier price should be > 0 for active tiers");
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,81 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.23;
3
+
4
+ import "forge-std/Test.sol";
5
+
6
+ import {JB721TiersHookStore} from "../../src/JB721TiersHookStore.sol";
7
+ import {JB721TierConfig} from "../../src/structs/JB721TierConfig.sol";
8
+ import {JBStored721Tier} from "../../src/structs/JBStored721Tier.sol";
9
+ import {JB721TiersHookFlags} from "../../src/structs/JB721TiersHookFlags.sol";
10
+ import {JBBitmapWord} from "../../src/structs/JBBitmapWord.sol";
11
+ import {JB721Tier} from "../../src/structs/JB721Tier.sol";
12
+ import {TierStoreHandler} from "./handlers/TierStoreHandler.sol";
13
+
14
+ /// @notice Invariant tests for `JB721TiersHookStore` tier supply tracking.
15
+ contract TestTieredHookStoreInvariant is Test {
16
+ JB721TiersHookStore store;
17
+ TierStoreHandler handler;
18
+
19
+ function setUp() public {
20
+ store = new JB721TiersHookStore();
21
+ handler = new TierStoreHandler(store);
22
+ targetContract(address(handler));
23
+
24
+ // Exclude internal wrapper functions from fuzzing — they bypass handler safety checks.
25
+ bytes4[] memory selectors = new bytes4[](4);
26
+ selectors[0] = TierStoreHandler._doAddTiers.selector;
27
+ selectors[1] = TierStoreHandler._doRemoveTiers.selector;
28
+ selectors[2] = TierStoreHandler._doMint.selector;
29
+ selectors[3] = TierStoreHandler._doBurn.selector;
30
+
31
+ targetSelector(FuzzSelector({addr: address(handler), selectors: _handlerSelectors()}));
32
+ }
33
+
34
+ function _handlerSelectors() internal pure returns (bytes4[] memory selectors) {
35
+ selectors = new bytes4[](4);
36
+ selectors[0] = TierStoreHandler.addTier.selector;
37
+ selectors[1] = TierStoreHandler.removeTier.selector;
38
+ selectors[2] = TierStoreHandler.mint.selector;
39
+ selectors[3] = TierStoreHandler.burn.selector;
40
+ }
41
+
42
+ /// @notice INV-721-1: For any tier, remaining + burned <= initial supply.
43
+ function invariant_supplyConservation() public {
44
+ address hook = handler.HOOK();
45
+ uint256 maxTier = store.maxTierIdOf(hook);
46
+
47
+ for (uint256 i = 1; i <= maxTier; i++) {
48
+ JB721Tier memory tier = store.tierOf(hook, i, false);
49
+ if (tier.initialSupply == 0) continue; // Tier doesn't exist.
50
+
51
+ uint256 burned = store.numberOfBurnedFor(hook, i);
52
+ // remaining + burned <= initial (remaining = initial - minted, but minted >= burned)
53
+ assertTrue(
54
+ tier.remainingSupply + burned <= tier.initialSupply, "remaining + burned must not exceed initial supply"
55
+ );
56
+ }
57
+ }
58
+
59
+ /// @notice INV-721-2: Reserve mints never exceed the proportional reserve allocation.
60
+ function invariant_reserveMintBounds() public {
61
+ address hook = handler.HOOK();
62
+ uint256 maxTier = store.maxTierIdOf(hook);
63
+
64
+ for (uint256 i = 1; i <= maxTier; i++) {
65
+ JB721Tier memory tier = store.tierOf(hook, i, false);
66
+ if (tier.initialSupply == 0) continue;
67
+
68
+ uint256 reservesMinted = store.numberOfReservesMintedFor(hook, i);
69
+ if (tier.reserveFrequency == 0) {
70
+ assertEq(reservesMinted, 0, "no reserves without reserveFrequency");
71
+ }
72
+ }
73
+ }
74
+
75
+ /// @notice INV-721-3: maxTierIdOf is monotonically increasing (never decreases).
76
+ function invariant_maxTierIdMonotonic() public {
77
+ address hook = handler.HOOK();
78
+ uint256 currentMax = store.maxTierIdOf(hook);
79
+ assertTrue(currentMax >= handler.lowestMaxTierIdSeen(), "maxTierIdOf should never decrease");
80
+ }
81
+ }
@@ -0,0 +1,262 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.23;
3
+
4
+ import "forge-std/Test.sol";
5
+ import "../../../src/JB721TiersHook.sol";
6
+ import "../../../src/JB721TiersHookStore.sol";
7
+ import "../../../src/structs/JB721TierConfig.sol";
8
+ import "../../../src/structs/JB721Tier.sol";
9
+ import "../../../src/interfaces/IJB721TiersHookStore.sol";
10
+ import "@bananapus/core-v6/src/libraries/JBConstants.sol";
11
+ import "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
12
+ import "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
13
+
14
+ /// @title TierLifecycleHandler
15
+ /// @notice Handler for 721 tier lifecycle invariant testing.
16
+ /// 8 operations: payAndMintNFT, cashOutNFT, addTier, removeTier,
17
+ /// mintReserves, setDiscount, ownerMint, advanceTime.
18
+ contract TierLifecycleHandler is Test {
19
+ JB721TiersHook public hook;
20
+ JB721TiersHookStore public store;
21
+ address public hookAddress;
22
+ address public owner;
23
+ address public mockController;
24
+
25
+ uint256 public constant PROJECT_ID = 69;
26
+ uint256 public constant NUM_ACTORS = 5;
27
+ address[] public actors;
28
+
29
+ // Ghost variables for supply tracking
30
+ mapping(uint256 => uint256) public ghost_mintedPerTier; // tierId => minted count
31
+ mapping(uint256 => uint256) public ghost_burnedPerTier; // tierId => burned count
32
+ mapping(uint256 => uint256) public ghost_reservesMintedPerTier; // tierId => reserves minted
33
+ uint256 public ghost_totalPayCredits;
34
+ mapping(address => uint256) public ghost_actorCredits; // actor => credit balance
35
+ uint256 public ghost_tiersAdded;
36
+ uint256 public ghost_tiersRemoved;
37
+
38
+ // Track token IDs per actor for cash outs
39
+ mapping(address => uint256[]) internal _actorTokenIds;
40
+
41
+ // Track removed tier IDs
42
+ mapping(uint256 => bool) public ghost_tierRemoved;
43
+
44
+ // Operation counters
45
+ uint256 public callCount_payAndMint;
46
+ uint256 public callCount_cashOut;
47
+ uint256 public callCount_addTier;
48
+ uint256 public callCount_removeTier;
49
+ uint256 public callCount_mintReserves;
50
+ uint256 public callCount_setDiscount;
51
+ uint256 public callCount_ownerMint;
52
+ uint256 public callCount_advanceTime;
53
+
54
+ constructor(JB721TiersHook _hook, JB721TiersHookStore _store, address _owner, address _mockController) {
55
+ hook = _hook;
56
+ store = _store;
57
+ hookAddress = address(_hook);
58
+ owner = _owner;
59
+ mockController = _mockController;
60
+
61
+ for (uint256 i = 0; i < NUM_ACTORS; i++) {
62
+ address actor = address(uint160(0x6000 + i));
63
+ actors.push(actor);
64
+ }
65
+ }
66
+
67
+ function _getActor(uint256 seed) internal view returns (address) {
68
+ return actors[seed % actors.length];
69
+ }
70
+
71
+ /// @notice Simulate a payment that mints NFTs by directly calling store.recordMint.
72
+ function payAndMintNFT(uint256 seed) external {
73
+ address actor = _getActor(seed);
74
+
75
+ // Get max tier ID to pick a valid tier
76
+ uint256 maxTierId = store.maxTierIdOf(hookAddress);
77
+ if (maxTierId == 0) return;
78
+
79
+ // Pick a tier (1-indexed)
80
+ uint256 tierId = (seed % maxTierId) + 1;
81
+
82
+ // Check if tier is removed
83
+ if (ghost_tierRemoved[tierId]) return;
84
+
85
+ // Get tier info
86
+ uint256[] memory categories = new uint256[](0);
87
+ JB721Tier[] memory tierInfo = store.tiersOf(hookAddress, categories, false, 0, 100);
88
+
89
+ // Find the target tier
90
+ uint104 tierPrice = 0;
91
+ for (uint256 i = 0; i < tierInfo.length; i++) {
92
+ if (tierInfo[i].id == tierId) {
93
+ tierPrice = tierInfo[i].price;
94
+ break;
95
+ }
96
+ }
97
+ if (tierPrice == 0) return;
98
+
99
+ // Call recordMint as the hook
100
+ uint16[] memory tierIds = new uint16[](1);
101
+ tierIds[0] = uint16(tierId);
102
+
103
+ vm.prank(hookAddress);
104
+ try store.recordMint(tierPrice, tierIds, false) returns (uint256[] memory tokenIds, uint256) {
105
+ // Track minted tokens
106
+ for (uint256 i = 0; i < tokenIds.length; i++) {
107
+ _actorTokenIds[actor].push(tokenIds[i]);
108
+ }
109
+ ghost_mintedPerTier[tierId]++;
110
+ callCount_payAndMint++;
111
+ } catch {}
112
+ }
113
+
114
+ /// @notice Simulate a cash out by burning an NFT.
115
+ function cashOutNFT(uint256 seed) external {
116
+ address actor = _getActor(seed);
117
+
118
+ // Check if actor has any tokens
119
+ if (_actorTokenIds[actor].length == 0) return;
120
+
121
+ // Pick a token to burn
122
+ uint256 tokenIndex = seed % _actorTokenIds[actor].length;
123
+ uint256 tokenId = _actorTokenIds[actor][tokenIndex];
124
+
125
+ uint256 tierId = store.tierIdOfToken(tokenId);
126
+
127
+ // Call recordBurn as the hook
128
+ uint256[] memory tokenIds = new uint256[](1);
129
+ tokenIds[0] = tokenId;
130
+
131
+ vm.prank(hookAddress);
132
+ try store.recordBurn(tokenIds) {
133
+ ghost_burnedPerTier[tierId]++;
134
+
135
+ // Remove token from actor's list (swap and pop)
136
+ uint256 lastIndex = _actorTokenIds[actor].length - 1;
137
+ if (tokenIndex != lastIndex) {
138
+ _actorTokenIds[actor][tokenIndex] = _actorTokenIds[actor][lastIndex];
139
+ }
140
+ _actorTokenIds[actor].pop();
141
+
142
+ callCount_cashOut++;
143
+ } catch {}
144
+ }
145
+
146
+ /// @notice Add a new tier.
147
+ function addTier(uint256 seed) external {
148
+ uint256 price = bound(seed, 1, 1000);
149
+ uint256 supply = bound(seed >> 8, 10, 500);
150
+
151
+ JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
152
+ newTiers[0] = JB721TierConfig({
153
+ price: uint104(price),
154
+ initialSupply: uint32(supply),
155
+ votingUnits: 0,
156
+ reserveFrequency: 0,
157
+ reserveBeneficiary: address(0),
158
+ encodedIPFSUri: bytes32(0),
159
+ category: uint24(100),
160
+ discountPercent: 0,
161
+ allowOwnerMint: true,
162
+ useReserveBeneficiaryAsDefault: false,
163
+ transfersPausable: false,
164
+ useVotingUnits: false,
165
+ cannotBeRemoved: false,
166
+ cannotIncreaseDiscountPercent: false
167
+ });
168
+
169
+ vm.prank(hookAddress);
170
+ try store.recordAddTiers(newTiers) returns (uint256[] memory tierIds) {
171
+ ghost_tiersAdded += tierIds.length;
172
+ callCount_addTier++;
173
+ } catch {}
174
+ }
175
+
176
+ /// @notice Remove a tier.
177
+ function removeTier(uint256 seed) external {
178
+ uint256 maxTierId = store.maxTierIdOf(hookAddress);
179
+ if (maxTierId == 0) return;
180
+
181
+ uint256 tierId = (seed % maxTierId) + 1;
182
+ if (ghost_tierRemoved[tierId]) return;
183
+
184
+ uint256[] memory tierIds = new uint256[](1);
185
+ tierIds[0] = tierId;
186
+
187
+ vm.prank(hookAddress);
188
+ try store.recordRemoveTierIds(tierIds) {
189
+ ghost_tierRemoved[tierId] = true;
190
+ ghost_tiersRemoved++;
191
+ callCount_removeTier++;
192
+ } catch {}
193
+ }
194
+
195
+ /// @notice Mint reserves for a tier.
196
+ function mintReserves(uint256 seed) external {
197
+ uint256 maxTierId = store.maxTierIdOf(hookAddress);
198
+ if (maxTierId == 0) return;
199
+
200
+ uint256 tierId = (seed % maxTierId) + 1;
201
+ if (ghost_tierRemoved[tierId]) return;
202
+
203
+ // Try to mint 1 reserve
204
+ vm.prank(hookAddress);
205
+ try store.recordMintReservesFor(tierId, 1) returns (uint256[] memory tokenIds) {
206
+ ghost_reservesMintedPerTier[tierId] += tokenIds.length;
207
+ callCount_mintReserves++;
208
+ } catch {}
209
+ }
210
+
211
+ /// @notice Set discount for a tier.
212
+ function setDiscount(uint256 seed) external {
213
+ uint256 maxTierId = store.maxTierIdOf(hookAddress);
214
+ if (maxTierId == 0) return;
215
+
216
+ uint256 tierId = (seed % maxTierId) + 1;
217
+ uint256 discount = bound(seed >> 8, 0, 100); // 0-100%
218
+
219
+ vm.prank(hookAddress);
220
+ try store.recordSetDiscountPercentOf(tierId, discount) {
221
+ callCount_setDiscount++;
222
+ } catch {}
223
+ }
224
+
225
+ /// @notice Owner mint (direct mint with isOwnerMint=true).
226
+ function ownerMint(uint256 seed) external {
227
+ uint256 maxTierId = store.maxTierIdOf(hookAddress);
228
+ if (maxTierId == 0) return;
229
+
230
+ uint256 tierId = (seed % maxTierId) + 1;
231
+ if (ghost_tierRemoved[tierId]) return;
232
+
233
+ uint16[] memory tierIds = new uint16[](1);
234
+ tierIds[0] = uint16(tierId);
235
+
236
+ vm.prank(hookAddress);
237
+ try store.recordMint(0, tierIds, true) returns (uint256[] memory tokenIds, uint256) {
238
+ address actor = _getActor(seed);
239
+ for (uint256 i = 0; i < tokenIds.length; i++) {
240
+ _actorTokenIds[actor].push(tokenIds[i]);
241
+ }
242
+ ghost_mintedPerTier[tierId]++;
243
+ callCount_ownerMint++;
244
+ } catch {}
245
+ }
246
+
247
+ /// @notice Advance time.
248
+ function advanceTime(uint256 seed) external {
249
+ uint256 delta = bound(seed, 1 hours, 30 days);
250
+ vm.warp(block.timestamp + delta);
251
+ callCount_advanceTime++;
252
+ }
253
+
254
+ // View helpers
255
+ function getActor(uint256 index) external view returns (address) {
256
+ return actors[index];
257
+ }
258
+
259
+ function getActorTokenCount(address actor) external view returns (uint256) {
260
+ return _actorTokenIds[actor].length;
261
+ }
262
+ }
@@ -0,0 +1,155 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.23;
3
+
4
+ import {CommonBase} from "forge-std/Base.sol";
5
+ import {StdCheats} from "forge-std/StdCheats.sol";
6
+ import {StdUtils} from "forge-std/StdUtils.sol";
7
+
8
+ import {JB721TiersHookStore} from "../../../src/JB721TiersHookStore.sol";
9
+ import {JB721TierConfig} from "../../../src/structs/JB721TierConfig.sol";
10
+ import {JB721TiersHookFlags} from "../../../src/structs/JB721TiersHookFlags.sol";
11
+
12
+ /// @notice Handler for JB721TiersHookStore invariant tests.
13
+ /// @dev Acts as the "hook" address itself, so msg.sender (this) == hook in the store.
14
+ contract TierStoreHandler is CommonBase, StdCheats, StdUtils {
15
+ JB721TiersHookStore public immutable STORE;
16
+
17
+ // This contract acts as the hook.
18
+ address public HOOK;
19
+
20
+ // Ghost variable tracking the max tier ID seen.
21
+ uint256 public lowestMaxTierIdSeen;
22
+
23
+ // Track how many tiers we've added.
24
+ uint256 public tiersAdded;
25
+
26
+ // Track minted token IDs so we can only burn minted tokens.
27
+ uint256[] public mintedTokenIds;
28
+
29
+ // Track burned token IDs to avoid double-burn.
30
+ mapping(uint256 => bool) public wasBurned;
31
+
32
+ constructor(JB721TiersHookStore store) {
33
+ STORE = store;
34
+ HOOK = address(this);
35
+ }
36
+
37
+ /// @notice Add a new tier.
38
+ function addTier(uint104 price, uint32 initialSupply, uint16 reserveFrequency, uint24 category) public {
39
+ // Bound inputs to valid ranges.
40
+ initialSupply = uint32(bound(initialSupply, 1, 1_000_000));
41
+ price = uint104(bound(price, 1, type(uint104).max));
42
+ category = uint24(bound(category, 0, 100));
43
+
44
+ // Reserve frequency must be <= initialSupply if non-zero.
45
+ if (reserveFrequency > 0) {
46
+ reserveFrequency = uint16(bound(reserveFrequency, 1, 200));
47
+ }
48
+
49
+ JB721TierConfig[] memory configs = new JB721TierConfig[](1);
50
+ configs[0] = JB721TierConfig({
51
+ price: price,
52
+ initialSupply: initialSupply,
53
+ votingUnits: 0,
54
+ reserveFrequency: reserveFrequency,
55
+ reserveBeneficiary: address(0),
56
+ encodedIPFSUri: bytes32(0),
57
+ category: category,
58
+ discountPercent: 0,
59
+ allowOwnerMint: true,
60
+ useReserveBeneficiaryAsDefault: false,
61
+ transfersPausable: false,
62
+ useVotingUnits: false,
63
+ cannotBeRemoved: false,
64
+ cannotIncreaseDiscountPercent: false
65
+ });
66
+
67
+ try this._doAddTiers(configs) {
68
+ tiersAdded++;
69
+ _updateMaxTierIdSeen();
70
+ } catch {}
71
+ }
72
+
73
+ /// @dev External wrapper so calldata encoding is correct for the store.
74
+ function _doAddTiers(JB721TierConfig[] calldata configs) external {
75
+ STORE.recordAddTiers(configs);
76
+ }
77
+
78
+ /// @notice Remove a tier.
79
+ function removeTier(uint256 tierId) public {
80
+ uint256 maxId = STORE.maxTierIdOf(HOOK);
81
+ if (maxId == 0) return;
82
+
83
+ tierId = bound(tierId, 1, maxId);
84
+
85
+ uint256[] memory ids = new uint256[](1);
86
+ ids[0] = tierId;
87
+
88
+ try this._doRemoveTiers(ids) {} catch {}
89
+ }
90
+
91
+ /// @dev External wrapper for calldata.
92
+ function _doRemoveTiers(uint256[] calldata ids) external {
93
+ STORE.recordRemoveTierIds(ids);
94
+ }
95
+
96
+ /// @notice Mint from a tier (simulates store recording a mint).
97
+ function mint(uint256 tierId) public {
98
+ uint256 maxId = STORE.maxTierIdOf(HOOK);
99
+ if (maxId == 0) return;
100
+
101
+ tierId = bound(tierId, 1, maxId);
102
+
103
+ uint16[] memory tierIds = new uint16[](1);
104
+ tierIds[0] = uint16(tierId);
105
+
106
+ try this._doMint(type(uint256).max, tierIds) returns (uint256[] memory tokenIds) {
107
+ for (uint256 i; i < tokenIds.length; i++) {
108
+ if (tokenIds[i] != 0) {
109
+ mintedTokenIds.push(tokenIds[i]);
110
+ }
111
+ }
112
+ } catch {}
113
+
114
+ _updateMaxTierIdSeen();
115
+ }
116
+
117
+ /// @dev External wrapper for calldata.
118
+ function _doMint(uint256 amount, uint16[] calldata tierIds) external returns (uint256[] memory tokenIds) {
119
+ (tokenIds,) = STORE.recordMint(amount, tierIds, true);
120
+ }
121
+
122
+ /// @notice Burn a minted token.
123
+ function burn(uint256 indexSeed) public {
124
+ if (mintedTokenIds.length == 0) return;
125
+
126
+ uint256 index = bound(indexSeed, 0, mintedTokenIds.length - 1);
127
+ uint256 tokenId = mintedTokenIds[index];
128
+
129
+ // Skip if already burned.
130
+ if (wasBurned[tokenId]) return;
131
+
132
+ uint256[] memory tokenIds = new uint256[](1);
133
+ tokenIds[0] = tokenId;
134
+
135
+ try this._doBurn(tokenIds) {
136
+ wasBurned[tokenId] = true;
137
+ } catch {}
138
+ }
139
+
140
+ /// @dev External wrapper for calldata.
141
+ function _doBurn(uint256[] calldata tokenIds) external {
142
+ STORE.recordBurn(tokenIds);
143
+ }
144
+
145
+ function mintedTokenCount() external view returns (uint256) {
146
+ return mintedTokenIds.length;
147
+ }
148
+
149
+ function _updateMaxTierIdSeen() internal {
150
+ uint256 current = STORE.maxTierIdOf(HOOK);
151
+ if (lowestMaxTierIdSeen == 0 || current > lowestMaxTierIdSeen) {
152
+ lowestMaxTierIdSeen = current;
153
+ }
154
+ }
155
+ }