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