@bananapus/721-hook-v6 0.0.32 → 0.0.33

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 (33) hide show
  1. package/USER_JOURNEYS.md +11 -0
  2. package/package.json +3 -3
  3. package/script/Deploy.s.sol +53 -19
  4. package/src/JB721Checkpoints.sol +92 -0
  5. package/src/JB721CheckpointsDeployer.sol +45 -0
  6. package/src/JB721TiersHook.sol +68 -32
  7. package/src/abstract/JB721Hook.sol +5 -0
  8. package/src/interfaces/IJB721Checkpoints.sol +34 -0
  9. package/src/interfaces/IJB721CheckpointsDeployer.sol +20 -0
  10. package/src/interfaces/IJB721TiersHook.sol +8 -0
  11. package/src/libraries/JB721TiersHookLib.sol +53 -2
  12. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +11 -1
  13. package/test/Fork.t.sol +11 -2
  14. package/test/TestAuditGaps.sol +1 -1
  15. package/test/TestCheckpoints.t.sol +329 -0
  16. package/test/audit/CodexNemesisRepoFindings.t.sol +270 -0
  17. package/test/audit/CodexRetroactiveReserveBeneficiaryDilution.t.sol +161 -0
  18. package/test/audit/CodexSplitCreditsMismatch.t.sol +2 -1
  19. package/test/audit/CrossCurrencySplitNoPrices.t.sol +1 -0
  20. package/test/audit/SameCurrencyDecimalMismatch.t.sol +249 -0
  21. package/test/audit/SplitFailureRedistribution.t.sol +2 -1
  22. package/test/fork/ERC20CashOutFork.t.sol +11 -2
  23. package/test/fork/ERC20TierSplitFork.t.sol +11 -2
  24. package/test/fork/IssueTokensForSplitsFork.t.sol +11 -2
  25. package/test/regression/BrokenTerminalDoesNotDos.t.sol +2 -2
  26. package/test/regression/SplitDistributionBugs.t.sol +5 -5
  27. package/test/regression/SplitNoBeneficiary.t.sol +1 -1
  28. package/test/unit/AuditFixes_Unit.t.sol +5 -5
  29. package/test/unit/pay_CrossCurrency_Unit.t.sol +1 -0
  30. package/test/unit/pay_Unit.t.sol +1 -0
  31. package/test/unit/redeem_Unit.t.sol +3 -3
  32. package/test/unit/splitHookDistribution_Unit.t.sol +6 -6
  33. package/test/unit/tierSplitRouting_Unit.t.sol +2 -2
@@ -6,6 +6,8 @@ import {IJBRulesets} from "@bananapus/core-v6/src/interfaces/IJBRulesets.sol";
6
6
  import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
7
7
  import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
8
8
 
9
+ import {IJB721Checkpoints} from "./IJB721Checkpoints.sol";
10
+ import {IJB721CheckpointsDeployer} from "./IJB721CheckpointsDeployer.sol";
9
11
  import {IJB721Hook} from "./IJB721Hook.sol";
10
12
  import {IJB721TiersHookStore} from "./IJB721TiersHookStore.sol";
11
13
  import {IJB721TokenUriResolver} from "./IJB721TokenUriResolver.sol";
@@ -144,6 +146,12 @@ interface IJB721TiersHook is IJB721Hook {
144
146
  /// @return decimals The amount of decimals being used in tier prices.
145
147
  function pricingContext() external view returns (uint256 currency, uint256 decimals);
146
148
 
149
+ /// @notice The checkpoint module that manages IVotes-compatible checkpointed voting power for this hook's NFTs.
150
+ /// @dev Deployed lazily on first mint. Pass this to JBTokenDistributor as the IVotes token.
151
+ /// @return The checkpoint module.
152
+ // forge-lint: disable-next-line(mixed-case-function)
153
+ function CHECKPOINTS() external view returns (IJB721Checkpoints);
154
+
147
155
  /// @notice The contract that exposes price feeds for currency conversions.
148
156
  /// @return The prices contract.
149
157
  function PRICES() external view returns (IJBPrices);
@@ -310,6 +310,10 @@ library JB721TiersHookLib {
310
310
  convertedTotal = totalSplitAmount;
311
311
  convertedMetadata = splitMetadata;
312
312
 
313
+ // Extract pricing decimals for reuse below.
314
+ // forge-lint: disable-next-line(unsafe-typecast)
315
+ uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
316
+
313
317
  // Convert each per-tier amount from the tier pricing currency to the payment currency.
314
318
  // forge-lint: disable-next-line(unsafe-typecast)
315
319
  if (amountCurrency != uint256(uint32(packedPricingContext))) {
@@ -329,8 +333,7 @@ library JB721TiersHookLib {
329
333
  });
330
334
 
331
335
  // The denominator scales each amount from tier-pricing decimals to payment-token decimals.
332
- // forge-lint: disable-next-line(unsafe-typecast)
333
- uint256 denom = 10 ** uint256(uint8(packedPricingContext >> 32));
336
+ uint256 denom = 10 ** pricingDecimals;
334
337
 
335
338
  // Decode per-tier breakdown so each amount can be converted individually.
336
339
  (uint16[] memory tierIds, uint256[] memory amounts) =
@@ -351,6 +354,35 @@ library JB721TiersHookLib {
351
354
  // Re-encode with the converted amounts.
352
355
  convertedMetadata = abi.encode(tierIds, amounts);
353
356
  }
357
+ } else if (amountDecimals != pricingDecimals) {
358
+ // Same currency but different decimal scales (e.g. pricing at 18 decimals, payment token at 6).
359
+ // Without this branch, split amounts stay in pricing decimals while the cap comparison uses
360
+ // payment decimals — causing orders-of-magnitude mis-scaling. This mirrors the same-currency
361
+ // decimal adjustment in `normalizePaymentValue` (which handles the mint path).
362
+
363
+ // Decode the per-tier breakdown so each amount can be rescaled individually.
364
+ (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(convertedMetadata, (uint16[], uint256[]));
365
+
366
+ // Re-accumulate the total from rescaled amounts to avoid rounding drift.
367
+ convertedTotal = 0;
368
+ for (uint256 i; i < amounts.length;) {
369
+ // Scale each amount from pricing decimals to payment decimals.
370
+ if (amountDecimals > pricingDecimals) {
371
+ // Payment has more decimals — multiply to add precision (e.g. 6→18: multiply by 10^12).
372
+ amounts[i] = amounts[i] * (10 ** (amountDecimals - pricingDecimals));
373
+ } else {
374
+ // Payment has fewer decimals — divide to remove precision (e.g. 18→6: divide by 10^12).
375
+ amounts[i] = amounts[i] / (10 ** (pricingDecimals - amountDecimals));
376
+ }
377
+ convertedTotal += amounts[i];
378
+
379
+ unchecked {
380
+ ++i;
381
+ }
382
+ }
383
+
384
+ // Re-encode with the rescaled amounts.
385
+ convertedMetadata = abi.encode(tierIds, amounts);
354
386
  }
355
387
 
356
388
  // Cap the total at the actual payment value. Pay credits fund NFT minting (virtual), but splits
@@ -770,4 +802,23 @@ library JB721TiersHookLib {
770
802
  baseUri: baseUri, hexString: store.encodedTierIPFSUriOf({hook: hook, tokenId: tokenId})
771
803
  });
772
804
  }
805
+
806
+ event SetDiscountPercent(uint256 indexed tierId, uint256 discountPercent, address caller);
807
+
808
+ /// @notice Set the discount percent for a tier, emitting an event and recording it in the store.
809
+ /// @param store The 721 tiers hook store.
810
+ /// @param tierId The ID of the tier.
811
+ /// @param discountPercent The discount percent to set.
812
+ /// @param caller The msg.sender of the original call.
813
+ function setDiscountPercentOf(
814
+ IJB721TiersHookStore store,
815
+ uint256 tierId,
816
+ uint256 discountPercent,
817
+ address caller
818
+ )
819
+ external
820
+ {
821
+ emit SetDiscountPercent({tierId: tierId, discountPercent: discountPercent, caller: caller});
822
+ store.recordSetDiscountPercentOf({tierId: tierId, discountPercent: discountPercent});
823
+ }
773
824
  }
@@ -15,6 +15,9 @@ import "../../src/JB721TiersHookDeployer.sol";
15
15
  // forge-lint: disable-next-line(unaliased-plain-import)
16
16
  import "../../src/JB721TiersHookStore.sol";
17
17
 
18
+ import {JB721CheckpointsDeployer} from "../../src/JB721CheckpointsDeployer.sol";
19
+ import {IJB721CheckpointsDeployer} from "../../src/interfaces/IJB721CheckpointsDeployer.sol";
20
+
18
21
  // forge-lint: disable-next-line(unaliased-plain-import)
19
22
  import "../utils/TestBaseWorkflow.sol";
20
23
  // forge-lint: disable-next-line(unaliased-plain-import)
@@ -69,7 +72,14 @@ contract Test_TiersHook_E2E is TestBaseWorkflow {
69
72
  super.setUp();
70
73
  store = new JB721TiersHookStore();
71
74
  hook = new JB721TiersHook(
72
- jbDirectory, jbPermissions, jbPrices, jbRulesets, store, IJBSplits(address(jbSplits)), trustedForwarder
75
+ jbDirectory,
76
+ jbPermissions,
77
+ jbPrices,
78
+ jbRulesets,
79
+ store,
80
+ IJBSplits(address(jbSplits)),
81
+ IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
82
+ trustedForwarder
73
83
  );
74
84
  addressRegistry = new JBAddressRegistry();
75
85
  JB721TiersHookDeployer hookDeployer = new JB721TiersHookDeployer(hook, store, addressRegistry, trustedForwarder);
package/test/Fork.t.sol CHANGED
@@ -66,6 +66,8 @@ import "../src/JB721TiersHookDeployer.sol";
66
66
  import "../src/JB721TiersHookProjectDeployer.sol";
67
67
  // forge-lint: disable-next-line(unaliased-plain-import)
68
68
  import "../src/JB721TiersHookStore.sol";
69
+ import {JB721CheckpointsDeployer} from "../src/JB721CheckpointsDeployer.sol";
70
+ import {IJB721CheckpointsDeployer} from "../src/interfaces/IJB721CheckpointsDeployer.sol";
69
71
  // forge-lint: disable-next-line(unaliased-plain-import)
70
72
  import "../src/interfaces/IJB721TiersHook.sol";
71
73
  // forge-lint: disable-next-line(unaliased-plain-import)
@@ -183,7 +185,7 @@ contract Fork_721Hook_Test is Test {
183
185
  jbPermissions = new JBPermissions(address(0));
184
186
  jbProjects = new JBProjects(multisig, address(0), address(0));
185
187
  jbDirectory = new JBDirectory(jbPermissions, jbProjects, multisig);
186
- JBERC20 jbErc20 = new JBERC20();
188
+ JBERC20 jbErc20 = new JBERC20(jbPermissions, jbProjects);
187
189
  jbTokens = new JBTokens(jbDirectory, jbErc20);
188
190
  jbRulesets = new JBRulesets(jbDirectory);
189
191
  jbPrices = new JBPrices(jbDirectory, jbPermissions, jbProjects, multisig, address(0));
@@ -231,7 +233,14 @@ contract Fork_721Hook_Test is Test {
231
233
  function _deploy721Hook() internal {
232
234
  store = new JB721TiersHookStore();
233
235
  hookImpl = new JB721TiersHook(
234
- jbDirectory, jbPermissions, jbPrices, jbRulesets, store, IJBSplits(address(jbSplits)), address(0)
236
+ jbDirectory,
237
+ jbPermissions,
238
+ jbPrices,
239
+ jbRulesets,
240
+ store,
241
+ IJBSplits(address(jbSplits)),
242
+ IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
243
+ address(0)
235
244
  );
236
245
  addressRegistry = new JBAddressRegistry();
237
246
  hookDeployer = new JB721TiersHookDeployer(hookImpl, store, addressRegistry, address(0));
@@ -164,7 +164,7 @@ contract TestAuditGaps_Reentrancy is UnitTestSetup {
164
164
  weight: 10e18,
165
165
  newlyIssuedTokenCount: 0,
166
166
  beneficiary: beneficiary,
167
- hookMetadata: abi.encode(splitTierIds, splitAmounts),
167
+ hookMetadata: abi.encode(beneficiary, beneficiary, abi.encode(splitTierIds, splitAmounts)),
168
168
  payerMetadata: payerMetadata
169
169
  });
170
170
  }
@@ -0,0 +1,329 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
+ import "./utils/UnitTestSetup.sol";
6
+ // forge-lint: disable-next-line(unaliased-plain-import)
7
+ import "./utils/ForTest_JB721TiersHook.sol";
8
+ import {JB721Checkpoints} from "../src/JB721Checkpoints.sol";
9
+ import {IJB721Checkpoints} from "../src/interfaces/IJB721Checkpoints.sol";
10
+ import {IJB721TiersHook} from "../src/interfaces/IJB721TiersHook.sol";
11
+ import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
12
+
13
+ /// @title TestCheckpoints
14
+ /// @notice Tests the checkpoint module IVotes checkpointed voting power baked into the base hook:
15
+ /// delegation, checkpoints, transfer, multi-tier, burn, and module deployment.
16
+ contract TestCheckpoints is UnitTestSetup {
17
+ /// @notice Deploys a ForTest hook with the given number of tiers.
18
+ function _initializeHookWithCheckpoints(uint256 numberOfTiers) internal returns (ForTest_JB721TiersHook tiersHook) {
19
+ (JB721TierConfig[] memory tierConfigs,) = _createTiers(defaultTierConfig, numberOfTiers);
20
+
21
+ ForTest_JB721TiersHookStore hookStore = new ForTest_JB721TiersHookStore();
22
+
23
+ tiersHook = new ForTest_JB721TiersHook(
24
+ ForTest_JB721TiersHook.ForTestInitConfig({
25
+ projectId: projectId,
26
+ name: name,
27
+ symbol: symbol,
28
+ baseUri: baseUri,
29
+ tokenUriResolver: IJB721TokenUriResolver(mockTokenUriResolver),
30
+ contractUri: contractUri,
31
+ tiers: tierConfigs,
32
+ flags: JB721TiersHookFlags({
33
+ preventOverspending: false,
34
+ issueTokensForSplits: false,
35
+ noNewTiersWithReserves: false,
36
+ noNewTiersWithVotes: false,
37
+ noNewTiersWithOwnerMinting: true
38
+ })
39
+ }),
40
+ IJBDirectory(mockJBDirectory),
41
+ IJBPrices(mockJBPrices),
42
+ IJBRulesets(mockJBRulesets),
43
+ IJB721TiersHookStore(address(hookStore)),
44
+ IJBSplits(mockJBSplits)
45
+ );
46
+
47
+ tiersHook.transferOwnership(owner);
48
+ }
49
+
50
+ // -------------------------------------------------------------------
51
+ // Test 1: Checkpoint module is deployed eagerly during initialize
52
+ // -------------------------------------------------------------------
53
+ function test_checkpointModule_isDeployed() public {
54
+ defaultTierConfig.flags.allowOwnerMint = true;
55
+ defaultTierConfig.reserveFrequency = 0;
56
+
57
+ ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
58
+
59
+ // CHECKPOINTS should be deployed immediately after initialization.
60
+ assertTrue(
61
+ address(tiersHook.CHECKPOINTS()) != address(0), "Checkpoint module should be deployed after initialization"
62
+ );
63
+ }
64
+
65
+ // -------------------------------------------------------------------
66
+ // Test 2: supportsInterface still works for base hook
67
+ // -------------------------------------------------------------------
68
+ function test_supportsInterface() public {
69
+ defaultTierConfig.flags.allowOwnerMint = true;
70
+ defaultTierConfig.reserveFrequency = 0;
71
+
72
+ ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
73
+
74
+ assertTrue(tiersHook.supportsInterface(type(IJB721TiersHook).interfaceId), "Should support IJB721TiersHook");
75
+ }
76
+
77
+ // -------------------------------------------------------------------
78
+ // Test 3: Mint + manual delegate -> getVotes equals tier votingUnits
79
+ // -------------------------------------------------------------------
80
+ function test_mintAndDelegate_getVotes() public {
81
+ defaultTierConfig.flags.allowOwnerMint = true;
82
+ defaultTierConfig.reserveFrequency = 0;
83
+ defaultTierConfig.flags.useVotingUnits = true;
84
+ defaultTierConfig.votingUnits = 100;
85
+
86
+ ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
87
+
88
+ address user = makeAddr("user");
89
+
90
+ // Mint an NFT to user (CHECKPOINTS already deployed during init).
91
+ uint16[] memory tiersToMint = new uint16[](1);
92
+ tiersToMint[0] = 1;
93
+ vm.prank(owner);
94
+ tiersHook.mintFor(tiersToMint, user);
95
+
96
+ IJB721Checkpoints module = tiersHook.CHECKPOINTS();
97
+
98
+ // Without delegation, getVotes should be 0.
99
+ assertEq(module.getVotes(user), 0, "Votes should be 0 before delegation");
100
+
101
+ // User self-delegates.
102
+ vm.prank(user);
103
+ module.delegate(user);
104
+
105
+ assertEq(module.getVotes(user), 100, "Votes should be 100 after delegation");
106
+ }
107
+
108
+ // -------------------------------------------------------------------
109
+ // Test 4: No auto-delegation — delegates(user) stays address(0) after mint
110
+ // -------------------------------------------------------------------
111
+ function test_noAutoDelegation_delegateStaysZero() public {
112
+ defaultTierConfig.flags.allowOwnerMint = true;
113
+ defaultTierConfig.reserveFrequency = 0;
114
+ defaultTierConfig.flags.useVotingUnits = true;
115
+ defaultTierConfig.votingUnits = 100;
116
+
117
+ ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
118
+
119
+ address user = makeAddr("user");
120
+
121
+ // Mint an NFT to user (CHECKPOINTS already deployed during init).
122
+ uint16[] memory tiersToMint = new uint16[](1);
123
+ tiersToMint[0] = 1;
124
+ vm.prank(owner);
125
+ tiersHook.mintFor(tiersToMint, user);
126
+
127
+ IJB721Checkpoints module = tiersHook.CHECKPOINTS();
128
+
129
+ // Delegate should be address(0) — no auto-delegation.
130
+ assertEq(module.delegates(user), address(0), "Delegate should be zero after mint");
131
+ }
132
+
133
+ // -------------------------------------------------------------------
134
+ // Test 5: Transfer moves checkpointed votes (with manual delegation)
135
+ // -------------------------------------------------------------------
136
+ function test_transfer_movesCheckpointedVotes() public {
137
+ defaultTierConfig.flags.allowOwnerMint = true;
138
+ defaultTierConfig.reserveFrequency = 0;
139
+ defaultTierConfig.flags.useVotingUnits = true;
140
+ defaultTierConfig.votingUnits = 100;
141
+
142
+ ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
143
+
144
+ address alice = makeAddr("alice");
145
+ address bob = makeAddr("bob");
146
+
147
+ // Mint to alice (CHECKPOINTS already deployed during init).
148
+ uint16[] memory tiersToMint = new uint16[](1);
149
+ tiersToMint[0] = 1;
150
+ vm.prank(owner);
151
+ tiersHook.mintFor(tiersToMint, alice);
152
+
153
+ IJB721Checkpoints module = tiersHook.CHECKPOINTS();
154
+
155
+ // Both delegate to themselves.
156
+ vm.prank(alice);
157
+ module.delegate(alice);
158
+ vm.prank(bob);
159
+ module.delegate(bob);
160
+
161
+ assertEq(module.getVotes(alice), 100, "Alice should have 100 votes");
162
+ assertEq(module.getVotes(bob), 0, "Bob should have 0 votes");
163
+
164
+ // Transfer NFT from alice to bob.
165
+ uint256 tokenId = _generateTokenId(1, 1);
166
+ vm.prank(alice);
167
+ IERC721(address(tiersHook)).transferFrom(alice, bob, tokenId);
168
+
169
+ assertEq(module.getVotes(alice), 0, "Alice should have 0 votes after transfer");
170
+ assertEq(module.getVotes(bob), 100, "Bob should have 100 votes after transfer");
171
+ }
172
+
173
+ // -------------------------------------------------------------------
174
+ // Test 6: getPastVotes / getPastTotalSupply checkpoints
175
+ // -------------------------------------------------------------------
176
+ function test_getPastVotes_checkpoint() public {
177
+ defaultTierConfig.flags.allowOwnerMint = true;
178
+ defaultTierConfig.reserveFrequency = 0;
179
+ defaultTierConfig.flags.useVotingUnits = true;
180
+ defaultTierConfig.votingUnits = 100;
181
+
182
+ ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
183
+
184
+ address user = makeAddr("user");
185
+
186
+ // Mint first NFT to test checkpoint tracking.
187
+ uint16[] memory tiersToMint = new uint16[](1);
188
+ tiersToMint[0] = 1;
189
+ vm.prank(owner);
190
+ tiersHook.mintFor(tiersToMint, user);
191
+
192
+ IJB721Checkpoints module = tiersHook.CHECKPOINTS();
193
+
194
+ // User self-delegates so checkpoints are created going forward.
195
+ vm.prank(user);
196
+ module.delegate(user);
197
+
198
+ uint256 blockBeforeSecondMint = block.number;
199
+ vm.roll(block.number + 1);
200
+
201
+ // Mint a second NFT.
202
+ vm.prank(owner);
203
+ tiersHook.mintFor(tiersToMint, user);
204
+
205
+ uint256 blockAfterSecondMint = block.number;
206
+ vm.roll(block.number + 1);
207
+
208
+ // Past votes before second mint = 100 (from first NFT + delegation).
209
+ assertEq(module.getPastVotes(user, blockBeforeSecondMint), 100, "Past votes before second mint should be 100");
210
+ // Past votes after second mint = 200.
211
+ assertEq(module.getPastVotes(user, blockAfterSecondMint), 200, "Past votes after second mint should be 200");
212
+
213
+ // Past total supply.
214
+ assertEq(
215
+ module.getPastTotalSupply(blockBeforeSecondMint), 100, "Past total supply before second mint should be 100"
216
+ );
217
+ assertEq(
218
+ module.getPastTotalSupply(blockAfterSecondMint), 200, "Past total supply after second mint should be 200"
219
+ );
220
+ }
221
+
222
+ // -------------------------------------------------------------------
223
+ // Test 7: Multi-tier with different voting units
224
+ // -------------------------------------------------------------------
225
+ function test_multiTier_differentVotingUnits() public {
226
+ defaultTierConfig.flags.allowOwnerMint = true;
227
+ defaultTierConfig.reserveFrequency = 0;
228
+ defaultTierConfig.flags.useVotingUnits = true;
229
+
230
+ ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(3);
231
+
232
+ // Set custom voting units per tier.
233
+ tiersHook.test_store().ForTest_setTierVotingUnits(address(tiersHook), 1, 100);
234
+ tiersHook.test_store().ForTest_setTierVotingUnits(address(tiersHook), 2, 200);
235
+ tiersHook.test_store().ForTest_setTierVotingUnits(address(tiersHook), 3, 500);
236
+
237
+ address user = makeAddr("user");
238
+
239
+ // Mint one from tier 1 to test checkpoint tracking.
240
+ uint16[] memory tier1 = new uint16[](1);
241
+ tier1[0] = 1;
242
+ vm.prank(owner);
243
+ tiersHook.mintFor(tier1, user);
244
+
245
+ IJB721Checkpoints module = tiersHook.CHECKPOINTS();
246
+
247
+ // User self-delegates.
248
+ vm.prank(user);
249
+ module.delegate(user);
250
+
251
+ // Mint from remaining tiers.
252
+ uint16[] memory tier2 = new uint16[](1);
253
+ tier2[0] = 2;
254
+ uint16[] memory tier3 = new uint16[](1);
255
+ tier3[0] = 3;
256
+
257
+ vm.startPrank(owner);
258
+ tiersHook.mintFor(tier2, user);
259
+ tiersHook.mintFor(tier3, user);
260
+ vm.stopPrank();
261
+
262
+ // 100 + 200 + 500 = 800.
263
+ assertEq(module.getVotes(user), 800, "User should have 800 checkpointed votes");
264
+ }
265
+
266
+ // -------------------------------------------------------------------
267
+ // Test 8: Burn decreases checkpointed total supply
268
+ // -------------------------------------------------------------------
269
+ function test_burn_decreasesTotalSupply() public {
270
+ defaultTierConfig.flags.allowOwnerMint = true;
271
+ defaultTierConfig.reserveFrequency = 0;
272
+ defaultTierConfig.flags.useVotingUnits = true;
273
+ defaultTierConfig.votingUnits = 100;
274
+
275
+ ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
276
+
277
+ address user = makeAddr("user");
278
+
279
+ // Mint 2 NFTs (first one deploys CHECKPOINTS lazily).
280
+ uint16[] memory tiersToMint = new uint16[](2);
281
+ tiersToMint[0] = 1;
282
+ tiersToMint[1] = 1;
283
+ vm.prank(owner);
284
+ tiersHook.mintFor(tiersToMint, user);
285
+
286
+ IJB721Checkpoints module = tiersHook.CHECKPOINTS();
287
+
288
+ // User self-delegates.
289
+ vm.prank(user);
290
+ module.delegate(user);
291
+
292
+ assertEq(module.getVotes(user), 200, "User should have 200 votes from 2 NFTs");
293
+
294
+ uint256 blockBeforeBurn = block.number;
295
+ vm.roll(block.number + 1);
296
+
297
+ // Burn one NFT.
298
+ uint256[] memory tokensToBurn = new uint256[](1);
299
+ tokensToBurn[0] = _generateTokenId(1, 1);
300
+ tiersHook.burn(tokensToBurn);
301
+
302
+ assertEq(module.getVotes(user), 100, "User should have 100 votes after burning 1 NFT");
303
+
304
+ vm.roll(block.number + 1);
305
+
306
+ assertEq(module.getPastTotalSupply(blockBeforeBurn), 200, "Past total supply before burn should be 200");
307
+ }
308
+
309
+ // -------------------------------------------------------------------
310
+ // Test 9: Unauthorized onTransfer reverts
311
+ // -------------------------------------------------------------------
312
+ function test_unauthorizedOnTransfer_reverts() public {
313
+ defaultTierConfig.flags.allowOwnerMint = true;
314
+ defaultTierConfig.reserveFrequency = 0;
315
+
316
+ ForTest_JB721TiersHook tiersHook = _initializeHookWithCheckpoints(1);
317
+
318
+ // Mint to test checkpoint tracking.
319
+ uint16[] memory tiersToMint = new uint16[](1);
320
+ tiersToMint[0] = 1;
321
+ vm.prank(owner);
322
+ tiersHook.mintFor(tiersToMint, makeAddr("user"));
323
+
324
+ IJB721Checkpoints module = tiersHook.CHECKPOINTS();
325
+
326
+ vm.expectRevert(JB721Checkpoints.JB721Checkpoints_Unauthorized.selector);
327
+ module.onTransfer(address(0), address(1), 1);
328
+ }
329
+ }