@bananapus/721-hook-v6 0.0.32 → 0.0.34

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 (35) 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 +90 -116
  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/JB721Constants.sol +6 -0
  12. package/src/libraries/JB721TiersHookLib.sol +353 -146
  13. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +11 -1
  14. package/test/Fork.t.sol +11 -2
  15. package/test/TestAuditGaps.sol +1 -1
  16. package/test/TestCheckpoints.t.sol +329 -0
  17. package/test/audit/CodexNemesisRepoFindings.t.sol +270 -0
  18. package/test/audit/CodexRetroactiveReserveBeneficiaryDilution.t.sol +161 -0
  19. package/test/audit/CodexSplitCreditsMismatch.t.sol +2 -1
  20. package/test/audit/CrossCurrencySplitNoPrices.t.sol +1 -0
  21. package/test/audit/SameCurrencyDecimalMismatch.t.sol +249 -0
  22. package/test/audit/SplitFailureRedistribution.t.sol +2 -1
  23. package/test/fork/ERC20CashOutFork.t.sol +11 -2
  24. package/test/fork/ERC20TierSplitFork.t.sol +11 -2
  25. package/test/fork/IssueTokensForSplitsFork.t.sol +11 -2
  26. package/test/regression/BrokenTerminalDoesNotDos.t.sol +2 -2
  27. package/test/regression/SplitDistributionBugs.t.sol +5 -5
  28. package/test/regression/SplitNoBeneficiary.t.sol +1 -1
  29. package/test/unit/AuditFixes_Unit.t.sol +5 -5
  30. package/test/unit/pay_CrossCurrency_Unit.t.sol +1 -0
  31. package/test/unit/pay_Unit.t.sol +1 -0
  32. package/test/unit/redeem_Unit.t.sol +3 -3
  33. package/test/unit/relayBeneficiary_Unit.t.sol +182 -0
  34. package/test/unit/splitHookDistribution_Unit.t.sol +6 -6
  35. package/test/unit/tierSplitRouting_Unit.t.sol +2 -2
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
+ }
@@ -0,0 +1,270 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import "../utils/UnitTestSetup.sol";
5
+
6
+ import {IJB721TiersHookStore} from "../../src/interfaces/IJB721TiersHookStore.sol";
7
+ import {JB721TierConfigFlags} from "../../src/structs/JB721TierConfigFlags.sol";
8
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
9
+
10
+ contract CodexNemesisRepoFindings is UnitTestSetup {
11
+ address payable internal splitBeneficiary = payable(makeAddr("splitBeneficiary"));
12
+
13
+ function _payMetadata(
14
+ address hookAddress,
15
+ bool allowOverspending,
16
+ uint16[] memory tierIds
17
+ )
18
+ internal
19
+ view
20
+ returns (bytes memory)
21
+ {
22
+ bytes[] memory data = new bytes[](1);
23
+ data[0] = abi.encode(allowOverspending, tierIds);
24
+ bytes4[] memory ids = new bytes4[](1);
25
+ ids[0] = metadataHelper.getId("pay", hookAddress);
26
+ return metadataHelper.createMetadata(ids, data);
27
+ }
28
+
29
+ function _nativeTokenAmount(uint256 value) internal pure returns (JBTokenAmount memory) {
30
+ return JBTokenAmount({
31
+ token: JBConstants.NATIVE_TOKEN,
32
+ value: value,
33
+ decimals: 18,
34
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
35
+ });
36
+ }
37
+
38
+ function test_payCredits_can_underfund_split_bearing_tier_mints() public {
39
+ ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
40
+
41
+ vm.mockCall(mockJBSplits, abi.encodeWithSelector(IJBSplits.setSplitGroupsOf.selector), abi.encode());
42
+
43
+ JBSplit[] memory tierSplits = new JBSplit[](1);
44
+ tierSplits[0] = JBSplit({
45
+ preferAddToBalance: false,
46
+ percent: JBConstants.SPLITS_TOTAL_PERCENT,
47
+ projectId: 0,
48
+ beneficiary: splitBeneficiary,
49
+ lockedUntil: 0,
50
+ hook: IJBSplitHook(address(0))
51
+ });
52
+
53
+ JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
54
+ tierConfigs[0] = JB721TierConfig({
55
+ price: 1 ether,
56
+ initialSupply: 10,
57
+ votingUnits: 0,
58
+ reserveFrequency: 0,
59
+ reserveBeneficiary: address(0),
60
+ encodedIPFSUri: bytes32(uint256(0x1234)),
61
+ category: 1,
62
+ discountPercent: 0,
63
+ flags: JB721TierConfigFlags({
64
+ allowOwnerMint: false,
65
+ useReserveBeneficiaryAsDefault: false,
66
+ transfersPausable: false,
67
+ useVotingUnits: false,
68
+ cantBeRemoved: false,
69
+ cantIncreaseDiscountPercent: false,
70
+ cantBuyWithCredits: false
71
+ }),
72
+ splitPercent: JBConstants.SPLITS_TOTAL_PERCENT,
73
+ splits: tierSplits
74
+ });
75
+
76
+ vm.prank(owner);
77
+ testHook.adjustTiers(tierConfigs, new uint256[](0));
78
+
79
+ uint256 groupId = uint256(uint160(address(testHook))) | (uint256(1) << 160);
80
+ vm.mockCall(
81
+ mockJBSplits,
82
+ abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, uint256(0), groupId),
83
+ abi.encode(tierSplits)
84
+ );
85
+
86
+ mockAndExpect(
87
+ mockJBDirectory,
88
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
89
+ abi.encode(true)
90
+ );
91
+
92
+ vm.prank(mockTerminalAddress);
93
+ testHook.afterPayRecordedWith(
94
+ JBAfterPayRecordedContext({
95
+ payer: beneficiary,
96
+ projectId: projectId,
97
+ rulesetId: 0,
98
+ amount: _nativeTokenAmount(1 ether),
99
+ forwardedAmount: _nativeTokenAmount(0),
100
+ weight: 10e18,
101
+ newlyIssuedTokenCount: 0,
102
+ beneficiary: beneficiary,
103
+ hookMetadata: bytes(""),
104
+ payerMetadata: bytes("")
105
+ })
106
+ );
107
+
108
+ assertEq(testHook.payCreditsOf(beneficiary), 1 ether, "setup: credits should be seeded");
109
+
110
+ uint16[] memory tierIdsToMint = new uint16[](1);
111
+ tierIdsToMint[0] = 1;
112
+ bytes memory payerMetadata = _payMetadata(address(testHook), true, tierIdsToMint);
113
+
114
+ JBBeforePayRecordedContext memory beforeContext = JBBeforePayRecordedContext({
115
+ terminal: mockTerminalAddress,
116
+ payer: beneficiary,
117
+ amount: _nativeTokenAmount(1),
118
+ projectId: projectId,
119
+ rulesetId: 0,
120
+ beneficiary: beneficiary,
121
+ weight: 10e18,
122
+ reservedPercent: 5000,
123
+ metadata: payerMetadata
124
+ });
125
+
126
+ (uint256 weight, JBPayHookSpecification[] memory specs) = testHook.beforePayRecordedWith(beforeContext);
127
+
128
+ assertEq(weight, 0, "only the fresh 1 wei payment is considered for split weight adjustment");
129
+ assertEq(specs.length, 1);
130
+ assertEq(specs[0].amount, 1, "split forwarding is capped to the fresh payment, not the credit-backed mint");
131
+
132
+ uint256 splitBeneficiaryBalanceBefore = splitBeneficiary.balance;
133
+ vm.deal(mockTerminalAddress, 1);
134
+
135
+ vm.prank(mockTerminalAddress);
136
+ testHook.afterPayRecordedWith{value: 1}(
137
+ JBAfterPayRecordedContext({
138
+ payer: beneficiary,
139
+ projectId: projectId,
140
+ rulesetId: 0,
141
+ amount: _nativeTokenAmount(1),
142
+ forwardedAmount: _nativeTokenAmount(1),
143
+ weight: weight,
144
+ newlyIssuedTokenCount: 0,
145
+ beneficiary: beneficiary,
146
+ hookMetadata: specs[0].metadata,
147
+ payerMetadata: payerMetadata
148
+ })
149
+ );
150
+
151
+ assertEq(testHook.balanceOf(beneficiary), 1, "beneficiary still receives the split-bearing NFT");
152
+ assertEq(testHook.payCreditsOf(beneficiary), 1, "stored credits fund essentially the entire mint");
153
+ assertEq(
154
+ splitBeneficiary.balance - splitBeneficiaryBalanceBefore,
155
+ 1,
156
+ "split beneficiary only receives the fresh 1 wei payment instead of the tier's 1 ether split amount"
157
+ );
158
+ }
159
+
160
+ function test_new_default_reserve_beneficiary_retroactively_dilutes_existing_tiers() public {
161
+ ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
162
+
163
+ JB721TierConfig[] memory initialTier = new JB721TierConfig[](1);
164
+ initialTier[0] = JB721TierConfig({
165
+ price: 1 ether,
166
+ initialSupply: 10,
167
+ votingUnits: 0,
168
+ reserveFrequency: 2,
169
+ reserveBeneficiary: address(0),
170
+ encodedIPFSUri: bytes32(uint256(0x1111)),
171
+ category: 1,
172
+ discountPercent: 0,
173
+ flags: JB721TierConfigFlags({
174
+ allowOwnerMint: false,
175
+ useReserveBeneficiaryAsDefault: false,
176
+ transfersPausable: false,
177
+ useVotingUnits: false,
178
+ cantBeRemoved: false,
179
+ cantIncreaseDiscountPercent: false,
180
+ cantBuyWithCredits: false
181
+ }),
182
+ splitPercent: 0,
183
+ splits: new JBSplit[](0)
184
+ });
185
+
186
+ vm.prank(owner);
187
+ testHook.adjustTiers(initialTier, new uint256[](0));
188
+
189
+ mockAndExpect(
190
+ mockJBDirectory,
191
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
192
+ abi.encode(true)
193
+ );
194
+
195
+ uint16[] memory tierIdsToMint = new uint16[](3);
196
+ tierIdsToMint[0] = 1;
197
+ tierIdsToMint[1] = 1;
198
+ tierIdsToMint[2] = 1;
199
+ bytes memory payerMetadata = _payMetadata(address(testHook), false, tierIdsToMint);
200
+
201
+ vm.prank(mockTerminalAddress);
202
+ testHook.afterPayRecordedWith(
203
+ JBAfterPayRecordedContext({
204
+ payer: beneficiary,
205
+ projectId: projectId,
206
+ rulesetId: 0,
207
+ amount: _nativeTokenAmount(3 ether),
208
+ forwardedAmount: _nativeTokenAmount(0),
209
+ weight: 10e18,
210
+ newlyIssuedTokenCount: 0,
211
+ beneficiary: beneficiary,
212
+ hookMetadata: bytes(""),
213
+ payerMetadata: payerMetadata
214
+ })
215
+ );
216
+
217
+ assertEq(testHook.totalCashOutWeight(), 3 ether, "denominator initially reflects only sold NFTs");
218
+ assertEq(
219
+ testHook.STORE().numberOfPendingReservesFor(address(testHook), 1),
220
+ 0,
221
+ "without a reserve beneficiary the sold tier has no pending reserves"
222
+ );
223
+
224
+ JB721TierConfig[] memory defaultingTier = new JB721TierConfig[](1);
225
+ defaultingTier[0] = JB721TierConfig({
226
+ price: 2 ether,
227
+ initialSupply: 10,
228
+ votingUnits: 0,
229
+ reserveFrequency: 1,
230
+ reserveBeneficiary: owner,
231
+ encodedIPFSUri: bytes32(uint256(0x2222)),
232
+ category: 2,
233
+ discountPercent: 0,
234
+ flags: JB721TierConfigFlags({
235
+ allowOwnerMint: false,
236
+ useReserveBeneficiaryAsDefault: true,
237
+ transfersPausable: false,
238
+ useVotingUnits: false,
239
+ cantBeRemoved: false,
240
+ cantIncreaseDiscountPercent: false,
241
+ cantBuyWithCredits: false
242
+ }),
243
+ splitPercent: 0,
244
+ splits: new JBSplit[](0)
245
+ });
246
+
247
+ vm.prank(owner);
248
+ testHook.adjustTiers(defaultingTier, new uint256[](0));
249
+
250
+ assertEq(
251
+ testHook.STORE().reserveBeneficiaryOf(address(testHook), 1),
252
+ owner,
253
+ "the new default reserve beneficiary retroactively applies to the older sold tier"
254
+ );
255
+ assertEq(
256
+ testHook.STORE().numberOfPendingReservesFor(address(testHook), 1),
257
+ 2,
258
+ "the older tier now reports newly created pending reserves from past sales"
259
+ );
260
+ assertEq(
261
+ testHook.totalCashOutWeight(),
262
+ 5 ether,
263
+ "cash-out denominator is diluted by retroactively created reserves on the existing tier"
264
+ );
265
+
266
+ testHook.mintPendingReservesFor(1, 2);
267
+
268
+ assertEq(testHook.balanceOf(owner), 2, "the owner can mint those retroactive reserve NFTs to themselves");
269
+ }
270
+ }