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