@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
package/test/Fork.t.sol DELETED
@@ -1,2346 +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/ERC721/IERC721.sol";
9
- // forge-lint: disable-next-line(unaliased-plain-import)
10
- import "@bananapus/core-v6/src/JBController.sol";
11
- // forge-lint: disable-next-line(unaliased-plain-import)
12
- import "@bananapus/core-v6/src/JBDirectory.sol";
13
- // forge-lint: disable-next-line(unaliased-plain-import)
14
- import "@bananapus/core-v6/src/JBMultiTerminal.sol";
15
- // forge-lint: disable-next-line(unaliased-plain-import)
16
- import "@bananapus/core-v6/src/JBFundAccessLimits.sol";
17
- // forge-lint: disable-next-line(unaliased-plain-import)
18
- import "@bananapus/core-v6/src/JBFeelessAddresses.sol";
19
- // forge-lint: disable-next-line(unaliased-plain-import)
20
- import "@bananapus/core-v6/src/JBTerminalStore.sol";
21
- // forge-lint: disable-next-line(unaliased-plain-import)
22
- import "@bananapus/core-v6/src/JBRulesets.sol";
23
- // forge-lint: disable-next-line(unaliased-plain-import)
24
- import "@bananapus/core-v6/src/JBPermissions.sol";
25
- // forge-lint: disable-next-line(unaliased-plain-import)
26
- import "@bananapus/core-v6/src/JBPrices.sol";
27
- import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
28
- // forge-lint: disable-next-line(unaliased-plain-import)
29
- import "@bananapus/core-v6/src/JBSplits.sol";
30
- // forge-lint: disable-next-line(unaliased-plain-import)
31
- import "@bananapus/core-v6/src/JBERC20.sol";
32
- // forge-lint: disable-next-line(unaliased-plain-import)
33
- import "@bananapus/core-v6/src/JBTokens.sol";
34
- // forge-lint: disable-next-line(unaliased-plain-import)
35
- import "@bananapus/core-v6/src/libraries/JBConstants.sol";
36
- // forge-lint: disable-next-line(unaliased-plain-import)
37
- import "@bananapus/core-v6/src/libraries/JBRulesetMetadataResolver.sol";
38
- // forge-lint: disable-next-line(unaliased-plain-import)
39
- import "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
40
- // forge-lint: disable-next-line(unaliased-plain-import)
41
- import "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
42
- // forge-lint: disable-next-line(unaliased-plain-import)
43
- import "@bananapus/core-v6/src/structs/JBSplit.sol";
44
- // forge-lint: disable-next-line(unaliased-plain-import)
45
- import "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
46
- // forge-lint: disable-next-line(unaliased-plain-import)
47
- import "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
48
- // forge-lint: disable-next-line(unaliased-plain-import)
49
- import "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
50
- // forge-lint: disable-next-line(unaliased-plain-import)
51
- import "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
52
- // forge-lint: disable-next-line(unaliased-plain-import)
53
- import "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
54
- import {MetadataResolverHelper} from "@bananapus/core-v6/test/helpers/MetadataResolverHelper.sol";
55
- // forge-lint: disable-next-line(unused-import)
56
- import {mulDiv} from "@prb/math/src/Common.sol";
57
-
58
- // forge-lint: disable-next-line(unaliased-plain-import)
59
- import "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
60
-
61
- // forge-lint: disable-next-line(unaliased-plain-import)
62
- import "../src/JB721TiersHook.sol";
63
- // forge-lint: disable-next-line(unaliased-plain-import)
64
- import "../src/JB721TiersHookDeployer.sol";
65
- // forge-lint: disable-next-line(unaliased-plain-import)
66
- import "../src/JB721TiersHookProjectDeployer.sol";
67
- // forge-lint: disable-next-line(unaliased-plain-import)
68
- import "../src/JB721TiersHookStore.sol";
69
- import {JB721CheckpointsDeployer} from "../src/JB721CheckpointsDeployer.sol";
70
- import {IJB721CheckpointsDeployer} from "../src/interfaces/IJB721CheckpointsDeployer.sol";
71
- // forge-lint: disable-next-line(unaliased-plain-import)
72
- import "../src/interfaces/IJB721TiersHook.sol";
73
- // forge-lint: disable-next-line(unaliased-plain-import)
74
- import "../src/structs/JBDeploy721TiersHookConfig.sol";
75
- // forge-lint: disable-next-line(unaliased-plain-import)
76
- import "../src/structs/JBLaunchProjectConfig.sol";
77
- // forge-lint: disable-next-line(unaliased-plain-import)
78
- import "../src/structs/JBPayDataHookRulesetConfig.sol";
79
- // forge-lint: disable-next-line(unaliased-plain-import)
80
- import "../src/structs/JBPayDataHookRulesetMetadata.sol";
81
- // forge-lint: disable-next-line(unaliased-plain-import)
82
- import "../src/structs/JB721TiersRulesetMetadata.sol";
83
- // forge-lint: disable-next-line(unaliased-plain-import)
84
- import "../src/libraries/JB721TiersRulesetMetadataResolver.sol";
85
- // forge-lint: disable-next-line(unaliased-plain-import)
86
- import "../src/libraries/JB721Constants.sol";
87
- import {JB721TierConfigFlags} from "../src/structs/JB721TierConfigFlags.sol";
88
-
89
- /// @title Fork_721Hook_Test
90
- /// @notice Comprehensive fork tests for JB721TiersHook: lifecycle, features, flags, and adversarial conditions.
91
- /// @dev Run with: RPC_ETHEREUM_MAINNET=<rpc_url> forge test --match-contract Fork_721Hook_Test -vvv
92
- contract Fork_721Hook_Test is Test {
93
- using JBRulesetMetadataResolver for JBRuleset;
94
-
95
- // =========================================================================
96
- // Constants
97
- // =========================================================================
98
-
99
- address constant NATIVE_TOKEN = JBConstants.NATIVE_TOKEN;
100
-
101
- // =========================================================================
102
- // Actors
103
- // =========================================================================
104
-
105
- address multisig = address(0xBEEF);
106
- address payer = makeAddr("payer");
107
- address beneficiary = makeAddr("beneficiary");
108
- address reserveBeneficiary = makeAddr("reserveBeneficiary");
109
- address attacker = makeAddr("attacker");
110
-
111
- // =========================================================================
112
- // JB Core
113
- // =========================================================================
114
-
115
- JBPermissions jbPermissions;
116
- JBProjects jbProjects;
117
- JBDirectory jbDirectory;
118
- JBRulesets jbRulesets;
119
- JBTokens jbTokens;
120
- JBSplits jbSplits;
121
- JBFundAccessLimits jbFundAccessLimits;
122
- JBFeelessAddresses jbFeelessAddresses;
123
- JBPrices jbPrices;
124
- JBController jbController;
125
- JBTerminalStore jbTerminalStore;
126
- JBMultiTerminal jbMultiTerminal;
127
-
128
- // =========================================================================
129
- // 721 Hook
130
- // =========================================================================
131
-
132
- JB721TiersHookStore store;
133
- JB721TiersHook hookImpl;
134
- JB721TiersHookDeployer hookDeployer;
135
- JB721TiersHookProjectDeployer projectDeployer;
136
- MetadataResolverHelper metadataHelper;
137
- JBAddressRegistry addressRegistry;
138
-
139
- // =========================================================================
140
- // IPFS URIs (reusable)
141
- // =========================================================================
142
-
143
- bytes32 constant IPFS_URI = 0x7D5A99F603F231D53A4F39D1521F98D2E8BB279CF29BEBFD0687DC98458E7F89;
144
-
145
- // =========================================================================
146
- // Events (for expectEmit)
147
- // =========================================================================
148
-
149
- event Mint(
150
- uint256 indexed tokenId,
151
- uint256 indexed tierId,
152
- address indexed beneficiary,
153
- uint256 totalAmountPaid,
154
- address caller
155
- );
156
- event Burn(uint256 indexed tokenId, address owner, address caller);
157
-
158
- // =========================================================================
159
- // Setup
160
- // =========================================================================
161
-
162
- /// @dev Accept ETH for cashout returns.
163
- receive() external payable {}
164
-
165
- function setUp() public {
166
- vm.createSelectFork("ethereum");
167
-
168
- _deployJBCore();
169
- _deploy721Hook();
170
-
171
- vm.deal(payer, 1000 ether);
172
- vm.deal(beneficiary, 100 ether);
173
- vm.deal(multisig, 100 ether);
174
- vm.deal(attacker, 100 ether);
175
-
176
- vm.label(multisig, "multisig");
177
- vm.label(payer, "payer");
178
- vm.label(beneficiary, "beneficiary");
179
- vm.label(reserveBeneficiary, "reserveBeneficiary");
180
- vm.label(attacker, "attacker");
181
- }
182
-
183
- // forge-lint: disable-next-line(mixed-case-function)
184
- function _deployJBCore() internal {
185
- jbPermissions = new JBPermissions(address(0));
186
- jbProjects = new JBProjects(multisig, address(0), address(0));
187
- jbDirectory = new JBDirectory(jbPermissions, jbProjects, multisig);
188
- JBERC20 jbErc20 = new JBERC20(jbPermissions, jbProjects);
189
- jbTokens = new JBTokens(jbDirectory, jbErc20);
190
- jbRulesets = new JBRulesets(jbDirectory);
191
- jbPrices = new JBPrices(jbDirectory, jbPermissions, jbProjects, multisig, address(0));
192
- jbSplits = new JBSplits(jbDirectory);
193
- jbFundAccessLimits = new JBFundAccessLimits(jbDirectory);
194
- jbFeelessAddresses = new JBFeelessAddresses(multisig);
195
-
196
- jbController = new JBController(
197
- jbDirectory,
198
- jbFundAccessLimits,
199
- jbPermissions,
200
- jbPrices,
201
- jbProjects,
202
- jbRulesets,
203
- jbSplits,
204
- jbTokens,
205
- address(0), // omnichainRulesetOperator
206
- address(0) // trustedForwarder
207
- );
208
-
209
- vm.prank(multisig);
210
- jbDirectory.setIsAllowedToSetFirstController(address(jbController), true);
211
-
212
- jbTerminalStore = new JBTerminalStore(jbDirectory, jbPrices, jbRulesets);
213
-
214
- jbMultiTerminal = new JBMultiTerminal(
215
- jbFeelessAddresses,
216
- jbPermissions,
217
- jbProjects,
218
- jbSplits,
219
- jbTerminalStore,
220
- jbTokens,
221
- IPermit2(address(0)), // Permit2 disabled for simplicity
222
- address(0) // trustedForwarder
223
- );
224
-
225
- vm.label(address(jbPermissions), "JBPermissions");
226
- vm.label(address(jbProjects), "JBProjects");
227
- vm.label(address(jbDirectory), "JBDirectory");
228
- vm.label(address(jbController), "JBController");
229
- vm.label(address(jbMultiTerminal), "JBMultiTerminal");
230
- vm.label(address(jbTerminalStore), "JBTerminalStore");
231
- }
232
-
233
- function _deploy721Hook() internal {
234
- store = new JB721TiersHookStore();
235
- hookImpl = new JB721TiersHook(
236
- jbDirectory,
237
- jbPermissions,
238
- jbPrices,
239
- jbRulesets,
240
- store,
241
- IJBSplits(address(jbSplits)),
242
- IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
243
- address(0)
244
- );
245
- addressRegistry = new JBAddressRegistry();
246
- hookDeployer = new JB721TiersHookDeployer(hookImpl, store, addressRegistry, address(0));
247
- projectDeployer = new JB721TiersHookProjectDeployer(
248
- IJBDirectory(jbDirectory), IJBPermissions(jbPermissions), hookDeployer, address(0)
249
- );
250
- metadataHelper = new MetadataResolverHelper();
251
-
252
- vm.label(address(store), "JB721TiersHookStore");
253
- vm.label(address(hookImpl), "JB721TiersHook_impl");
254
- vm.label(address(projectDeployer), "JB721TiersHookProjectDeployer");
255
- }
256
-
257
- // =========================================================================
258
- // Project Launch Helpers
259
- // =========================================================================
260
-
261
- /// @dev Launch a project with 721 hook. Returns projectId and hook address.
262
- function _launchProject(
263
- JB721TierConfig[] memory tierConfigs,
264
- JB721TiersHookFlags memory flags,
265
- uint16 cashOutTaxRate,
266
- bool useDataHookForCashOut,
267
- uint16 metadata721
268
- )
269
- internal
270
- returns (uint256 projectId, address dataHook)
271
- {
272
- JBDeploy721TiersHookConfig memory hookConfig = JBDeploy721TiersHookConfig({
273
- name: "TestNFT",
274
- symbol: "TNFT",
275
- baseUri: "ipfs://base/",
276
- tokenUriResolver: IJB721TokenUriResolver(address(0)),
277
- contractUri: "ipfs://contract",
278
- tiersConfig: JB721InitTiersConfig({
279
- // forge-lint: disable-next-line(unsafe-typecast)
280
- tiers: tierConfigs,
281
- // forge-lint: disable-next-line(unsafe-typecast)
282
- currency: uint32(uint160(NATIVE_TOKEN)),
283
- decimals: 18
284
- }),
285
- flags: flags
286
- });
287
-
288
- JBPayDataHookRulesetMetadata memory rulesetMetadata = JBPayDataHookRulesetMetadata({
289
- reservedPercent: 5000, // 50%
290
- cashOutTaxRate: cashOutTaxRate,
291
- // forge-lint: disable-next-line(unsafe-typecast)
292
- baseCurrency: uint32(uint160(NATIVE_TOKEN)),
293
- pausePay: false,
294
- pauseCreditTransfers: false,
295
- allowOwnerMinting: true,
296
- allowSetCustomToken: false,
297
- allowTerminalMigration: false,
298
- allowSetTerminals: false,
299
- allowSetController: false,
300
- allowAddAccountingContext: false,
301
- allowAddPriceFeed: false,
302
- ownerMustSendPayouts: false,
303
- holdFees: false,
304
- useTotalSurplusForCashOuts: false,
305
- useDataHookForCashOut: useDataHookForCashOut,
306
- metadata: metadata721
307
- });
308
-
309
- JBPayDataHookRulesetConfig[] memory rulesetConfigs = new JBPayDataHookRulesetConfig[](1);
310
- rulesetConfigs[0].mustStartAtOrAfter = 0;
311
- rulesetConfigs[0].duration = 0; // Never expires
312
- rulesetConfigs[0].weight = 1_000_000e18; // 1M tokens per ETH
313
- rulesetConfigs[0].weightCutPercent = 0;
314
- rulesetConfigs[0].approvalHook = IJBRulesetApprovalHook(address(0));
315
- rulesetConfigs[0].metadata = rulesetMetadata;
316
-
317
- JBAccountingContext[] memory accountingContexts = new JBAccountingContext[](1);
318
- accountingContexts[0] =
319
- // forge-lint: disable-next-line(unsafe-typecast)
320
- JBAccountingContext({token: NATIVE_TOKEN, currency: uint32(uint160(NATIVE_TOKEN)), decimals: 18});
321
-
322
- JBTerminalConfig[] memory terminalConfigs = new JBTerminalConfig[](1);
323
- terminalConfigs[0] =
324
- JBTerminalConfig({terminal: jbMultiTerminal, accountingContextsToAccept: accountingContexts});
325
-
326
- JBLaunchProjectConfig memory launchConfig = JBLaunchProjectConfig({
327
- projectUri: "test-project",
328
- rulesetConfigurations: rulesetConfigs,
329
- terminalConfigurations: terminalConfigs,
330
- memo: ""
331
- });
332
-
333
- IJB721TiersHook hookInstance;
334
- (projectId, hookInstance) =
335
- projectDeployer.launchProjectFor(multisig, hookConfig, launchConfig, jbController, bytes32(0));
336
-
337
- dataHook = address(hookInstance);
338
- vm.label(dataHook, "hook_clone");
339
- }
340
-
341
- /// @dev Shorthand: launch with standard 10 tiers, no flags, 50% tax, cash out data hook enabled.
342
- function _launchStandardProject()
343
- internal
344
- returns (uint256 projectId, address dataHook, JB721TierConfig[] memory tierConfigs)
345
- {
346
- tierConfigs = _makeStandardTiers(10, 10, false);
347
- JB721TiersHookFlags memory flags = _defaultFlags();
348
- (projectId, dataHook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
349
- }
350
-
351
- function _makeStandardTiers(
352
- uint256 count,
353
- uint32 supplyPerTier,
354
- bool allowOwnerMint
355
- )
356
- internal
357
- view
358
- returns (JB721TierConfig[] memory tierConfigs)
359
- {
360
- tierConfigs = new JB721TierConfig[](count);
361
- for (uint256 i; i < count; i++) {
362
- tierConfigs[i] = JB721TierConfig({
363
- price: uint104((i + 1) * 0.01 ether),
364
- initialSupply: supplyPerTier,
365
- votingUnits: uint32((i + 1) * 10),
366
- reserveFrequency: 10,
367
- reserveBeneficiary: reserveBeneficiary,
368
- encodedIPFSUri: IPFS_URI,
369
- category: uint24(100),
370
- discountPercent: 0,
371
- flags: JB721TierConfigFlags({
372
- allowOwnerMint: allowOwnerMint,
373
- useReserveBeneficiaryAsDefault: false,
374
- transfersPausable: false,
375
- useVotingUnits: false,
376
- cantBeRemoved: false,
377
- cantIncreaseDiscountPercent: false,
378
- cantBuyWithCredits: false
379
- }),
380
- splitPercent: 0,
381
- splits: new JBSplit[](0)
382
- });
383
- }
384
- }
385
-
386
- function _defaultFlags() internal pure returns (JB721TiersHookFlags memory) {
387
- return JB721TiersHookFlags({
388
- preventOverspending: false,
389
- issueTokensForSplits: false,
390
- noNewTiersWithReserves: false,
391
- noNewTiersWithVotes: false,
392
- noNewTiersWithOwnerMinting: false
393
- });
394
- }
395
-
396
- // =========================================================================
397
- // Metadata Building Helpers
398
- // =========================================================================
399
-
400
- /// @dev Build pay metadata that requests specific tier IDs. `allowOverspending` controls revert behavior.
401
- function _buildPayMetadata(
402
- address,
403
- uint16[] memory tierIds,
404
- bool allowOverspending
405
- )
406
- internal
407
- view
408
- returns (bytes memory)
409
- {
410
- bytes[] memory data = new bytes[](1);
411
- data[0] = abi.encode(allowOverspending, tierIds);
412
- bytes4[] memory ids = new bytes4[](1);
413
- ids[0] = JBMetadataResolver.getId("pay", address(hookImpl));
414
- return metadataHelper.createMetadata(ids, data);
415
- }
416
-
417
- /// @dev Build cash out metadata that specifies token IDs to burn.
418
- function _buildCashOutMetadata(address, uint256[] memory tokenIds) internal view returns (bytes memory) {
419
- bytes[] memory data = new bytes[](1);
420
- data[0] = abi.encode(tokenIds);
421
- bytes4[] memory ids = new bytes4[](1);
422
- ids[0] = JBMetadataResolver.getId("cashOut", address(hookImpl));
423
- return metadataHelper.createMetadata(ids, data);
424
- }
425
-
426
- // =========================================================================
427
- // Token ID Helper
428
- // =========================================================================
429
-
430
- function _tokenId(uint256 tierId, uint256 mintNumber) internal pure returns (uint256) {
431
- return tierId * 1_000_000_000 + mintNumber;
432
- }
433
-
434
- // =========================================================================
435
- // Pay Helper
436
- // =========================================================================
437
-
438
- function _payAndMint(
439
- uint256 projectId,
440
- uint256 value,
441
- uint16[] memory tierIds,
442
- bool allowOverspending,
443
- address hook
444
- )
445
- internal
446
- returns (uint256 tokenCount)
447
- {
448
- bytes memory meta = _buildPayMetadata(hook, tierIds, allowOverspending);
449
- vm.prank(payer);
450
- tokenCount = jbMultiTerminal.pay{value: value}({
451
- projectId: projectId,
452
- amount: value,
453
- token: NATIVE_TOKEN,
454
- beneficiary: beneficiary,
455
- minReturnedTokens: 0,
456
- memo: "",
457
- metadata: meta
458
- });
459
- }
460
-
461
- // =====================================================================
462
- // SECTION 1: BASIC LIFECYCLE
463
- // =====================================================================
464
-
465
- /// @notice Launch project, pay to mint 1 NFT, verify ownership and balance.
466
- function test_fork_basicPayAndMint() public {
467
- (uint256 projectId, address hook,) = _launchStandardProject();
468
-
469
- // Mint from tier 1 (price = 0.01 ETH).
470
- uint16[] memory tierIds = new uint16[](1);
471
- tierIds[0] = 1;
472
-
473
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
474
-
475
- assertEq(IERC721(hook).balanceOf(beneficiary), 1, "should own 1 NFT");
476
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), beneficiary, "beneficiary should own token");
477
- assertEq(IJB721TiersHook(hook).firstOwnerOf(_tokenId(1, 1)), beneficiary, "firstOwner should be beneficiary");
478
- }
479
-
480
- /// @notice Pay to mint multiple NFTs from different tiers in one transaction.
481
- function test_fork_multiTierMintInOnePay() public {
482
- (uint256 projectId, address hook,) = _launchStandardProject();
483
-
484
- // Mint from tiers 1, 3, 5 (prices: 0.01 + 0.03 + 0.05 = 0.09 ETH).
485
- uint16[] memory tierIds = new uint16[](3);
486
- tierIds[0] = 1;
487
- tierIds[1] = 3;
488
- tierIds[2] = 5;
489
-
490
- _payAndMint(projectId, 0.09 ether, tierIds, true, hook);
491
-
492
- assertEq(IERC721(hook).balanceOf(beneficiary), 3, "should own 3 NFTs");
493
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), beneficiary, "owns tier 1 NFT");
494
- assertEq(IERC721(hook).ownerOf(_tokenId(3, 1)), beneficiary, "owns tier 3 NFT");
495
- assertEq(IERC721(hook).ownerOf(_tokenId(5, 1)), beneficiary, "owns tier 5 NFT");
496
- }
497
-
498
- /// @notice Mint multiple NFTs from the SAME tier in one payment.
499
- function test_fork_duplicateTierMint() public {
500
- (uint256 projectId, address hook,) = _launchStandardProject();
501
-
502
- uint16[] memory tierIds = new uint16[](3);
503
- tierIds[0] = 2;
504
- tierIds[1] = 2;
505
- tierIds[2] = 2;
506
-
507
- _payAndMint(projectId, 0.06 ether, tierIds, true, hook);
508
-
509
- assertEq(IERC721(hook).balanceOf(beneficiary), 3, "3 NFTs from same tier");
510
-
511
- JB721Tier memory tier = store.tierOf(hook, 2, false);
512
- assertEq(tier.remainingSupply, 7, "remaining supply should be 7");
513
- }
514
-
515
- // =====================================================================
516
- // SECTION 2: CASH OUT (REDEEM) LIFECYCLE
517
- // =====================================================================
518
-
519
- /// @notice Pay, mint NFT, cash out. Verify ETH reclaim and NFT burn.
520
- function test_fork_cashOutSingleNFT() public {
521
- (uint256 projectId, address hook,) = _launchStandardProject();
522
-
523
- uint16[] memory tierIds = new uint16[](1);
524
- tierIds[0] = 5; // Price: 0.05 ETH
525
-
526
- _payAndMint(projectId, 0.05 ether, tierIds, true, hook);
527
-
528
- assertEq(IERC721(hook).balanceOf(beneficiary), 1, "should own 1 NFT");
529
-
530
- // Cash out the NFT.
531
- uint256[] memory tokensToCashOut = new uint256[](1);
532
- tokensToCashOut[0] = _tokenId(5, 1);
533
- bytes memory cashOutMeta = _buildCashOutMetadata(hook, tokensToCashOut);
534
-
535
- uint256 balBefore = beneficiary.balance;
536
- vm.prank(beneficiary);
537
- jbMultiTerminal.cashOutTokensOf({
538
- holder: beneficiary,
539
- projectId: projectId,
540
- tokenToReclaim: NATIVE_TOKEN,
541
- cashOutCount: 0,
542
- minTokensReclaimed: 0,
543
- beneficiary: payable(beneficiary),
544
- metadata: cashOutMeta
545
- });
546
-
547
- assertEq(IERC721(hook).balanceOf(beneficiary), 0, "NFT should be burned");
548
- assertEq(store.numberOfBurnedFor(hook, 5), 1, "burn should be recorded");
549
- assertGt(beneficiary.balance, balBefore, "should have reclaimed some ETH");
550
- }
551
-
552
- /// @notice Pay with multiple tiers, cash out all at once.
553
- function test_fork_cashOutMultipleNFTs() public {
554
- (uint256 projectId, address hook,) = _launchStandardProject();
555
-
556
- uint16[] memory tierIds = new uint16[](3);
557
- tierIds[0] = 1;
558
- tierIds[1] = 2;
559
- tierIds[2] = 3;
560
-
561
- _payAndMint(projectId, 0.06 ether, tierIds, true, hook);
562
-
563
- uint256[] memory tokensToCashOut = new uint256[](3);
564
- tokensToCashOut[0] = _tokenId(1, 1);
565
- tokensToCashOut[1] = _tokenId(2, 1);
566
- tokensToCashOut[2] = _tokenId(3, 1);
567
- bytes memory cashOutMeta = _buildCashOutMetadata(hook, tokensToCashOut);
568
-
569
- vm.prank(beneficiary);
570
- jbMultiTerminal.cashOutTokensOf({
571
- holder: beneficiary,
572
- projectId: projectId,
573
- tokenToReclaim: NATIVE_TOKEN,
574
- cashOutCount: 0,
575
- minTokensReclaimed: 0,
576
- beneficiary: payable(beneficiary),
577
- metadata: cashOutMeta
578
- });
579
-
580
- assertEq(IERC721(hook).balanceOf(beneficiary), 0, "all NFTs burned");
581
- }
582
-
583
- /// @notice With 0% cash out tax rate, reclaim should be proportional to NFT weight.
584
- function test_fork_cashOutWithZeroTaxRate() public {
585
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
586
- JB721TiersHookFlags memory flags = _defaultFlags();
587
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 0, true, 0x00); // 0% tax
588
-
589
- uint16[] memory tierIds = new uint16[](1);
590
- tierIds[0] = 1;
591
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
592
-
593
- uint256[] memory tokensToCashOut = new uint256[](1);
594
- tokensToCashOut[0] = _tokenId(1, 1);
595
- bytes memory cashOutMeta = _buildCashOutMetadata(hook, tokensToCashOut);
596
-
597
- uint256 balBefore = beneficiary.balance;
598
- vm.prank(beneficiary);
599
- jbMultiTerminal.cashOutTokensOf({
600
- holder: beneficiary,
601
- projectId: projectId,
602
- tokenToReclaim: NATIVE_TOKEN,
603
- cashOutCount: 0,
604
- minTokensReclaimed: 0,
605
- beneficiary: payable(beneficiary),
606
- metadata: cashOutMeta
607
- });
608
-
609
- // With 0% tax and single NFT, should reclaim nearly all (minus fee).
610
- uint256 reclaimed = beneficiary.balance - balBefore;
611
- assertGt(reclaimed, 0, "should reclaim ETH");
612
- }
613
-
614
- // =====================================================================
615
- // SECTION 3: PAY CREDITS
616
- // =====================================================================
617
-
618
- /// @notice Overpayment when no tier IDs specified should accumulate credits.
619
- function test_fork_payCreditsAccumulation() public {
620
- (uint256 projectId, address hook,) = _launchStandardProject();
621
-
622
- // Pay without specifying tier IDs → all ETH becomes credits.
623
- vm.prank(payer);
624
- jbMultiTerminal.pay{value: 0.5 ether}({
625
- projectId: projectId,
626
- amount: 0.5 ether,
627
- token: NATIVE_TOKEN,
628
- beneficiary: beneficiary,
629
- minReturnedTokens: 0,
630
- memo: "",
631
- metadata: new bytes(0)
632
- });
633
-
634
- assertEq(IERC721(hook).balanceOf(beneficiary), 0, "no NFT minted");
635
- assertEq(IJB721TiersHook(hook).payCreditsOf(beneficiary), 0.5 ether, "credits should equal payment");
636
- }
637
-
638
- /// @notice Overpayment with tier IDs specified should mint the requested tier and credit the rest.
639
- function test_fork_overpayAccumulatesCredits() public {
640
- (uint256 projectId, address hook,) = _launchStandardProject();
641
-
642
- uint16[] memory tierIds = new uint16[](1);
643
- tierIds[0] = 1; // 0.01 ETH
644
-
645
- _payAndMint(projectId, 0.05 ether, tierIds, true, hook);
646
-
647
- assertEq(IERC721(hook).balanceOf(beneficiary), 1, "1 NFT minted");
648
- assertEq(IJB721TiersHook(hook).payCreditsOf(beneficiary), 0.04 ether, "leftover credited");
649
- }
650
-
651
- /// @notice Credits should be used on subsequent self-pay (payer == beneficiary).
652
- function test_fork_payCreditsUsedOnSelfPay() public {
653
- (uint256 projectId, address hook,) = _launchStandardProject();
654
-
655
- // First: accumulate credits.
656
- vm.prank(payer);
657
- jbMultiTerminal.pay{value: 0.005 ether}({
658
- projectId: projectId,
659
- amount: 0.005 ether,
660
- token: NATIVE_TOKEN,
661
- beneficiary: payer, // payer == beneficiary for credit usage
662
- minReturnedTokens: 0,
663
- memo: "",
664
- metadata: new bytes(0)
665
- });
666
-
667
- assertEq(IJB721TiersHook(hook).payCreditsOf(payer), 0.005 ether, "credits stored");
668
-
669
- // Second: pay 0.005 more, combined with 0.005 credits = 0.01, enough for tier 1.
670
- uint16[] memory tierIds = new uint16[](1);
671
- tierIds[0] = 1;
672
-
673
- bytes memory meta = _buildPayMetadata(hook, tierIds, true);
674
- vm.prank(payer);
675
- jbMultiTerminal.pay{value: 0.005 ether}({
676
- projectId: projectId,
677
- amount: 0.005 ether,
678
- token: NATIVE_TOKEN,
679
- beneficiary: payer,
680
- minReturnedTokens: 0,
681
- memo: "",
682
- metadata: meta
683
- });
684
-
685
- assertEq(IERC721(hook).balanceOf(payer), 1, "NFT minted using credits + payment");
686
- assertEq(IJB721TiersHook(hook).payCreditsOf(payer), 0, "credits consumed");
687
- }
688
-
689
- /// @notice Credits should NOT be combined when payer != beneficiary.
690
- function test_fork_payCreditsNotUsedWhenPayerDifferent() public {
691
- (uint256 projectId, address hook,) = _launchStandardProject();
692
-
693
- // Accumulate credits for beneficiary.
694
- vm.prank(payer);
695
- jbMultiTerminal.pay{value: 0.1 ether}({
696
- projectId: projectId,
697
- amount: 0.1 ether,
698
- token: NATIVE_TOKEN,
699
- beneficiary: beneficiary,
700
- minReturnedTokens: 0,
701
- memo: "",
702
- metadata: new bytes(0)
703
- });
704
-
705
- uint256 creditsBefore = IJB721TiersHook(hook).payCreditsOf(beneficiary);
706
- assertEq(creditsBefore, 0.1 ether, "beneficiary has credits");
707
-
708
- // Pay from a different payer on behalf of beneficiary — credits should NOT be combined.
709
- uint16[] memory tierIds = new uint16[](1);
710
- tierIds[0] = 1; // 0.01 ETH
711
-
712
- // Attacker pays 0.01 ETH on behalf of beneficiary.
713
- bytes memory meta = _buildPayMetadata(hook, tierIds, true);
714
- vm.prank(attacker);
715
- jbMultiTerminal.pay{value: 0.01 ether}({
716
- projectId: projectId,
717
- amount: 0.01 ether,
718
- token: NATIVE_TOKEN,
719
- beneficiary: beneficiary,
720
- minReturnedTokens: 0,
721
- memo: "",
722
- metadata: meta
723
- });
724
-
725
- // Credits should remain unchanged (payer != beneficiary, credits not consumed).
726
- assertEq(IJB721TiersHook(hook).payCreditsOf(beneficiary), creditsBefore, "credits unchanged");
727
- }
728
-
729
- // =====================================================================
730
- // SECTION 4: FLAGS
731
- // =====================================================================
732
-
733
- /// @notice preventOverspending: reverts if leftover after minting.
734
- function test_fork_preventOverspending_reverts() public {
735
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
736
- JB721TiersHookFlags memory flags = _defaultFlags();
737
- flags.preventOverspending = true;
738
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
739
-
740
- // Pay 0.05 ETH for a 0.01 ETH tier — 0.04 leftover should cause revert.
741
- uint16[] memory tierIds = new uint16[](1);
742
- tierIds[0] = 1;
743
-
744
- bytes memory meta = _buildPayMetadata(hook, tierIds, false);
745
-
746
- vm.prank(payer);
747
- vm.expectRevert();
748
- jbMultiTerminal.pay{value: 0.05 ether}({
749
- projectId: projectId,
750
- amount: 0.05 ether,
751
- token: NATIVE_TOKEN,
752
- beneficiary: beneficiary,
753
- minReturnedTokens: 0,
754
- memo: "",
755
- metadata: meta
756
- });
757
- }
758
-
759
- /// @notice preventOverspending: exact payment should succeed.
760
- function test_fork_preventOverspending_exactPaySucceeds() public {
761
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
762
- JB721TiersHookFlags memory flags = _defaultFlags();
763
- flags.preventOverspending = true;
764
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
765
-
766
- uint16[] memory tierIds = new uint16[](1);
767
- tierIds[0] = 1;
768
-
769
- bytes memory meta = _buildPayMetadata(hook, tierIds, false);
770
-
771
- vm.prank(payer);
772
- jbMultiTerminal.pay{value: 0.01 ether}({
773
- projectId: projectId,
774
- amount: 0.01 ether,
775
- token: NATIVE_TOKEN,
776
- beneficiary: beneficiary,
777
- minReturnedTokens: 0,
778
- memo: "",
779
- metadata: meta
780
- });
781
-
782
- assertEq(IERC721(hook).balanceOf(beneficiary), 1, "exact pay minted 1 NFT");
783
- }
784
-
785
- /// @notice noNewTiersWithReserves: adding a tier with reserveFrequency should revert.
786
- function test_fork_noNewTiersWithReserves_reverts() public {
787
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
788
- tierConfigs[0].reserveFrequency = 0; // Initial tier has no reserves (allowed).
789
- JB721TiersHookFlags memory flags = _defaultFlags();
790
- flags.noNewTiersWithReserves = true;
791
- (, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
792
-
793
- // Try to add a new tier with reserves.
794
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
795
- newTiers[0] = JB721TierConfig({
796
- price: 0.1 ether,
797
- initialSupply: 10,
798
- votingUnits: 0,
799
- reserveFrequency: 5,
800
- reserveBeneficiary: reserveBeneficiary,
801
- encodedIPFSUri: IPFS_URI,
802
- category: 200,
803
- discountPercent: 0,
804
- flags: JB721TierConfigFlags({
805
- allowOwnerMint: false,
806
- useReserveBeneficiaryAsDefault: false,
807
- transfersPausable: false,
808
- useVotingUnits: false,
809
- cantBeRemoved: false,
810
- cantIncreaseDiscountPercent: false,
811
- cantBuyWithCredits: false
812
- }),
813
- splitPercent: 0,
814
- splits: new JBSplit[](0)
815
- });
816
-
817
- vm.prank(multisig);
818
- vm.expectRevert();
819
- IJB721TiersHook(hook).adjustTiers(newTiers, new uint256[](0));
820
- }
821
-
822
- /// @notice noNewTiersWithOwnerMinting: adding a tier with allowOwnerMint should revert.
823
- function test_fork_noNewTiersWithOwnerMinting_reverts() public {
824
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
825
- JB721TiersHookFlags memory flags = _defaultFlags();
826
- flags.noNewTiersWithOwnerMinting = true;
827
- (, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
828
-
829
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
830
- newTiers[0] = JB721TierConfig({
831
- price: 0.1 ether,
832
- initialSupply: 10,
833
- votingUnits: 0,
834
- reserveFrequency: 0,
835
- reserveBeneficiary: address(0),
836
- encodedIPFSUri: IPFS_URI,
837
- category: 200,
838
- discountPercent: 0,
839
- flags: JB721TierConfigFlags({
840
- allowOwnerMint: true,
841
- useReserveBeneficiaryAsDefault: false,
842
- transfersPausable: false,
843
- useVotingUnits: false,
844
- cantBeRemoved: false,
845
- cantIncreaseDiscountPercent: false,
846
- cantBuyWithCredits: false
847
- }),
848
- splitPercent: 0,
849
- splits: new JBSplit[](0)
850
- });
851
-
852
- vm.prank(multisig);
853
- vm.expectRevert();
854
- IJB721TiersHook(hook).adjustTiers(newTiers, new uint256[](0));
855
- }
856
-
857
- /// @notice cantBeRemoved: removing an immutable tier should revert.
858
- function test_fork_cantBeRemoved_reverts() public {
859
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
860
- tierConfigs[0].flags.cantBeRemoved = true;
861
- JB721TiersHookFlags memory flags = _defaultFlags();
862
- (, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
863
-
864
- uint256[] memory toRemove = new uint256[](1);
865
- toRemove[0] = 1;
866
-
867
- vm.prank(multisig);
868
- vm.expectRevert();
869
- IJB721TiersHook(hook).adjustTiers(new JB721TierConfig[](0), toRemove);
870
- }
871
-
872
- // =====================================================================
873
- // SECTION 5: DISCOUNTS
874
- // =====================================================================
875
-
876
- /// @notice Mint at a discounted price. Discount of 100 = 50% off.
877
- function test_fork_discountedMint() public {
878
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
879
- tierConfigs[0].price = 1 ether;
880
- tierConfigs[0].discountPercent = 100; // 50% off → effective price = 0.5 ETH
881
- JB721TiersHookFlags memory flags = _defaultFlags();
882
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
883
-
884
- uint16[] memory tierIds = new uint16[](1);
885
- tierIds[0] = 1;
886
-
887
- // Pay exactly 1 ETH — should mint at 0.5 ETH effective price, leftover 0.5 ETH as credits.
888
- _payAndMint(projectId, 1 ether, tierIds, true, hook);
889
-
890
- assertEq(IERC721(hook).balanceOf(beneficiary), 1, "NFT minted at discounted price");
891
- assertEq(IJB721TiersHook(hook).payCreditsOf(beneficiary), 0.5 ether, "leftover credited");
892
- }
893
-
894
- /// @notice Full discount (200 = 100% off) makes tier free.
895
- function test_fork_fullDiscount_freeMint() public {
896
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
897
- tierConfigs[0].price = 1 ether;
898
- tierConfigs[0].discountPercent = 200; // 100% off → free
899
- JB721TiersHookFlags memory flags = _defaultFlags();
900
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
901
-
902
- uint16[] memory tierIds = new uint16[](1);
903
- tierIds[0] = 1;
904
-
905
- // Mint with 0 ETH — should work since effective price is 0.
906
- bytes memory meta = _buildPayMetadata(hook, tierIds, true);
907
- vm.prank(payer);
908
- jbMultiTerminal.pay{value: 0}({
909
- projectId: projectId,
910
- amount: 0,
911
- token: NATIVE_TOKEN,
912
- beneficiary: beneficiary,
913
- minReturnedTokens: 0,
914
- memo: "",
915
- metadata: meta
916
- });
917
-
918
- assertEq(IERC721(hook).balanceOf(beneficiary), 1, "NFT minted for free");
919
- }
920
-
921
- /// @notice cantIncreaseDiscountPercent: setting higher discount reverts.
922
- function test_fork_cannotIncreaseDiscount() public {
923
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
924
- tierConfigs[0].discountPercent = 50;
925
- tierConfigs[0].flags.cantIncreaseDiscountPercent = true;
926
- JB721TiersHookFlags memory flags = _defaultFlags();
927
- (, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
928
-
929
- vm.prank(multisig);
930
- vm.expectRevert();
931
- IJB721TiersHook(hook).setDiscountPercentOf(1, 100);
932
-
933
- // Decreasing should work.
934
- vm.prank(multisig);
935
- IJB721TiersHook(hook).setDiscountPercentOf(1, 25);
936
-
937
- JB721Tier memory tier = store.tierOf(hook, 1, false);
938
- assertEq(tier.discountPercent, 25, "discount decreased");
939
- }
940
-
941
- // =====================================================================
942
- // SECTION 6: RESERVE MINTING
943
- // =====================================================================
944
-
945
- /// @notice Pay enough to trigger pending reserves, then mint them.
946
- function test_fork_reserveMintLifecycle() public {
947
- (uint256 projectId, address hook,) = _launchStandardProject();
948
-
949
- // Mint 5 NFTs from tier 1 (reserve frequency = 10 → ceil(5/10) = 1 pending reserve).
950
- uint16[] memory tierIds = new uint16[](5);
951
- for (uint256 i; i < 5; i++) {
952
- tierIds[i] = 1;
953
- }
954
-
955
- _payAndMint(projectId, 0.05 ether, tierIds, true, hook);
956
-
957
- uint256 pending = store.numberOfPendingReservesFor(hook, 1);
958
- assertGt(pending, 0, "should have pending reserves");
959
-
960
- // Mint the pending reserves.
961
- vm.prank(multisig);
962
- IJB721TiersHook(hook).mintPendingReservesFor(1, pending);
963
-
964
- assertEq(store.numberOfPendingReservesFor(hook, 1), 0, "no pending reserves after mint");
965
- assertGt(IERC721(hook).balanceOf(reserveBeneficiary), 0, "reserve beneficiary received NFTs");
966
- }
967
-
968
- /// @notice Minting more pending reserves than available should revert.
969
- function test_fork_reserveMint_tooMany_reverts() public {
970
- (uint256 projectId, address hook,) = _launchStandardProject();
971
-
972
- uint16[] memory tierIds = new uint16[](1);
973
- tierIds[0] = 1;
974
-
975
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
976
-
977
- uint256 pending = store.numberOfPendingReservesFor(hook, 1);
978
-
979
- vm.prank(multisig);
980
- vm.expectRevert();
981
- IJB721TiersHook(hook).mintPendingReservesFor(1, pending + 10);
982
- }
983
-
984
- /// @notice Reserve frequency = 1: every paid mint generates a pending reserve.
985
- function test_fork_highReserveFrequency() public {
986
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 100, false);
987
- tierConfigs[0].reserveFrequency = 1;
988
- tierConfigs[0].reserveBeneficiary = reserveBeneficiary;
989
- JB721TiersHookFlags memory flags = _defaultFlags();
990
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
991
-
992
- // Mint 5 paid NFTs.
993
- uint16[] memory tierIds = new uint16[](5);
994
- for (uint256 i; i < 5; i++) {
995
- tierIds[i] = 1;
996
- }
997
-
998
- _payAndMint(projectId, 0.05 ether, tierIds, true, hook);
999
-
1000
- uint256 pending = store.numberOfPendingReservesFor(hook, 1);
1001
- assertGt(pending, 0, "high frequency means many pending reserves");
1002
-
1003
- vm.prank(multisig);
1004
- IJB721TiersHook(hook).mintPendingReservesFor(1, pending);
1005
-
1006
- assertEq(store.numberOfPendingReservesFor(hook, 1), 0, "all reserves minted");
1007
- }
1008
-
1009
- // =====================================================================
1010
- // SECTION 7: TRANSFER PAUSING
1011
- // =====================================================================
1012
-
1013
- /// @notice transfersPausable tier flag + ruleset metadata pauses transfers.
1014
- function test_fork_transfersPaused_reverts() public {
1015
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
1016
- tierConfigs[0].flags.transfersPausable = true;
1017
- JB721TiersHookFlags memory flags = _defaultFlags();
1018
-
1019
- // Pack 721 metadata: bit 0 = pauseTransfers = true.
1020
- uint16 packed721Meta = uint16(
1021
- // forge-lint: disable-next-line(named-struct-fields)
1022
- JB721TiersRulesetMetadataResolver.pack721TiersRulesetMetadata(JB721TiersRulesetMetadata(true, false))
1023
- );
1024
-
1025
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, packed721Meta);
1026
-
1027
- // Mint an NFT.
1028
- uint16[] memory tierIds = new uint16[](1);
1029
- tierIds[0] = 1;
1030
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
1031
-
1032
- // Try to transfer — should revert.
1033
- vm.prank(beneficiary);
1034
- vm.expectRevert();
1035
- IERC721(hook).transferFrom(beneficiary, attacker, _tokenId(1, 1));
1036
- }
1037
-
1038
- /// @notice Transfer works when transfersPausable=true but ruleset metadata doesn't pause.
1039
- function test_fork_transfersPausable_notPaused_works() public {
1040
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
1041
- tierConfigs[0].flags.transfersPausable = true;
1042
- JB721TiersHookFlags memory flags = _defaultFlags();
1043
-
1044
- // 721 metadata: transfers NOT paused.
1045
- uint16 packed721Meta = uint16(
1046
- // forge-lint: disable-next-line(named-struct-fields)
1047
- JB721TiersRulesetMetadataResolver.pack721TiersRulesetMetadata(JB721TiersRulesetMetadata(false, false))
1048
- );
1049
-
1050
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, packed721Meta);
1051
-
1052
- uint16[] memory tierIds = new uint16[](1);
1053
- tierIds[0] = 1;
1054
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
1055
-
1056
- // Transfer should succeed.
1057
- vm.prank(beneficiary);
1058
- IERC721(hook).transferFrom(beneficiary, attacker, _tokenId(1, 1));
1059
-
1060
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), attacker, "transfer succeeded");
1061
- }
1062
-
1063
- // =====================================================================
1064
- // SECTION 8: MINT PENDING RESERVES PAUSED
1065
- // =====================================================================
1066
-
1067
- /// @notice mintPendingReserves paused via ruleset metadata.
1068
- function test_fork_mintPendingReservesPaused_reverts() public {
1069
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 100, false);
1070
- tierConfigs[0].reserveFrequency = 1;
1071
- tierConfigs[0].reserveBeneficiary = reserveBeneficiary;
1072
- JB721TiersHookFlags memory flags = _defaultFlags();
1073
-
1074
- // 721 metadata: bit 1 = pauseMintPendingReserves = true.
1075
- uint16 packed721Meta = uint16(
1076
- // forge-lint: disable-next-line(named-struct-fields)
1077
- JB721TiersRulesetMetadataResolver.pack721TiersRulesetMetadata(JB721TiersRulesetMetadata(false, true))
1078
- );
1079
-
1080
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, packed721Meta);
1081
-
1082
- uint16[] memory tierIds = new uint16[](1);
1083
- tierIds[0] = 1;
1084
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
1085
-
1086
- uint256 pending = store.numberOfPendingReservesFor(hook, 1);
1087
- assertGt(pending, 0, "should have pending reserves");
1088
-
1089
- vm.prank(multisig);
1090
- vm.expectRevert();
1091
- IJB721TiersHook(hook).mintPendingReservesFor(1, 1);
1092
- }
1093
-
1094
- // =====================================================================
1095
- // SECTION 9: OWNER MINTING (mintFor)
1096
- // =====================================================================
1097
-
1098
- /// @notice Owner can mint via mintFor when allowOwnerMint is set on tier.
1099
- function test_fork_ownerMint() public {
1100
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, true); // allowOwnerMint=true
1101
- tierConfigs[0].reserveFrequency = 0; // No reserves (required: can't have both).
1102
- JB721TiersHookFlags memory flags = _defaultFlags();
1103
- (, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1104
-
1105
- uint16[] memory tierIds = new uint16[](2);
1106
- tierIds[0] = 1;
1107
- tierIds[1] = 1;
1108
-
1109
- vm.prank(multisig);
1110
- IJB721TiersHook(hook).mintFor(tierIds, beneficiary);
1111
-
1112
- assertEq(IERC721(hook).balanceOf(beneficiary), 2, "owner minted 2 NFTs");
1113
- }
1114
-
1115
- /// @notice Non-owner cannot mintFor.
1116
- function test_fork_ownerMint_noPermission_reverts() public {
1117
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, true);
1118
- tierConfigs[0].reserveFrequency = 0;
1119
- JB721TiersHookFlags memory flags = _defaultFlags();
1120
- (, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1121
-
1122
- uint16[] memory tierIds = new uint16[](1);
1123
- tierIds[0] = 1;
1124
-
1125
- vm.prank(attacker);
1126
- vm.expectRevert();
1127
- IJB721TiersHook(hook).mintFor(tierIds, attacker);
1128
- }
1129
-
1130
- // =====================================================================
1131
- // SECTION 10: TIER MANAGEMENT
1132
- // =====================================================================
1133
-
1134
- /// @notice Add tiers after launch, mint from new tier.
1135
- function test_fork_addTiersAndMint() public {
1136
- (uint256 projectId, address hook,) = _launchStandardProject();
1137
-
1138
- // Add a new expensive tier.
1139
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
1140
- newTiers[0] = JB721TierConfig({
1141
- price: 1 ether,
1142
- initialSupply: 5,
1143
- votingUnits: 100,
1144
- reserveFrequency: 0,
1145
- reserveBeneficiary: address(0),
1146
- encodedIPFSUri: IPFS_URI,
1147
- category: 200,
1148
- discountPercent: 0,
1149
- flags: JB721TierConfigFlags({
1150
- allowOwnerMint: false,
1151
- useReserveBeneficiaryAsDefault: false,
1152
- transfersPausable: false,
1153
- useVotingUnits: false,
1154
- cantBeRemoved: false,
1155
- cantIncreaseDiscountPercent: false,
1156
- cantBuyWithCredits: false
1157
- }),
1158
- splitPercent: 0,
1159
- splits: new JBSplit[](0)
1160
- });
1161
-
1162
- vm.prank(multisig);
1163
- IJB721TiersHook(hook).adjustTiers(newTiers, new uint256[](0));
1164
-
1165
- // Tier 11 should now exist.
1166
- JB721Tier memory newTier = store.tierOf(hook, 11, false);
1167
- assertEq(newTier.price, 1 ether, "new tier price");
1168
- assertEq(newTier.initialSupply, 5, "new tier supply");
1169
-
1170
- // Mint from the new tier.
1171
- uint16[] memory tierIds = new uint16[](1);
1172
- tierIds[0] = 11;
1173
-
1174
- _payAndMint(projectId, 1 ether, tierIds, true, hook);
1175
-
1176
- assertEq(IERC721(hook).ownerOf(_tokenId(11, 1)), beneficiary, "minted from new tier");
1177
- }
1178
-
1179
- /// @notice Remove a tier, verify minting from it fails.
1180
- function test_fork_removeTierBlocksMinting() public {
1181
- (uint256 projectId, address hook,) = _launchStandardProject();
1182
-
1183
- uint256[] memory toRemove = new uint256[](1);
1184
- toRemove[0] = 3;
1185
-
1186
- vm.prank(multisig);
1187
- IJB721TiersHook(hook).adjustTiers(new JB721TierConfig[](0), toRemove);
1188
-
1189
- // Try to mint from removed tier — should revert (supply=0 after removal).
1190
- uint16[] memory tierIds = new uint16[](1);
1191
- tierIds[0] = 3;
1192
-
1193
- bytes memory meta = _buildPayMetadata(hook, tierIds, false);
1194
-
1195
- vm.prank(payer);
1196
- vm.expectRevert();
1197
- jbMultiTerminal.pay{value: 0.03 ether}({
1198
- projectId: projectId,
1199
- amount: 0.03 ether,
1200
- token: NATIVE_TOKEN,
1201
- beneficiary: beneficiary,
1202
- minReturnedTokens: 0,
1203
- memo: "",
1204
- metadata: meta
1205
- });
1206
- }
1207
-
1208
- // =====================================================================
1209
- // SECTION 11: SUPPLY EXHAUSTION
1210
- // =====================================================================
1211
-
1212
- /// @notice Exhaust supply, then verify further minting reverts.
1213
- function test_fork_supplyExhaustion() public {
1214
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 3, false); // Only 3 NFTs
1215
- tierConfigs[0].reserveFrequency = 0;
1216
- JB721TiersHookFlags memory flags = _defaultFlags();
1217
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1218
-
1219
- // Mint all 3.
1220
- uint16[] memory tierIds = new uint16[](3);
1221
- tierIds[0] = 1;
1222
- tierIds[1] = 1;
1223
- tierIds[2] = 1;
1224
-
1225
- _payAndMint(projectId, 0.03 ether, tierIds, true, hook);
1226
-
1227
- assertEq(IERC721(hook).balanceOf(beneficiary), 3, "all 3 minted");
1228
-
1229
- JB721Tier memory tier = store.tierOf(hook, 1, false);
1230
- assertEq(tier.remainingSupply, 0, "supply exhausted");
1231
-
1232
- // Try to mint one more — should revert.
1233
- uint16[] memory oneMore = new uint16[](1);
1234
- oneMore[0] = 1;
1235
-
1236
- bytes memory meta = _buildPayMetadata(hook, oneMore, false);
1237
-
1238
- vm.prank(payer);
1239
- vm.expectRevert();
1240
- jbMultiTerminal.pay{value: 0.01 ether}({
1241
- projectId: projectId,
1242
- amount: 0.01 ether,
1243
- token: NATIVE_TOKEN,
1244
- beneficiary: beneficiary,
1245
- minReturnedTokens: 0,
1246
- memo: "",
1247
- metadata: meta
1248
- });
1249
- }
1250
-
1251
- // =====================================================================
1252
- // SECTION 12: ERC-721 BEHAVIOR
1253
- // =====================================================================
1254
-
1255
- /// @notice firstOwnerOf tracks correctly through transfers.
1256
- function test_fork_firstOwnerOfTracksAcrossTransfers() public {
1257
- (uint256 projectId, address hook,) = _launchStandardProject();
1258
-
1259
- uint16[] memory tierIds = new uint16[](1);
1260
- tierIds[0] = 1;
1261
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
1262
-
1263
- uint256 tokenId = _tokenId(1, 1);
1264
- assertEq(IJB721TiersHook(hook).firstOwnerOf(tokenId), beneficiary, "initial firstOwner");
1265
-
1266
- // Transfer to attacker.
1267
- vm.prank(beneficiary);
1268
- IERC721(hook).transferFrom(beneficiary, attacker, tokenId);
1269
- assertEq(IERC721(hook).ownerOf(tokenId), attacker, "attacker owns");
1270
- assertEq(IJB721TiersHook(hook).firstOwnerOf(tokenId), beneficiary, "firstOwner unchanged");
1271
-
1272
- // Transfer again.
1273
- vm.prank(attacker);
1274
- IERC721(hook).transferFrom(attacker, payer, tokenId);
1275
- assertEq(IJB721TiersHook(hook).firstOwnerOf(tokenId), beneficiary, "firstOwner still beneficiary");
1276
- }
1277
-
1278
- /// @notice Approval and transferFrom by approved operator.
1279
- function test_fork_approvalAndTransfer() public {
1280
- (uint256 projectId, address hook,) = _launchStandardProject();
1281
-
1282
- uint16[] memory tierIds = new uint16[](1);
1283
- tierIds[0] = 1;
1284
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
1285
-
1286
- uint256 tokenId = _tokenId(1, 1);
1287
-
1288
- // Approve attacker.
1289
- vm.prank(beneficiary);
1290
- IERC721(hook).approve(attacker, tokenId);
1291
-
1292
- // Attacker transfers using approval.
1293
- vm.prank(attacker);
1294
- IERC721(hook).transferFrom(beneficiary, attacker, tokenId);
1295
-
1296
- assertEq(IERC721(hook).ownerOf(tokenId), attacker, "attacker now owns via approval");
1297
- }
1298
-
1299
- // =====================================================================
1300
- // SECTION 13: VOTING UNITS
1301
- // =====================================================================
1302
-
1303
- /// @notice Price-based voting (useVotingUnits=false) uses tier price as voting power.
1304
- function test_fork_priceBasedVoting() public {
1305
- (uint256 projectId, address hook,) = _launchStandardProject();
1306
-
1307
- // Mint from tier 5 (price = 0.05 ETH, useVotingUnits=false → price-based).
1308
- uint16[] memory tierIds = new uint16[](1);
1309
- tierIds[0] = 5;
1310
- _payAndMint(projectId, 0.05 ether, tierIds, true, hook);
1311
-
1312
- uint256 votingPower = store.votingUnitsOf(hook, beneficiary);
1313
- assertEq(votingPower, 0.05 ether, "voting power should equal tier price");
1314
- }
1315
-
1316
- /// @notice Custom voting units (useVotingUnits=true) uses specified value.
1317
- function test_fork_customVotingUnits() public {
1318
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
1319
- tierConfigs[0].flags.useVotingUnits = true;
1320
- tierConfigs[0].votingUnits = 42;
1321
- JB721TiersHookFlags memory flags = _defaultFlags();
1322
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1323
-
1324
- uint16[] memory tierIds = new uint16[](1);
1325
- tierIds[0] = 1;
1326
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
1327
-
1328
- uint256 votingPower = store.votingUnitsOf(hook, beneficiary);
1329
- assertEq(votingPower, 42, "voting power should be custom units");
1330
- }
1331
-
1332
- // =====================================================================
1333
- // SECTION 14: CASH OUT WEIGHT AND MATH
1334
- // =====================================================================
1335
-
1336
- /// @notice Cash out weight uses original price (not discounted).
1337
- function test_fork_cashOutWeightUsesOriginalPrice() public {
1338
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
1339
- tierConfigs[0].price = 1 ether;
1340
- tierConfigs[0].discountPercent = 100; // 50% discount
1341
- tierConfigs[0].reserveFrequency = 0;
1342
- JB721TiersHookFlags memory flags = _defaultFlags();
1343
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1344
-
1345
- uint16[] memory tierIds = new uint16[](1);
1346
- tierIds[0] = 1;
1347
- _payAndMint(projectId, 1 ether, tierIds, true, hook);
1348
-
1349
- // Cash out weight should be based on original price (1 ETH), not discounted (0.5 ETH).
1350
- uint256[] memory tokenIds = new uint256[](1);
1351
- tokenIds[0] = _tokenId(1, 1);
1352
- uint256 weight = store.cashOutWeightOf(hook, tokenIds);
1353
- assertEq(weight, 1 ether, "cash out weight uses original price");
1354
- }
1355
-
1356
- /// @notice totalCashOutWeight includes pending reserves in denominator.
1357
- function test_fork_totalCashOutWeightIncludesPendingReserves() public {
1358
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 100, false);
1359
- tierConfigs[0].price = 1 ether;
1360
- tierConfigs[0].reserveFrequency = 1;
1361
- tierConfigs[0].reserveBeneficiary = reserveBeneficiary;
1362
- JB721TiersHookFlags memory flags = _defaultFlags();
1363
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1364
-
1365
- uint16[] memory tierIds = new uint16[](1);
1366
- tierIds[0] = 1;
1367
- _payAndMint(projectId, 1 ether, tierIds, true, hook);
1368
-
1369
- uint256 totalWeight = store.totalCashOutWeight(hook);
1370
-
1371
- // 1 paid mint + pending reserves. Total weight > just 1 * price.
1372
- assertGt(totalWeight, 1 ether, "total weight includes pending reserves");
1373
- }
1374
-
1375
- // =====================================================================
1376
- // SECTION 15: ADVERSARIAL — FLASH LOAN ATTACK
1377
- // =====================================================================
1378
-
1379
- /// @notice Pay and cash out in same block — verify no profit (bonding curve prevents it).
1380
- function test_fork_flashLoanAttack_noProfit() public {
1381
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 100, false);
1382
- tierConfigs[0].price = 1 ether;
1383
- tierConfigs[0].reserveFrequency = 0;
1384
- JB721TiersHookFlags memory flags = _defaultFlags();
1385
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00); // 50% tax
1386
-
1387
- // Seed with initial payments from payer — minting NFTs so totalCashOutWeight is large.
1388
- uint16[] memory seedTierIds = new uint16[](10);
1389
- for (uint256 i; i < 10; i++) {
1390
- seedTierIds[i] = 1;
1391
- }
1392
- bytes memory seedMeta = _buildPayMetadata(hook, seedTierIds, true);
1393
- vm.prank(payer);
1394
- jbMultiTerminal.pay{value: 10 ether}({
1395
- projectId: projectId,
1396
- amount: 10 ether,
1397
- token: NATIVE_TOKEN,
1398
- beneficiary: payer,
1399
- minReturnedTokens: 0,
1400
- memo: "",
1401
- metadata: seedMeta
1402
- });
1403
-
1404
- uint256 attackerBalBefore = attacker.balance;
1405
-
1406
- // Attacker: pay → mint NFT → immediately cash out.
1407
- uint16[] memory tierIds = new uint16[](1);
1408
- tierIds[0] = 1;
1409
- bytes memory payMeta = _buildPayMetadata(hook, tierIds, true);
1410
-
1411
- vm.prank(attacker);
1412
- jbMultiTerminal.pay{value: 1 ether}({
1413
- projectId: projectId,
1414
- amount: 1 ether,
1415
- token: NATIVE_TOKEN,
1416
- beneficiary: attacker,
1417
- minReturnedTokens: 0,
1418
- memo: "",
1419
- metadata: payMeta
1420
- });
1421
-
1422
- uint256[] memory tokensToCashOut = new uint256[](1);
1423
- tokensToCashOut[0] = _tokenId(1, 11); // Token #11 (first 10 minted by payer)
1424
- bytes memory cashOutMeta = _buildCashOutMetadata(hook, tokensToCashOut);
1425
-
1426
- vm.prank(attacker);
1427
- jbMultiTerminal.cashOutTokensOf({
1428
- holder: attacker,
1429
- projectId: projectId,
1430
- tokenToReclaim: NATIVE_TOKEN,
1431
- cashOutCount: 0,
1432
- minTokensReclaimed: 0,
1433
- beneficiary: payable(attacker),
1434
- metadata: cashOutMeta
1435
- });
1436
-
1437
- // Attacker should not have profited.
1438
- assertLe(attacker.balance, attackerBalBefore, "flash loan attack should not profit");
1439
- }
1440
-
1441
- // =====================================================================
1442
- // SECTION 16: ADVERSARIAL — NON-OWNER CASH OUT
1443
- // =====================================================================
1444
-
1445
- /// @notice Non-owner trying to cash out someone else's NFT should revert.
1446
- function test_fork_nonOwnerCashOut_reverts() public {
1447
- (uint256 projectId, address hook,) = _launchStandardProject();
1448
-
1449
- uint16[] memory tierIds = new uint16[](1);
1450
- tierIds[0] = 1;
1451
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
1452
-
1453
- // beneficiary owns the NFT. attacker tries to cash out.
1454
- uint256[] memory tokensToCashOut = new uint256[](1);
1455
- tokensToCashOut[0] = _tokenId(1, 1);
1456
- bytes memory cashOutMeta = _buildCashOutMetadata(hook, tokensToCashOut);
1457
-
1458
- vm.prank(attacker);
1459
- vm.expectRevert();
1460
- jbMultiTerminal.cashOutTokensOf({
1461
- holder: attacker,
1462
- projectId: projectId,
1463
- tokenToReclaim: NATIVE_TOKEN,
1464
- cashOutCount: 0,
1465
- minTokensReclaimed: 0,
1466
- beneficiary: payable(attacker),
1467
- metadata: cashOutMeta
1468
- });
1469
- }
1470
-
1471
- // =====================================================================
1472
- // SECTION 17: ADVERSARIAL — RE-INITIALIZATION
1473
- // =====================================================================
1474
-
1475
- /// @notice Calling initialize() again on a deployed hook should revert.
1476
- function test_fork_reInitialize_reverts() public {
1477
- (, address hook,) = _launchStandardProject();
1478
-
1479
- JB721TierConfig[] memory emptyTiers = new JB721TierConfig[](0);
1480
-
1481
- vm.expectRevert();
1482
- IJB721TiersHook(hook)
1483
- .initialize(
1484
- 99, // different projectId
1485
- "Evil",
1486
- "EVIL",
1487
- "ipfs://evil/",
1488
- IJB721TokenUriResolver(address(0)),
1489
- "ipfs://evil-contract",
1490
- // forge-lint: disable-next-line(unsafe-typecast)
1491
- JB721InitTiersConfig({tiers: emptyTiers, currency: uint32(uint160(NATIVE_TOKEN)), decimals: 18}),
1492
- _defaultFlags()
1493
- );
1494
- }
1495
-
1496
- // =====================================================================
1497
- // SECTION 18: ADVERSARIAL — UNDERPAY
1498
- // =====================================================================
1499
-
1500
- /// @notice Paying less than tier price with preventOverspending=true should revert.
1501
- function test_fork_underpay_reverts() public {
1502
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
1503
- tierConfigs[0].price = 1 ether;
1504
- tierConfigs[0].reserveFrequency = 0;
1505
- JB721TiersHookFlags memory flags = _defaultFlags();
1506
- flags.preventOverspending = true;
1507
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1508
-
1509
- uint16[] memory tierIds = new uint16[](1);
1510
- tierIds[0] = 1;
1511
-
1512
- bytes memory meta = _buildPayMetadata(hook, tierIds, false);
1513
-
1514
- vm.prank(payer);
1515
- vm.expectRevert();
1516
- jbMultiTerminal.pay{value: 0.5 ether}({
1517
- projectId: projectId,
1518
- amount: 0.5 ether,
1519
- token: NATIVE_TOKEN,
1520
- beneficiary: beneficiary,
1521
- minReturnedTokens: 0,
1522
- memo: "",
1523
- metadata: meta
1524
- });
1525
- }
1526
-
1527
- // =====================================================================
1528
- // SECTION 19: ADVERSARIAL — ADJUSTTIERS WITHOUT PERMISSION
1529
- // =====================================================================
1530
-
1531
- /// @notice Non-owner cannot adjustTiers.
1532
- function test_fork_adjustTiers_noPermission_reverts() public {
1533
- (, address hook,) = _launchStandardProject();
1534
-
1535
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](1);
1536
- newTiers[0] = JB721TierConfig({
1537
- price: 0.001 ether,
1538
- initialSupply: type(uint32).max,
1539
- votingUnits: 0,
1540
- reserveFrequency: 0,
1541
- reserveBeneficiary: attacker,
1542
- encodedIPFSUri: IPFS_URI,
1543
- category: 200,
1544
- discountPercent: 0,
1545
- flags: JB721TierConfigFlags({
1546
- allowOwnerMint: false,
1547
- useReserveBeneficiaryAsDefault: false,
1548
- transfersPausable: false,
1549
- useVotingUnits: false,
1550
- cantBeRemoved: false,
1551
- cantIncreaseDiscountPercent: false,
1552
- cantBuyWithCredits: false
1553
- }),
1554
- splitPercent: 0,
1555
- splits: new JBSplit[](0)
1556
- });
1557
-
1558
- vm.prank(attacker);
1559
- vm.expectRevert();
1560
- IJB721TiersHook(hook).adjustTiers(newTiers, new uint256[](0));
1561
- }
1562
-
1563
- // =====================================================================
1564
- // SECTION 20: ADVERSARIAL — STALE CASH OUT WEIGHT AFTER REMOVAL
1565
- // =====================================================================
1566
-
1567
- /// @notice Mint from a tier, remove it, verify totalCashOutWeight still includes minted tokens.
1568
- function test_fork_cashOutWeightPreservedAfterTierRemoval() public {
1569
- (uint256 projectId, address hook,) = _launchStandardProject();
1570
-
1571
- uint16[] memory tierIds = new uint16[](3);
1572
- tierIds[0] = 5;
1573
- tierIds[1] = 5;
1574
- tierIds[2] = 5;
1575
- _payAndMint(projectId, 0.15 ether, tierIds, true, hook);
1576
-
1577
- uint256 weightBefore = store.totalCashOutWeight(hook);
1578
- assertGt(weightBefore, 0, "should have weight");
1579
-
1580
- // Remove tier 5.
1581
- uint256[] memory toRemove = new uint256[](1);
1582
- toRemove[0] = 5;
1583
-
1584
- vm.prank(multisig);
1585
- IJB721TiersHook(hook).adjustTiers(new JB721TierConfig[](0), toRemove);
1586
-
1587
- uint256 weightAfter = store.totalCashOutWeight(hook);
1588
- assertEq(weightAfter, weightBefore, "weight preserved after tier removal");
1589
- }
1590
-
1591
- // =====================================================================
1592
- // SECTION 21: MULTI-USER SCENARIOS
1593
- // =====================================================================
1594
-
1595
- /// @notice Multiple users pay and mint from same project.
1596
- function test_fork_multipleUsersMinting() public {
1597
- (uint256 projectId, address hook,) = _launchStandardProject();
1598
-
1599
- address user1 = makeAddr("user1");
1600
- address user2 = makeAddr("user2");
1601
- address user3 = makeAddr("user3");
1602
- vm.deal(user1, 10 ether);
1603
- vm.deal(user2, 10 ether);
1604
- vm.deal(user3, 10 ether);
1605
-
1606
- uint16[] memory tierIds = new uint16[](1);
1607
- tierIds[0] = 1;
1608
-
1609
- // User1 mints.
1610
- bytes memory meta = _buildPayMetadata(hook, tierIds, true);
1611
- vm.prank(user1);
1612
- jbMultiTerminal.pay{value: 0.01 ether}({
1613
- projectId: projectId,
1614
- amount: 0.01 ether,
1615
- token: NATIVE_TOKEN,
1616
- beneficiary: user1,
1617
- minReturnedTokens: 0,
1618
- memo: "",
1619
- metadata: meta
1620
- });
1621
-
1622
- // User2 mints.
1623
- vm.prank(user2);
1624
- jbMultiTerminal.pay{value: 0.01 ether}({
1625
- projectId: projectId,
1626
- amount: 0.01 ether,
1627
- token: NATIVE_TOKEN,
1628
- beneficiary: user2,
1629
- minReturnedTokens: 0,
1630
- memo: "",
1631
- metadata: meta
1632
- });
1633
-
1634
- // User3 mints.
1635
- vm.prank(user3);
1636
- jbMultiTerminal.pay{value: 0.01 ether}({
1637
- projectId: projectId,
1638
- amount: 0.01 ether,
1639
- token: NATIVE_TOKEN,
1640
- beneficiary: user3,
1641
- minReturnedTokens: 0,
1642
- memo: "",
1643
- metadata: meta
1644
- });
1645
-
1646
- assertEq(IERC721(hook).balanceOf(user1), 1, "user1 has 1 NFT");
1647
- assertEq(IERC721(hook).balanceOf(user2), 1, "user2 has 1 NFT");
1648
- assertEq(IERC721(hook).balanceOf(user3), 1, "user3 has 1 NFT");
1649
-
1650
- // Each user got a different token number.
1651
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), user1, "user1 has #1");
1652
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 2)), user2, "user2 has #2");
1653
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 3)), user3, "user3 has #3");
1654
-
1655
- JB721Tier memory tier = store.tierOf(hook, 1, false);
1656
- assertEq(tier.remainingSupply, 7, "supply decreased by 3");
1657
- }
1658
-
1659
- // =====================================================================
1660
- // SECTION 22: FULL LIFECYCLE — PAY, MINT, RESERVE, CASH OUT, REMINT
1661
- // =====================================================================
1662
-
1663
- /// @notice Complete lifecycle: pay → mint → reserves → cash out → verify store consistency.
1664
- function test_fork_fullLifecycle() public {
1665
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(3, 20, false);
1666
- tierConfigs[0].reserveFrequency = 5;
1667
- tierConfigs[1].reserveFrequency = 5;
1668
- tierConfigs[2].reserveFrequency = 5;
1669
- JB721TiersHookFlags memory flags = _defaultFlags();
1670
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1671
-
1672
- // Phase 1: Multiple payments minting various tiers.
1673
- uint16[] memory batch1 = new uint16[](3);
1674
- batch1[0] = 1;
1675
- batch1[1] = 1;
1676
- batch1[2] = 2;
1677
- _payAndMint(projectId, 0.04 ether, batch1, true, hook);
1678
-
1679
- uint16[] memory batch2 = new uint16[](2);
1680
- batch2[0] = 2;
1681
- batch2[1] = 3;
1682
- _payAndMint(projectId, 0.05 ether, batch2, true, hook);
1683
-
1684
- assertEq(IERC721(hook).balanceOf(beneficiary), 5, "5 NFTs total");
1685
-
1686
- // Phase 2: Mint pending reserves.
1687
- for (uint256 tierId = 1; tierId <= 3; tierId++) {
1688
- uint256 pending = store.numberOfPendingReservesFor(hook, tierId);
1689
- if (pending > 0) {
1690
- vm.prank(multisig);
1691
- IJB721TiersHook(hook).mintPendingReservesFor(tierId, pending);
1692
- }
1693
- }
1694
-
1695
- assertGt(IERC721(hook).balanceOf(reserveBeneficiary), 0, "reserve beneficiary has NFTs");
1696
-
1697
- // Phase 3: Cash out some NFTs.
1698
- uint256[] memory tokensToCashOut = new uint256[](2);
1699
- tokensToCashOut[0] = _tokenId(1, 1);
1700
- tokensToCashOut[1] = _tokenId(2, 1);
1701
- bytes memory cashOutMeta = _buildCashOutMetadata(hook, tokensToCashOut);
1702
-
1703
- uint256 balBefore = beneficiary.balance;
1704
- vm.prank(beneficiary);
1705
- jbMultiTerminal.cashOutTokensOf({
1706
- holder: beneficiary,
1707
- projectId: projectId,
1708
- tokenToReclaim: NATIVE_TOKEN,
1709
- cashOutCount: 0,
1710
- minTokensReclaimed: 0,
1711
- beneficiary: payable(beneficiary),
1712
- metadata: cashOutMeta
1713
- });
1714
-
1715
- assertEq(IERC721(hook).balanceOf(beneficiary), 3, "3 NFTs remaining");
1716
- assertGt(beneficiary.balance, balBefore, "ETH reclaimed");
1717
- assertEq(store.numberOfBurnedFor(hook, 1), 1, "tier 1 burn recorded");
1718
- assertEq(store.numberOfBurnedFor(hook, 2), 1, "tier 2 burn recorded");
1719
-
1720
- // Phase 4: Re-mint from depleted tiers — supply should still be available.
1721
- uint16[] memory reMint = new uint16[](1);
1722
- reMint[0] = 1;
1723
- _payAndMint(projectId, 0.01 ether, reMint, true, hook);
1724
-
1725
- assertEq(IERC721(hook).balanceOf(beneficiary), 4, "4 NFTs after remint");
1726
- }
1727
-
1728
- // =====================================================================
1729
- // SECTION 23: ZERO PRICE TIER
1730
- // =====================================================================
1731
-
1732
- /// @notice A zero-price tier can be minted for free.
1733
- function test_fork_zeroPriceTier() public {
1734
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
1735
- tierConfigs[0].price = 0;
1736
- tierConfigs[0].reserveFrequency = 0;
1737
- JB721TiersHookFlags memory flags = _defaultFlags();
1738
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1739
-
1740
- uint16[] memory tierIds = new uint16[](1);
1741
- tierIds[0] = 1;
1742
-
1743
- bytes memory meta = _buildPayMetadata(hook, tierIds, true);
1744
- vm.prank(payer);
1745
- jbMultiTerminal.pay{value: 0}({
1746
- projectId: projectId,
1747
- amount: 0,
1748
- token: NATIVE_TOKEN,
1749
- beneficiary: beneficiary,
1750
- minReturnedTokens: 0,
1751
- memo: "",
1752
- metadata: meta
1753
- });
1754
-
1755
- assertEq(IERC721(hook).balanceOf(beneficiary), 1, "zero price tier minted");
1756
- }
1757
-
1758
- // =====================================================================
1759
- // SECTION 24: DETERMINISTIC DEPLOYMENT
1760
- // =====================================================================
1761
-
1762
- /// @notice Deterministic and non-deterministic clone deployments both work.
1763
- function test_fork_deterministicDeployment() public {
1764
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
1765
- tierConfigs[0].reserveFrequency = 0;
1766
- JB721TiersHookFlags memory flags = _defaultFlags();
1767
-
1768
- // Non-deterministic.
1769
- (uint256 projectId1, address hook1) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1770
- assertGt(projectId1, 0, "project 1 launched");
1771
- assertTrue(hook1.code.length > 0, "hook 1 deployed");
1772
-
1773
- // Verify registered in address registry.
1774
- assertEq(addressRegistry.deployerOf(hook1), address(hookDeployer), "hook 1 in registry");
1775
- }
1776
-
1777
- // =====================================================================
1778
- // SECTION 25: MULTI-CATEGORY TIERS
1779
- // =====================================================================
1780
-
1781
- /// @notice Tiers across multiple categories can be minted independently.
1782
- function test_fork_multiCategoryTiers() public {
1783
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](3);
1784
- for (uint256 i; i < 3; i++) {
1785
- tierConfigs[i] = JB721TierConfig({
1786
- price: uint104((i + 1) * 0.01 ether),
1787
- initialSupply: 10,
1788
- votingUnits: 0,
1789
- reserveFrequency: 0,
1790
- reserveBeneficiary: address(0),
1791
- encodedIPFSUri: IPFS_URI,
1792
- category: uint24((i + 1) * 100), // Categories: 100, 200, 300
1793
- discountPercent: 0,
1794
- flags: JB721TierConfigFlags({
1795
- allowOwnerMint: false,
1796
- useReserveBeneficiaryAsDefault: false,
1797
- transfersPausable: false,
1798
- useVotingUnits: false,
1799
- cantBeRemoved: false,
1800
- cantIncreaseDiscountPercent: false,
1801
- cantBuyWithCredits: false
1802
- }),
1803
- splitPercent: 0,
1804
- splits: new JBSplit[](0)
1805
- });
1806
- }
1807
- JB721TiersHookFlags memory flags = _defaultFlags();
1808
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1809
-
1810
- // Mint from each category.
1811
- uint16[] memory tierIds = new uint16[](3);
1812
- tierIds[0] = 1;
1813
- tierIds[1] = 2;
1814
- tierIds[2] = 3;
1815
-
1816
- _payAndMint(projectId, 0.06 ether, tierIds, true, hook);
1817
-
1818
- assertEq(IERC721(hook).balanceOf(beneficiary), 3, "one from each category");
1819
-
1820
- // Query tiers by category.
1821
- uint256[] memory cat100 = new uint256[](1);
1822
- cat100[0] = 100;
1823
- JB721Tier[] memory cat100Tiers = store.tiersOf(hook, cat100, false, 0, 100);
1824
- assertEq(cat100Tiers.length, 1, "1 tier in category 100");
1825
- }
1826
-
1827
- // =====================================================================
1828
- // SECTION 26: PAY WITHOUT METADATA — NO TIERS SPECIFIED
1829
- // =====================================================================
1830
-
1831
- /// @notice Paying without metadata gives all payment as credits.
1832
- function test_fork_payWithoutMetadata_noNFTsMinted() public {
1833
- (uint256 projectId, address hook,) = _launchStandardProject();
1834
-
1835
- vm.prank(payer);
1836
- jbMultiTerminal.pay{value: 1 ether}({
1837
- projectId: projectId,
1838
- amount: 1 ether,
1839
- token: NATIVE_TOKEN,
1840
- beneficiary: beneficiary,
1841
- minReturnedTokens: 0,
1842
- memo: "",
1843
- metadata: new bytes(0)
1844
- });
1845
-
1846
- assertEq(IERC721(hook).balanceOf(beneficiary), 0, "no NFTs minted");
1847
- assertEq(IJB721TiersHook(hook).payCreditsOf(beneficiary), 1 ether, "full payment as credits");
1848
- }
1849
-
1850
- // =====================================================================
1851
- // SECTION 27: M6 RESERVE PROTECTION
1852
- // =====================================================================
1853
-
1854
- /// @notice M6: Cannot mint past pending reserves. If remaining supply would drop below
1855
- /// pending reserves, the paid mint reverts.
1856
- function test_fork_m6_reserveProtection() public {
1857
- // Supply=4, reserveFrequency=1.
1858
- // After 2 paid mints: remaining=2, pendingReserves = ceil(2/1) = 2+1 = 3? No:
1859
- // Formula: nonReserveMints = initialSupply - remaining - reservesMinted = 4-2-0 = 2
1860
- // pendingReserves = ceil(2/1) - 0 = 2
1861
- // remaining(2) >= pending(2) → OK (M6 checks remaining < pending, not <=)
1862
- // After 3rd paid mint attempt: remaining would be 1
1863
- // nonReserveMints = 4-1-0 = 3, pending = ceil(3/1) - 0 = 3
1864
- // remaining(1) < pending(3) → REVERTS!
1865
- // So: mint 2, then 3rd should revert.
1866
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 4, false);
1867
- tierConfigs[0].reserveFrequency = 1;
1868
- tierConfigs[0].reserveBeneficiary = reserveBeneficiary;
1869
- JB721TiersHookFlags memory flags = _defaultFlags();
1870
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1871
-
1872
- // Mint 2 NFTs successfully.
1873
- uint16[] memory two = new uint16[](2);
1874
- two[0] = 1;
1875
- two[1] = 1;
1876
- _payAndMint(projectId, 0.02 ether, two, true, hook);
1877
-
1878
- assertEq(IERC721(hook).balanceOf(beneficiary), 2, "2 minted successfully");
1879
-
1880
- // 3rd mint should fail — remaining would drop below pending reserves.
1881
- uint16[] memory one = new uint16[](1);
1882
- one[0] = 1;
1883
- bytes memory meta = _buildPayMetadata(hook, one, false);
1884
-
1885
- vm.prank(payer);
1886
- vm.expectRevert();
1887
- jbMultiTerminal.pay{value: 0.01 ether}({
1888
- projectId: projectId,
1889
- amount: 0.01 ether,
1890
- token: NATIVE_TOKEN,
1891
- beneficiary: beneficiary,
1892
- minReturnedTokens: 0,
1893
- memo: "",
1894
- metadata: meta
1895
- });
1896
- }
1897
-
1898
- // =====================================================================
1899
- // SECTION 28: CASH OUT RECLAIM CONSISTENCY
1900
- // =====================================================================
1901
-
1902
- /// @notice Two users mint same tier. Higher tax rate should yield less reclaim.
1903
- function test_fork_cashOutTaxRateEffect() public {
1904
- // High tax rate project.
1905
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 100, false);
1906
- tierConfigs[0].price = 1 ether;
1907
- tierConfigs[0].reserveFrequency = 0;
1908
- JB721TiersHookFlags memory flags = _defaultFlags();
1909
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 9000, true, 0x00); // 90% tax
1910
-
1911
- // First user pays.
1912
- address user1 = makeAddr("taxUser1");
1913
- vm.deal(user1, 10 ether);
1914
-
1915
- uint16[] memory tierIds = new uint16[](1);
1916
- tierIds[0] = 1;
1917
-
1918
- bytes memory meta = _buildPayMetadata(hook, tierIds, true);
1919
- vm.prank(user1);
1920
- jbMultiTerminal.pay{value: 1 ether}({
1921
- projectId: projectId,
1922
- amount: 1 ether,
1923
- token: NATIVE_TOKEN,
1924
- beneficiary: user1,
1925
- minReturnedTokens: 0,
1926
- memo: "",
1927
- metadata: meta
1928
- });
1929
-
1930
- // Second user pays.
1931
- address user2 = makeAddr("taxUser2");
1932
- vm.deal(user2, 10 ether);
1933
- vm.prank(user2);
1934
- jbMultiTerminal.pay{value: 1 ether}({
1935
- projectId: projectId,
1936
- amount: 1 ether,
1937
- token: NATIVE_TOKEN,
1938
- beneficiary: user2,
1939
- minReturnedTokens: 0,
1940
- memo: "",
1941
- metadata: meta
1942
- });
1943
-
1944
- // User1 cashes out — with 90% tax, they should get very little back.
1945
- uint256[] memory tokensToCashOut = new uint256[](1);
1946
- tokensToCashOut[0] = _tokenId(1, 1);
1947
- bytes memory cashOutMeta = _buildCashOutMetadata(hook, tokensToCashOut);
1948
-
1949
- uint256 balBefore = user1.balance;
1950
- vm.prank(user1);
1951
- jbMultiTerminal.cashOutTokensOf({
1952
- holder: user1,
1953
- projectId: projectId,
1954
- tokenToReclaim: NATIVE_TOKEN,
1955
- cashOutCount: 0,
1956
- minTokensReclaimed: 0,
1957
- beneficiary: payable(user1),
1958
- metadata: cashOutMeta
1959
- });
1960
-
1961
- uint256 reclaimed = user1.balance - balBefore;
1962
- // With 90% tax, should get less than what was paid (1 ETH).
1963
- assertLt(reclaimed, 1 ether, "90% tax should yield less than paid");
1964
- }
1965
-
1966
- // =====================================================================
1967
- // SECTION 29: MULTIPLE PROJECTS SHARING SAME STORE
1968
- // =====================================================================
1969
-
1970
- /// @notice Two projects using the same store should not interfere with each other.
1971
- function test_fork_crossProjectIsolation() public {
1972
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 10, false);
1973
- tierConfigs[0].reserveFrequency = 0;
1974
- JB721TiersHookFlags memory flags = _defaultFlags();
1975
-
1976
- (uint256 projectId1, address hook1) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1977
- (, address hook2) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
1978
-
1979
- assertTrue(hook1 != hook2, "hooks are different clones");
1980
-
1981
- // Mint from project 1.
1982
- uint16[] memory tierIds = new uint16[](3);
1983
- tierIds[0] = 1;
1984
- tierIds[1] = 1;
1985
- tierIds[2] = 1;
1986
- bytes memory meta1 = _buildPayMetadata(hook1, tierIds, true);
1987
- vm.prank(payer);
1988
- jbMultiTerminal.pay{value: 0.03 ether}({
1989
- projectId: projectId1,
1990
- amount: 0.03 ether,
1991
- token: NATIVE_TOKEN,
1992
- beneficiary: beneficiary,
1993
- minReturnedTokens: 0,
1994
- memo: "",
1995
- metadata: meta1
1996
- });
1997
-
1998
- // Project 2's supply should be unaffected.
1999
- JB721Tier memory p2Tier = store.tierOf(hook2, 1, false);
2000
- assertEq(p2Tier.remainingSupply, 10, "project 2 supply unaffected");
2001
-
2002
- // Project 1's supply should be decreased.
2003
- JB721Tier memory p1Tier = store.tierOf(hook1, 1, false);
2004
- assertEq(p1Tier.remainingSupply, 7, "project 1 supply decreased");
2005
- }
2006
-
2007
- // =====================================================================
2008
- // SECTION 30: BURN AFTER CASH OUT AND REMINT
2009
- // =====================================================================
2010
-
2011
- /// @notice After burning via cash out, new mints get different token numbers.
2012
- function test_fork_tokenNumbersAfterBurn() public {
2013
- JB721TierConfig[] memory tierConfigs = _makeStandardTiers(1, 100, false);
2014
- tierConfigs[0].reserveFrequency = 0;
2015
- JB721TiersHookFlags memory flags = _defaultFlags();
2016
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 0, true, 0x00);
2017
-
2018
- // Mint token #1.
2019
- uint16[] memory tierIds = new uint16[](1);
2020
- tierIds[0] = 1;
2021
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
2022
-
2023
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), beneficiary, "token #1 exists");
2024
-
2025
- // Cash out token #1 (burn it).
2026
- uint256[] memory toCashOut = new uint256[](1);
2027
- toCashOut[0] = _tokenId(1, 1);
2028
- bytes memory cashOutMeta = _buildCashOutMetadata(hook, toCashOut);
2029
- vm.prank(beneficiary);
2030
- jbMultiTerminal.cashOutTokensOf({
2031
- holder: beneficiary,
2032
- projectId: projectId,
2033
- tokenToReclaim: NATIVE_TOKEN,
2034
- cashOutCount: 0,
2035
- minTokensReclaimed: 0,
2036
- beneficiary: payable(beneficiary),
2037
- metadata: cashOutMeta
2038
- });
2039
-
2040
- // Mint again — should get token #2, not #1 (burned tokens don't recycle numbers).
2041
- _payAndMint(projectId, 0.01 ether, tierIds, true, hook);
2042
-
2043
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 2)), beneficiary, "token #2 after burn");
2044
-
2045
- // Token #1 should no longer exist (burned).
2046
- vm.expectRevert();
2047
- IERC721(hook).ownerOf(_tokenId(1, 1));
2048
- }
2049
-
2050
- // =====================================================================
2051
- // SECTION 31: TIER SPLITS
2052
- /// @dev Helper: build a single split tier config.
2053
- function _makeSplitTierConfig(
2054
- uint104 price,
2055
- uint32 splitPct,
2056
- JBSplit[] memory splits,
2057
- uint24 category
2058
- )
2059
- internal
2060
- pure
2061
- returns (JB721TierConfig memory)
2062
- {
2063
- return JB721TierConfig({
2064
- price: price,
2065
- initialSupply: 10,
2066
- votingUnits: 0,
2067
- reserveFrequency: 0,
2068
- reserveBeneficiary: address(0),
2069
- encodedIPFSUri: IPFS_URI,
2070
- category: category,
2071
- discountPercent: 0,
2072
- flags: JB721TierConfigFlags({
2073
- allowOwnerMint: false,
2074
- useReserveBeneficiaryAsDefault: false,
2075
- transfersPausable: false,
2076
- useVotingUnits: false,
2077
- cantBeRemoved: false,
2078
- cantIncreaseDiscountPercent: false,
2079
- cantBuyWithCredits: false
2080
- }),
2081
- splitPercent: splitPct,
2082
- splits: splits
2083
- });
2084
- }
2085
-
2086
- /// @notice A tier with splitPercent routes that portion of the payment to the split beneficiary.
2087
- /// The payer's token weight is reduced by the split fraction.
2088
- function test_fork_tierSplit_routesFundsToSplitBeneficiary() public {
2089
- address splitReceiver = makeAddr("splitReceiver");
2090
-
2091
- JBSplit[] memory splits = new JBSplit[](1);
2092
- splits[0] = JBSplit({
2093
- preferAddToBalance: false,
2094
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT), // 100% of split amount goes to this beneficiary
2095
- projectId: 0,
2096
- beneficiary: payable(splitReceiver),
2097
- lockedUntil: 0,
2098
- hook: IJBSplitHook(address(0))
2099
- });
2100
-
2101
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
2102
- tierConfigs[0] = _makeSplitTierConfig(1 ether, uint32(JBConstants.SPLITS_TOTAL_PERCENT / 2), splits, 100);
2103
-
2104
- JB721TiersHookFlags memory flags = _defaultFlags();
2105
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
2106
-
2107
- // Pay 1 ETH to mint the tier (tier ID = 1, since it's the first added).
2108
- uint16[] memory tierIds = new uint16[](1);
2109
- tierIds[0] = 1;
2110
- uint256 tokensMinted = _payAndMint(projectId, 1 ether, tierIds, true, hook);
2111
-
2112
- // Split receiver should have received 0.5 ETH (50% of 1 ETH tier price).
2113
- assertEq(splitReceiver.balance, 0.5 ether, "split receiver got 50%");
2114
-
2115
- // Beneficiary should own the NFT.
2116
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), beneficiary, "NFT minted to beneficiary");
2117
-
2118
- // Token weight is reduced by split: weight = 1M * (1 - 0.5) = 500k tokens/ETH.
2119
- // Then 50% reserved percent halves it further: payer gets 250k tokens.
2120
- assertEq(tokensMinted, 250_000e18, "tokens minted for non-split portion (after reserved)");
2121
- }
2122
-
2123
- /// @notice When issueTokensForSplits is true, payer gets full token credit despite splits.
2124
- function test_fork_tierSplit_issueTokensForSplits() public {
2125
- address splitReceiver = makeAddr("splitReceiver");
2126
-
2127
- JBSplit[] memory splits = new JBSplit[](1);
2128
- splits[0] = JBSplit({
2129
- preferAddToBalance: false,
2130
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
2131
- projectId: 0,
2132
- beneficiary: payable(splitReceiver),
2133
- lockedUntil: 0,
2134
- hook: IJBSplitHook(address(0))
2135
- });
2136
-
2137
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
2138
- tierConfigs[0] = _makeSplitTierConfig(1 ether, uint32(JBConstants.SPLITS_TOTAL_PERCENT / 2), splits, 100);
2139
-
2140
- // Enable issueTokensForSplits.
2141
- JB721TiersHookFlags memory flags = _defaultFlags();
2142
- flags.issueTokensForSplits = true;
2143
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
2144
-
2145
- uint16[] memory tierIds = new uint16[](1);
2146
- tierIds[0] = 1;
2147
- uint256 tokensMinted = _payAndMint(projectId, 1 ether, tierIds, true, hook);
2148
-
2149
- // Split receiver still gets their ETH.
2150
- assertEq(splitReceiver.balance, 0.5 ether, "split receiver got 50%");
2151
-
2152
- // Payer gets FULL token credit (weight not reduced by split).
2153
- // 1M tokens/ETH * 1 ETH * (1 - 50% reserved) = 500k tokens.
2154
- assertEq(tokensMinted, 500_000e18, "full tokens despite split (after reserved)");
2155
- }
2156
-
2157
- /// @notice Mix of tiers: one with splits, one without. Only the split tier routes funds.
2158
- function test_fork_tierSplit_mixedTiers() public {
2159
- address splitReceiver = makeAddr("splitReceiver");
2160
-
2161
- JBSplit[] memory splits = new JBSplit[](1);
2162
- splits[0] = JBSplit({
2163
- preferAddToBalance: false,
2164
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
2165
- projectId: 0,
2166
- beneficiary: payable(splitReceiver),
2167
- lockedUntil: 0,
2168
- hook: IJBSplitHook(address(0))
2169
- });
2170
-
2171
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](2);
2172
- // Tier 1: no splits (0.5 ETH).
2173
- tierConfigs[0] = _makeSplitTierConfig(0.5 ether, 0, new JBSplit[](0), 100);
2174
- // Tier 2: 50% split (0.5 ETH) → 0.25 ETH routed. Higher category for sort order.
2175
- tierConfigs[1] = _makeSplitTierConfig(0.5 ether, uint32(JBConstants.SPLITS_TOTAL_PERCENT / 2), splits, 200);
2176
-
2177
- JB721TiersHookFlags memory flags = _defaultFlags();
2178
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
2179
-
2180
- // Mint both tiers (total: 1 ETH).
2181
- uint16[] memory tierIds = new uint16[](2);
2182
- tierIds[0] = 1;
2183
- tierIds[1] = 2;
2184
- uint256 tokensMinted = _payAndMint(projectId, 1 ether, tierIds, true, hook);
2185
-
2186
- // Split receiver gets 0.25 ETH (50% of tier 2's 0.5 ETH price).
2187
- assertEq(splitReceiver.balance, 0.25 ether, "split receiver got 50% of tier 2 price");
2188
-
2189
- // Both NFTs minted.
2190
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), beneficiary, "tier 1 NFT minted");
2191
- assertEq(IERC721(hook).ownerOf(_tokenId(2, 1)), beneficiary, "tier 2 NFT minted");
2192
-
2193
- // Weight reduced by split fraction: 1M * (1 - 0.25) / 1 = 750k, then 50% reserved = 375k.
2194
- assertEq(tokensMinted, 375_000e18, "tokens reduced by split fraction (after reserved)");
2195
- }
2196
-
2197
- /// @notice Split with no valid recipient (no projectId, no beneficiary) sends funds
2198
- /// back into the project's terminal balance as leftover.
2199
- function test_fork_tierSplit_preferAddToBalance() public {
2200
- JBSplit[] memory splits = new JBSplit[](1);
2201
- splits[0] = JBSplit({
2202
- preferAddToBalance: true,
2203
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
2204
- projectId: 0,
2205
- beneficiary: payable(address(0)),
2206
- lockedUntil: 0,
2207
- hook: IJBSplitHook(address(0))
2208
- });
2209
-
2210
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
2211
- tierConfigs[0] = _makeSplitTierConfig(1 ether, uint32(JBConstants.SPLITS_TOTAL_PERCENT), splits, 100);
2212
-
2213
- JB721TiersHookFlags memory flags = _defaultFlags();
2214
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
2215
-
2216
- uint16[] memory tierIds = new uint16[](1);
2217
- tierIds[0] = 1;
2218
- _payAndMint(projectId, 1 ether, tierIds, true, hook);
2219
-
2220
- // NFT still minted.
2221
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), beneficiary, "NFT minted");
2222
-
2223
- // All 1 ETH should be in the project's terminal balance (split had no valid recipient,
2224
- // so leftover is added back to balance).
2225
- uint256 projectBalance = jbTerminalStore.balanceOf(address(jbMultiTerminal), projectId, NATIVE_TOKEN);
2226
- assertEq(projectBalance, 1 ether, "full amount in project balance");
2227
- }
2228
-
2229
- /// @notice 100% split with valid receiver means weight goes to zero (no project tokens minted).
2230
- function test_fork_tierSplit_fullSplitZerosWeight() public {
2231
- address splitReceiver = makeAddr("splitReceiver");
2232
-
2233
- JBSplit[] memory splits = new JBSplit[](1);
2234
- splits[0] = JBSplit({
2235
- preferAddToBalance: false,
2236
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
2237
- projectId: 0,
2238
- beneficiary: payable(splitReceiver),
2239
- lockedUntil: 0,
2240
- hook: IJBSplitHook(address(0))
2241
- });
2242
-
2243
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
2244
- tierConfigs[0] = _makeSplitTierConfig(1 ether, uint32(JBConstants.SPLITS_TOTAL_PERCENT), splits, 100);
2245
-
2246
- JB721TiersHookFlags memory flags = _defaultFlags();
2247
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
2248
-
2249
- uint16[] memory tierIds = new uint16[](1);
2250
- tierIds[0] = 1;
2251
- uint256 tokensMinted = _payAndMint(projectId, 1 ether, tierIds, true, hook);
2252
-
2253
- // All funds routed to split receiver.
2254
- assertEq(splitReceiver.balance, 1 ether, "split receiver got 100%");
2255
-
2256
- // Zero project tokens minted (weight set to 0 when splits consume entire payment).
2257
- assertEq(tokensMinted, 0, "zero tokens when full split");
2258
-
2259
- // NFT still minted.
2260
- assertEq(IERC721(hook).ownerOf(_tokenId(1, 1)), beneficiary, "NFT still minted");
2261
- }
2262
-
2263
- /// @notice Split to another Juicebox project (via pay into its terminal).
2264
- function test_fork_tierSplit_toProject() public {
2265
- address splitReceiver = makeAddr("splitReceiver");
2266
-
2267
- // First launch a receiver project (simple, no hooks).
2268
- JBRulesetMetadata memory receiverMetadata = JBRulesetMetadata({
2269
- reservedPercent: 0,
2270
- cashOutTaxRate: 0,
2271
- // forge-lint: disable-next-line(unsafe-typecast)
2272
- baseCurrency: uint32(uint160(NATIVE_TOKEN)),
2273
- pausePay: false,
2274
- pauseCreditTransfers: false,
2275
- allowOwnerMinting: false,
2276
- allowSetCustomToken: false,
2277
- allowTerminalMigration: false,
2278
- allowSetTerminals: false,
2279
- allowSetController: false,
2280
- allowAddAccountingContext: false,
2281
- allowAddPriceFeed: false,
2282
- ownerMustSendPayouts: false,
2283
- holdFees: false,
2284
- useTotalSurplusForCashOuts: false,
2285
- useDataHookForPay: false,
2286
- useDataHookForCashOut: false,
2287
- dataHook: address(0),
2288
- metadata: 0
2289
- });
2290
-
2291
- JBRulesetConfig[] memory receiverRulesets = new JBRulesetConfig[](1);
2292
- receiverRulesets[0] = JBRulesetConfig({
2293
- mustStartAtOrAfter: 0,
2294
- duration: 0,
2295
- weight: 1_000_000e18,
2296
- weightCutPercent: 0,
2297
- approvalHook: IJBRulesetApprovalHook(address(0)),
2298
- metadata: receiverMetadata,
2299
- splitGroups: new JBSplitGroup[](0),
2300
- fundAccessLimitGroups: new JBFundAccessLimitGroup[](0)
2301
- });
2302
-
2303
- JBAccountingContext[] memory receiverAccounting = new JBAccountingContext[](1);
2304
- receiverAccounting[0] =
2305
- // forge-lint: disable-next-line(unsafe-typecast)
2306
- JBAccountingContext({token: NATIVE_TOKEN, currency: uint32(uint160(NATIVE_TOKEN)), decimals: 18});
2307
- JBTerminalConfig[] memory receiverTerminals = new JBTerminalConfig[](1);
2308
- receiverTerminals[0] =
2309
- JBTerminalConfig({terminal: jbMultiTerminal, accountingContextsToAccept: receiverAccounting});
2310
-
2311
- vm.prank(multisig);
2312
- uint256 receiverProjectId = jbController.launchProjectFor({
2313
- owner: multisig,
2314
- projectUri: "receiver-project",
2315
- rulesetConfigurations: receiverRulesets,
2316
- terminalConfigurations: receiverTerminals,
2317
- memo: ""
2318
- });
2319
-
2320
- // Now create the 721 hook project with a split that pays into the receiver project.
2321
- JBSplit[] memory splits = new JBSplit[](1);
2322
- splits[0] = JBSplit({
2323
- preferAddToBalance: false,
2324
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
2325
- // forge-lint: disable-next-line(unsafe-typecast)
2326
- projectId: uint56(receiverProjectId),
2327
- beneficiary: payable(splitReceiver),
2328
- lockedUntil: 0,
2329
- hook: IJBSplitHook(address(0))
2330
- });
2331
-
2332
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
2333
- tierConfigs[0] = _makeSplitTierConfig(1 ether, uint32(JBConstants.SPLITS_TOTAL_PERCENT / 2), splits, 100);
2334
-
2335
- JB721TiersHookFlags memory flags = _defaultFlags();
2336
- (uint256 projectId, address hook) = _launchProject(tierConfigs, flags, 5000, true, 0x00);
2337
-
2338
- uint16[] memory tierIds = new uint16[](1);
2339
- tierIds[0] = 1;
2340
- _payAndMint(projectId, 1 ether, tierIds, true, hook);
2341
-
2342
- // Receiver project should have 0.5 ETH in its balance.
2343
- uint256 receiverBalance = jbTerminalStore.balanceOf(address(jbMultiTerminal), receiverProjectId, NATIVE_TOKEN);
2344
- assertEq(receiverBalance, 0.5 ether, "receiver project got split funds");
2345
- }
2346
- }