@bananapus/721-hook-v6 0.0.41 → 0.0.43

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