@bananapus/721-hook-v6 0.0.18 → 0.0.19

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.
@@ -0,0 +1,612 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
+ import "forge-std/Test.sol";
6
+
7
+ // forge-lint: disable-next-line(unaliased-plain-import)
8
+ import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
9
+ // forge-lint: disable-next-line(unaliased-plain-import)
10
+ import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
11
+ // forge-lint: disable-next-line(unaliased-plain-import)
12
+ import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
13
+
14
+ // forge-lint: disable-next-line(unaliased-plain-import)
15
+ import "@bananapus/core-v6/src/JBController.sol";
16
+ // forge-lint: disable-next-line(unaliased-plain-import)
17
+ import "@bananapus/core-v6/src/JBDirectory.sol";
18
+ // forge-lint: disable-next-line(unaliased-plain-import)
19
+ import "@bananapus/core-v6/src/JBMultiTerminal.sol";
20
+ // forge-lint: disable-next-line(unaliased-plain-import)
21
+ import "@bananapus/core-v6/src/JBFundAccessLimits.sol";
22
+ // forge-lint: disable-next-line(unaliased-plain-import)
23
+ import "@bananapus/core-v6/src/JBFeelessAddresses.sol";
24
+ // forge-lint: disable-next-line(unaliased-plain-import)
25
+ import "@bananapus/core-v6/src/JBTerminalStore.sol";
26
+ // forge-lint: disable-next-line(unaliased-plain-import)
27
+ import "@bananapus/core-v6/src/JBRulesets.sol";
28
+ // forge-lint: disable-next-line(unaliased-plain-import)
29
+ import "@bananapus/core-v6/src/JBPermissions.sol";
30
+ // forge-lint: disable-next-line(unaliased-plain-import)
31
+ import "@bananapus/core-v6/src/JBPrices.sol";
32
+ import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
33
+ // forge-lint: disable-next-line(unaliased-plain-import)
34
+ import "@bananapus/core-v6/src/JBSplits.sol";
35
+ // forge-lint: disable-next-line(unaliased-plain-import)
36
+ import "@bananapus/core-v6/src/JBERC20.sol";
37
+ // forge-lint: disable-next-line(unaliased-plain-import)
38
+ import "@bananapus/core-v6/src/JBTokens.sol";
39
+ // forge-lint: disable-next-line(unaliased-plain-import)
40
+ import "@bananapus/core-v6/src/libraries/JBConstants.sol";
41
+ // forge-lint: disable-next-line(unaliased-plain-import)
42
+ import "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol";
43
+ // forge-lint: disable-next-line(unaliased-plain-import)
44
+ import "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
45
+ // forge-lint: disable-next-line(unaliased-plain-import)
46
+ import "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
47
+ // forge-lint: disable-next-line(unaliased-plain-import)
48
+ import "@bananapus/core-v6/src/structs/JBSplit.sol";
49
+ // forge-lint: disable-next-line(unaliased-plain-import)
50
+ import "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
51
+ // forge-lint: disable-next-line(unaliased-plain-import)
52
+ import "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
53
+ // forge-lint: disable-next-line(unaliased-plain-import)
54
+ import "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
55
+ // forge-lint: disable-next-line(unaliased-plain-import)
56
+ import "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
57
+ import {MetadataResolverHelper} from "@bananapus/core-v6/test/helpers/MetadataResolverHelper.sol";
58
+ import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
59
+ // forge-lint: disable-next-line(unused-import)
60
+ import {mulDiv} from "@prb/math/src/Common.sol";
61
+
62
+ // forge-lint: disable-next-line(unaliased-plain-import)
63
+ import "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
64
+
65
+ // forge-lint: disable-next-line(unaliased-plain-import)
66
+ import "../../src/JB721TiersHook.sol";
67
+ // forge-lint: disable-next-line(unaliased-plain-import)
68
+ import "../../src/JB721TiersHookDeployer.sol";
69
+ // forge-lint: disable-next-line(unaliased-plain-import)
70
+ import "../../src/JB721TiersHookProjectDeployer.sol";
71
+ // forge-lint: disable-next-line(unaliased-plain-import)
72
+ import "../../src/JB721TiersHookStore.sol";
73
+ // forge-lint: disable-next-line(unaliased-plain-import)
74
+ import "../../src/interfaces/IJB721TiersHook.sol";
75
+ // forge-lint: disable-next-line(unaliased-plain-import)
76
+ import "../../src/structs/JBDeploy721TiersHookConfig.sol";
77
+ // forge-lint: disable-next-line(unaliased-plain-import)
78
+ import "../../src/structs/JBLaunchProjectConfig.sol";
79
+ // forge-lint: disable-next-line(unaliased-plain-import)
80
+ import "../../src/structs/JBPayDataHookRulesetConfig.sol";
81
+ // forge-lint: disable-next-line(unaliased-plain-import)
82
+ import "../../src/structs/JBPayDataHookRulesetMetadata.sol";
83
+
84
+ /// @notice Mock ERC20 with 6 decimals (USDC-like).
85
+ contract MockUSDC6_CashOut is ERC20 {
86
+ constructor() ERC20("Mock USDC", "USDC") {}
87
+
88
+ function decimals() public pure override returns (uint8) {
89
+ return 6;
90
+ }
91
+
92
+ function mint(address to, uint256 amount) external {
93
+ _mint(to, amount);
94
+ }
95
+ }
96
+
97
+ /// @title ERC20CashOutFork
98
+ /// @notice Fork tests for ERC-20 (USDC) cashout with JB721TiersHook: bonding curve math, fee deduction, NFT burning.
99
+ /// @dev Run with: forge test --match-contract ERC20CashOutFork -vvv --fork-url $RPC
100
+ contract ERC20CashOutFork is Test {
101
+ using JBRulesetMetadataResolver for JBRuleset;
102
+
103
+ // =========================================================================
104
+ // Constants
105
+ // =========================================================================
106
+
107
+ uint256 constant FEE = 25;
108
+ uint256 constant MAX_FEE = 1000;
109
+
110
+ // =========================================================================
111
+ // Actors
112
+ // =========================================================================
113
+
114
+ address multisig = address(0xBEEF);
115
+ address payer = makeAddr("payer");
116
+ address beneficiary = makeAddr("beneficiary");
117
+ address reserveBeneficiary = makeAddr("reserveBeneficiary");
118
+
119
+ // =========================================================================
120
+ // JB Core
121
+ // =========================================================================
122
+
123
+ JBPermissions jbPermissions;
124
+ JBProjects jbProjects;
125
+ JBDirectory jbDirectory;
126
+ JBRulesets jbRulesets;
127
+ JBTokens jbTokens;
128
+ JBSplits jbSplits;
129
+ JBFundAccessLimits jbFundAccessLimits;
130
+ JBFeelessAddresses jbFeelessAddresses;
131
+ JBPrices jbPrices;
132
+ JBController jbController;
133
+ JBTerminalStore jbTerminalStore;
134
+ JBMultiTerminal jbMultiTerminal;
135
+
136
+ // =========================================================================
137
+ // 721 Hook
138
+ // =========================================================================
139
+
140
+ JB721TiersHookStore store;
141
+ JB721TiersHook hookImpl;
142
+ JB721TiersHookDeployer hookDeployer;
143
+ JB721TiersHookProjectDeployer projectDeployer;
144
+ MetadataResolverHelper metadataHelper;
145
+ JBAddressRegistry addressRegistry;
146
+
147
+ // =========================================================================
148
+ // Token
149
+ // =========================================================================
150
+
151
+ MockUSDC6_CashOut usdc;
152
+
153
+ // =========================================================================
154
+ // Setup
155
+ // =========================================================================
156
+
157
+ receive() external payable {}
158
+
159
+ function setUp() public {
160
+ vm.createSelectFork("ethereum");
161
+
162
+ _deployJBCore();
163
+ _deploy721Hook();
164
+
165
+ usdc = new MockUSDC6_CashOut();
166
+ usdc.mint(payer, 1_000_000e6);
167
+
168
+ vm.deal(payer, 10 ether);
169
+ vm.deal(multisig, 10 ether);
170
+ vm.deal(beneficiary, 10 ether);
171
+ }
172
+
173
+ // forge-lint: disable-next-line(mixed-case-function)
174
+ function _deployJBCore() internal {
175
+ jbPermissions = new JBPermissions(address(0));
176
+ jbProjects = new JBProjects(multisig, address(0), address(0));
177
+ jbDirectory = new JBDirectory(jbPermissions, jbProjects, multisig);
178
+ JBERC20 jbErc20 = new JBERC20();
179
+ jbTokens = new JBTokens(jbDirectory, jbErc20);
180
+ jbRulesets = new JBRulesets(jbDirectory);
181
+ jbPrices = new JBPrices(jbDirectory, jbPermissions, jbProjects, multisig, address(0));
182
+ jbSplits = new JBSplits(jbDirectory);
183
+ jbFundAccessLimits = new JBFundAccessLimits(jbDirectory);
184
+ jbFeelessAddresses = new JBFeelessAddresses(multisig);
185
+
186
+ jbController = new JBController(
187
+ jbDirectory,
188
+ jbFundAccessLimits,
189
+ jbPermissions,
190
+ jbPrices,
191
+ jbProjects,
192
+ jbRulesets,
193
+ jbSplits,
194
+ jbTokens,
195
+ address(0),
196
+ address(0)
197
+ );
198
+
199
+ vm.prank(multisig);
200
+ jbDirectory.setIsAllowedToSetFirstController(address(jbController), true);
201
+
202
+ jbTerminalStore = new JBTerminalStore(jbDirectory, jbPrices, jbRulesets);
203
+
204
+ jbMultiTerminal = new JBMultiTerminal(
205
+ jbFeelessAddresses,
206
+ jbPermissions,
207
+ jbProjects,
208
+ jbSplits,
209
+ jbTerminalStore,
210
+ jbTokens,
211
+ IPermit2(address(0)),
212
+ address(0)
213
+ );
214
+ }
215
+
216
+ function _deploy721Hook() internal {
217
+ store = new JB721TiersHookStore();
218
+ hookImpl = new JB721TiersHook(
219
+ jbDirectory, jbPermissions, jbPrices, jbRulesets, store, IJBSplits(address(jbSplits)), address(0)
220
+ );
221
+ addressRegistry = new JBAddressRegistry();
222
+ hookDeployer = new JB721TiersHookDeployer(hookImpl, store, addressRegistry, address(0));
223
+ projectDeployer = new JB721TiersHookProjectDeployer(
224
+ IJBDirectory(jbDirectory), IJBPermissions(jbPermissions), hookDeployer, address(0)
225
+ );
226
+ metadataHelper = new MetadataResolverHelper();
227
+ }
228
+
229
+ // =========================================================================
230
+ // Launch Helper
231
+ // =========================================================================
232
+
233
+ /// @dev Launch a USDC-denominated project with cashout enabled.
234
+ // forge-lint: disable-next-line(mixed-case-function)
235
+ function _launchUSDCProject(
236
+ JB721TierConfig[] memory tierConfigs,
237
+ uint16 cashOutTaxRate
238
+ )
239
+ internal
240
+ returns (uint256 projectId, address dataHook)
241
+ {
242
+ // forge-lint: disable-next-line(unsafe-typecast)
243
+ uint32 currency = uint32(uint160(address(usdc)));
244
+
245
+ JBDeploy721TiersHookConfig memory hookConfig = JBDeploy721TiersHookConfig({
246
+ name: "TestNFT",
247
+ symbol: "TNFT",
248
+ baseUri: "ipfs://base/",
249
+ tokenUriResolver: IJB721TokenUriResolver(address(0)),
250
+ contractUri: "ipfs://contract",
251
+ tiersConfig: JB721InitTiersConfig({tiers: tierConfigs, currency: currency, decimals: 6}),
252
+ reserveBeneficiary: reserveBeneficiary,
253
+ flags: JB721TiersHookFlags({
254
+ preventOverspending: false,
255
+ issueTokensForSplits: false,
256
+ noNewTiersWithReserves: false,
257
+ noNewTiersWithVotes: false,
258
+ noNewTiersWithOwnerMinting: false
259
+ })
260
+ });
261
+
262
+ JBPayDataHookRulesetMetadata memory rulesetMetadata = JBPayDataHookRulesetMetadata({
263
+ reservedPercent: 0,
264
+ cashOutTaxRate: cashOutTaxRate,
265
+ baseCurrency: currency,
266
+ pausePay: false,
267
+ pauseCreditTransfers: false,
268
+ allowOwnerMinting: true,
269
+ allowSetCustomToken: false,
270
+ allowTerminalMigration: false,
271
+ allowSetTerminals: false,
272
+ allowSetController: false,
273
+ allowAddAccountingContext: false,
274
+ allowAddPriceFeed: false,
275
+ ownerMustSendPayouts: false,
276
+ holdFees: false,
277
+ useTotalSurplusForCashOuts: false,
278
+ useDataHookForCashOut: true,
279
+ metadata: 0x00
280
+ });
281
+
282
+ JBPayDataHookRulesetConfig[] memory rulesetConfigs = new JBPayDataHookRulesetConfig[](1);
283
+ rulesetConfigs[0].mustStartAtOrAfter = 0;
284
+ rulesetConfigs[0].duration = 0;
285
+ rulesetConfigs[0].weight = 1_000_000e18;
286
+ rulesetConfigs[0].weightCutPercent = 0;
287
+ rulesetConfigs[0].approvalHook = IJBRulesetApprovalHook(address(0));
288
+ rulesetConfigs[0].metadata = rulesetMetadata;
289
+
290
+ JBAccountingContext[] memory accountingContexts = new JBAccountingContext[](1);
291
+ accountingContexts[0] = JBAccountingContext({token: address(usdc), currency: currency, decimals: 6});
292
+
293
+ JBTerminalConfig[] memory terminalConfigs = new JBTerminalConfig[](1);
294
+ terminalConfigs[0] =
295
+ JBTerminalConfig({terminal: jbMultiTerminal, accountingContextsToAccept: accountingContexts});
296
+
297
+ JBLaunchProjectConfig memory launchConfig = JBLaunchProjectConfig({
298
+ projectUri: "test-erc20-cashout-project",
299
+ rulesetConfigurations: rulesetConfigs,
300
+ terminalConfigurations: terminalConfigs,
301
+ memo: ""
302
+ });
303
+
304
+ IJB721TiersHook hookInstance;
305
+ (projectId, hookInstance) =
306
+ projectDeployer.launchProjectFor(multisig, hookConfig, launchConfig, jbController, bytes32(0));
307
+
308
+ dataHook = address(hookInstance);
309
+ }
310
+
311
+ // =========================================================================
312
+ // Metadata Helpers
313
+ // =========================================================================
314
+
315
+ function _buildPayMetadata(uint16[] memory tierIds, bool allowOverspending) internal view returns (bytes memory) {
316
+ bytes[] memory data = new bytes[](1);
317
+ data[0] = abi.encode(allowOverspending, tierIds);
318
+ bytes4[] memory ids = new bytes4[](1);
319
+ ids[0] = JBMetadataResolver.getId("pay", address(hookImpl));
320
+ return metadataHelper.createMetadata(ids, data);
321
+ }
322
+
323
+ function _buildCashOutMetadata(uint256[] memory tokenIds) internal view returns (bytes memory) {
324
+ bytes[] memory data = new bytes[](1);
325
+ data[0] = abi.encode(tokenIds);
326
+ bytes4[] memory ids = new bytes4[](1);
327
+ ids[0] = JBMetadataResolver.getId("cashOut", address(hookImpl));
328
+ return metadataHelper.createMetadata(ids, data);
329
+ }
330
+
331
+ function _tokenId(uint256 tierId, uint256 mintNumber) internal pure returns (uint256) {
332
+ return tierId * 1_000_000_000 + mintNumber;
333
+ }
334
+
335
+ // =========================================================================
336
+ // Test 1: ERC-20 cashout returns correct amount via bonding curve at 6 decimals
337
+ // =========================================================================
338
+
339
+ /// @notice Pay USDC to mint 721 NFTs, cashout, verify USDC returned via bonding curve math at 6 decimals.
340
+ function testFork_ERC20CashOutReturnsCorrectAmount() public {
341
+ // Create 1 tier: 100 USDC, supply 10.
342
+ JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
343
+ tierConfigs[0] = JB721TierConfig({
344
+ price: 100e6,
345
+ initialSupply: 10,
346
+ votingUnits: 0,
347
+ reserveFrequency: 0,
348
+ reserveBeneficiary: address(0),
349
+ // forge-lint: disable-next-line(unsafe-typecast)
350
+ encodedIPFSUri: bytes32("tier1"),
351
+ category: 1,
352
+ discountPercent: 0,
353
+ allowOwnerMint: false,
354
+ useReserveBeneficiaryAsDefault: false,
355
+ transfersPausable: false,
356
+ useVotingUnits: false,
357
+ cannotBeRemoved: false,
358
+ cannotIncreaseDiscountPercent: false,
359
+ splitPercent: 0,
360
+ splits: new JBSplit[](0)
361
+ });
362
+
363
+ // 50% cashout tax rate (5000 out of 10000).
364
+ (uint256 projectId, address hook) = _launchUSDCProject(tierConfigs, 5000);
365
+
366
+ // Pay 100 USDC to mint 1 NFT from tier 1.
367
+ uint16[] memory tierIds = new uint16[](1);
368
+ tierIds[0] = 1;
369
+ bytes memory payMeta = _buildPayMetadata(tierIds, true);
370
+
371
+ vm.startPrank(payer);
372
+ usdc.approve(address(jbMultiTerminal), 100e6);
373
+ jbMultiTerminal.pay({
374
+ projectId: projectId,
375
+ amount: 100e6,
376
+ token: address(usdc),
377
+ beneficiary: beneficiary,
378
+ minReturnedTokens: 0,
379
+ memo: "",
380
+ metadata: payMeta
381
+ });
382
+ vm.stopPrank();
383
+
384
+ assertEq(IERC721(hook).balanceOf(beneficiary), 1, "beneficiary should own 1 NFT");
385
+
386
+ // Cash out the NFT.
387
+ uint256[] memory tokensToCashOut = new uint256[](1);
388
+ tokensToCashOut[0] = _tokenId(1, 1);
389
+ bytes memory cashOutMeta = _buildCashOutMetadata(tokensToCashOut);
390
+
391
+ uint256 usdcBefore = usdc.balanceOf(beneficiary);
392
+
393
+ vm.prank(beneficiary);
394
+ jbMultiTerminal.cashOutTokensOf({
395
+ holder: beneficiary,
396
+ projectId: projectId,
397
+ tokenToReclaim: address(usdc),
398
+ cashOutCount: 0,
399
+ minTokensReclaimed: 0,
400
+ beneficiary: payable(beneficiary),
401
+ metadata: cashOutMeta
402
+ });
403
+
404
+ uint256 usdcAfter = usdc.balanceOf(beneficiary);
405
+ uint256 reclaimed = usdcAfter - usdcBefore;
406
+
407
+ // With a single payer who is the only holder and 50% cashout tax rate:
408
+ // Bonding curve: base * [(MAX - tax) + tax * (count / supply)] / MAX
409
+ // With count == supply (sole holder cashing out everything):
410
+ // base * [(10000 - 5000) + 5000 * (count/supply)] / 10000 = base * 1 = base (full surplus)
411
+ // Then a 2.5% fee is deducted: net = surplus * (1000 - 25) / 1000
412
+ // surplus = 100e6, net = 100e6 * 975 / 1000 = 97_500_000 = 97.5 USDC
413
+ assertGt(reclaimed, 0, "should have reclaimed some USDC");
414
+ // The reclaim should be close to 97.5 USDC (97_500_000), accounting for potential rounding.
415
+ // With sole holder and count == supply, bonding curve returns full surplus minus fee.
416
+ uint256 expectedNetOfFee = mulDiv(100e6, MAX_FEE - FEE, MAX_FEE);
417
+ assertEq(reclaimed, expectedNetOfFee, "reclaimed USDC should match bonding curve minus 2.5% fee");
418
+ }
419
+
420
+ // =========================================================================
421
+ // Test 2: 2.5% fee held on ERC-20 cashout
422
+ // =========================================================================
423
+
424
+ /// @notice Verify 2.5% fee is held on ERC-20 cashout by checking the difference between gross and net reclaim.
425
+ function testFork_ERC20CashOutFeeDeduction() public {
426
+ // 1 tier: 200 USDC, supply 10.
427
+ JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
428
+ tierConfigs[0] = JB721TierConfig({
429
+ price: 200e6,
430
+ initialSupply: 10,
431
+ votingUnits: 0,
432
+ reserveFrequency: 0,
433
+ reserveBeneficiary: address(0),
434
+ // forge-lint: disable-next-line(unsafe-typecast)
435
+ encodedIPFSUri: bytes32("tier1"),
436
+ category: 1,
437
+ discountPercent: 0,
438
+ allowOwnerMint: false,
439
+ useReserveBeneficiaryAsDefault: false,
440
+ transfersPausable: false,
441
+ useVotingUnits: false,
442
+ cannotBeRemoved: false,
443
+ cannotIncreaseDiscountPercent: false,
444
+ splitPercent: 0,
445
+ splits: new JBSplit[](0)
446
+ });
447
+
448
+ // Use nonzero cashOutTaxRate so the protocol fee (2.5%) is charged on cashouts.
449
+ // With a sole holder (cashOutCount == totalSupply), bonding curve still returns full surplus.
450
+ (uint256 projectId,) = _launchUSDCProject(tierConfigs, 1);
451
+
452
+ // Pay 200 USDC to mint 1 NFT.
453
+ uint16[] memory tierIds = new uint16[](1);
454
+ tierIds[0] = 1;
455
+ bytes memory payMeta = _buildPayMetadata(tierIds, true);
456
+
457
+ vm.startPrank(payer);
458
+ usdc.approve(address(jbMultiTerminal), 200e6);
459
+ jbMultiTerminal.pay({
460
+ projectId: projectId,
461
+ amount: 200e6,
462
+ token: address(usdc),
463
+ beneficiary: beneficiary,
464
+ minReturnedTokens: 0,
465
+ memo: "",
466
+ metadata: payMeta
467
+ });
468
+ vm.stopPrank();
469
+
470
+ // Cash out.
471
+ uint256[] memory tokensToCashOut = new uint256[](1);
472
+ tokensToCashOut[0] = _tokenId(1, 1);
473
+ bytes memory cashOutMeta = _buildCashOutMetadata(tokensToCashOut);
474
+
475
+ uint256 usdcBefore = usdc.balanceOf(beneficiary);
476
+
477
+ vm.prank(beneficiary);
478
+ jbMultiTerminal.cashOutTokensOf({
479
+ holder: beneficiary,
480
+ projectId: projectId,
481
+ tokenToReclaim: address(usdc),
482
+ cashOutCount: 0,
483
+ minTokensReclaimed: 0,
484
+ beneficiary: payable(beneficiary),
485
+ metadata: cashOutMeta
486
+ });
487
+
488
+ uint256 reclaimed = usdc.balanceOf(beneficiary) - usdcBefore;
489
+
490
+ // With 0% tax + sole holder: bonding curve returns full surplus = 200 USDC.
491
+ // Fee = 200e6 * 25 / 1000 = 5_000_000 (5 USDC).
492
+ // Net = 200e6 - 5e6 = 195_000_000 (195 USDC).
493
+ uint256 grossReclaim = 200e6;
494
+ uint256 expectedFee = mulDiv(grossReclaim, FEE, MAX_FEE);
495
+ uint256 expectedNet = grossReclaim - expectedFee;
496
+
497
+ assertEq(expectedFee, 5e6, "fee should be 5 USDC (2.5% of 200)");
498
+ assertEq(reclaimed, expectedNet, "beneficiary should receive gross minus 2.5% fee");
499
+
500
+ // The terminal should still hold the fee amount (held for 28 days).
501
+ // Verify the terminal USDC balance is exactly the fee amount (project balance is 0 after full cashout).
502
+ uint256 terminalBalance = usdc.balanceOf(address(jbMultiTerminal));
503
+ assertEq(terminalBalance, expectedFee, "terminal should hold the fee amount in USDC");
504
+ }
505
+
506
+ // =========================================================================
507
+ // Test 3: 721 NFTs burned during ERC-20 cashout
508
+ // =========================================================================
509
+
510
+ /// @notice Verify 721 NFTs are burned during cashout (regardless of ERC-20 token type).
511
+ function testFork_ERC20CashOutBurnsNFTs() public {
512
+ // 2 tiers: 50 USDC and 150 USDC.
513
+ JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](2);
514
+ tierConfigs[0] = JB721TierConfig({
515
+ price: 50e6,
516
+ initialSupply: 10,
517
+ votingUnits: 0,
518
+ reserveFrequency: 0,
519
+ reserveBeneficiary: address(0),
520
+ // forge-lint: disable-next-line(unsafe-typecast)
521
+ encodedIPFSUri: bytes32("tier1"),
522
+ category: 1,
523
+ discountPercent: 0,
524
+ allowOwnerMint: false,
525
+ useReserveBeneficiaryAsDefault: false,
526
+ transfersPausable: false,
527
+ useVotingUnits: false,
528
+ cannotBeRemoved: false,
529
+ cannotIncreaseDiscountPercent: false,
530
+ splitPercent: 0,
531
+ splits: new JBSplit[](0)
532
+ });
533
+ tierConfigs[1] = JB721TierConfig({
534
+ price: 150e6,
535
+ initialSupply: 10,
536
+ votingUnits: 0,
537
+ reserveFrequency: 0,
538
+ reserveBeneficiary: address(0),
539
+ // forge-lint: disable-next-line(unsafe-typecast)
540
+ encodedIPFSUri: bytes32("tier2"),
541
+ category: 2,
542
+ discountPercent: 0,
543
+ allowOwnerMint: false,
544
+ useReserveBeneficiaryAsDefault: false,
545
+ transfersPausable: false,
546
+ useVotingUnits: false,
547
+ cannotBeRemoved: false,
548
+ cannotIncreaseDiscountPercent: false,
549
+ splitPercent: 0,
550
+ splits: new JBSplit[](0)
551
+ });
552
+
553
+ // 50% cashout tax rate.
554
+ (uint256 projectId, address hook) = _launchUSDCProject(tierConfigs, 5000);
555
+
556
+ // Pay 200 USDC to mint 1 NFT from each tier.
557
+ uint16[] memory tierIds = new uint16[](2);
558
+ tierIds[0] = 1;
559
+ tierIds[1] = 2;
560
+ bytes memory payMeta = _buildPayMetadata(tierIds, true);
561
+
562
+ vm.startPrank(payer);
563
+ usdc.approve(address(jbMultiTerminal), 200e6);
564
+ jbMultiTerminal.pay({
565
+ projectId: projectId,
566
+ amount: 200e6,
567
+ token: address(usdc),
568
+ beneficiary: beneficiary,
569
+ minReturnedTokens: 0,
570
+ memo: "",
571
+ metadata: payMeta
572
+ });
573
+ vm.stopPrank();
574
+
575
+ assertEq(IERC721(hook).balanceOf(beneficiary), 2, "beneficiary should own 2 NFTs");
576
+ assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), beneficiary, "owns tier 1 NFT");
577
+ assertEq(IERC721(hook).ownerOf(_tokenId(2, 1)), beneficiary, "owns tier 2 NFT");
578
+
579
+ // Verify store burn counts are 0 before cashout.
580
+ assertEq(store.numberOfBurnedFor(hook, 1), 0, "tier 1: no burns before cashout");
581
+ assertEq(store.numberOfBurnedFor(hook, 2), 0, "tier 2: no burns before cashout");
582
+
583
+ // Cash out both NFTs.
584
+ uint256[] memory tokensToCashOut = new uint256[](2);
585
+ tokensToCashOut[0] = _tokenId(1, 1);
586
+ tokensToCashOut[1] = _tokenId(2, 1);
587
+ bytes memory cashOutMeta = _buildCashOutMetadata(tokensToCashOut);
588
+
589
+ vm.prank(beneficiary);
590
+ jbMultiTerminal.cashOutTokensOf({
591
+ holder: beneficiary,
592
+ projectId: projectId,
593
+ tokenToReclaim: address(usdc),
594
+ cashOutCount: 0,
595
+ minTokensReclaimed: 0,
596
+ beneficiary: payable(beneficiary),
597
+ metadata: cashOutMeta
598
+ });
599
+
600
+ // Verify NFTs are burned.
601
+ assertEq(IERC721(hook).balanceOf(beneficiary), 0, "all NFTs should be burned");
602
+ assertEq(store.numberOfBurnedFor(hook, 1), 1, "tier 1: 1 NFT burned");
603
+ assertEq(store.numberOfBurnedFor(hook, 2), 1, "tier 2: 1 NFT burned");
604
+
605
+ // Verify ownerOf reverts for burned tokens (ERC721 standard behavior).
606
+ vm.expectRevert();
607
+ IERC721(hook).ownerOf(_tokenId(1, 1));
608
+
609
+ vm.expectRevert();
610
+ IERC721(hook).ownerOf(_tokenId(2, 1));
611
+ }
612
+ }