@bananapus/distributor-v6 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,603 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ // Core contracts.
7
+ import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
8
+ import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
9
+ import {JBDirectory} from "@bananapus/core-v6/src/JBDirectory.sol";
10
+ import {JBController} from "@bananapus/core-v6/src/JBController.sol";
11
+ import {JBMultiTerminal} from "@bananapus/core-v6/src/JBMultiTerminal.sol";
12
+ import {JBTerminalStore} from "@bananapus/core-v6/src/JBTerminalStore.sol";
13
+ import {JBRulesets} from "@bananapus/core-v6/src/JBRulesets.sol";
14
+ import {JBTokens} from "@bananapus/core-v6/src/JBTokens.sol";
15
+ import {JBPrices} from "@bananapus/core-v6/src/JBPrices.sol";
16
+ import {JBSplits} from "@bananapus/core-v6/src/JBSplits.sol";
17
+ import {JBFundAccessLimits} from "@bananapus/core-v6/src/JBFundAccessLimits.sol";
18
+ import {JBFeelessAddresses} from "@bananapus/core-v6/src/JBFeelessAddresses.sol";
19
+ import {JBERC20} from "@bananapus/core-v6/src/JBERC20.sol";
20
+
21
+ // Core interfaces.
22
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
23
+ import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
24
+ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
25
+ import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
26
+
27
+ // Core structs.
28
+ import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
29
+ import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
30
+ import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
31
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
32
+ import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
33
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
34
+ import {JBFundAccessLimitGroup} from "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
35
+ import {JBCurrencyAmount} from "@bananapus/core-v6/src/structs/JBCurrencyAmount.sol";
36
+
37
+ // Core libraries.
38
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
39
+
40
+ // OZ.
41
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
42
+ import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
43
+
44
+ // Permit2.
45
+ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
46
+
47
+ // Distributor.
48
+ import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
49
+
50
+ /// @notice Fork tests for JBTokenDistributor against real JB core on mainnet fork.
51
+ /// @dev Deploys full JB core, launches a project with JBERC20, and tests the complete
52
+ /// fund -> vest -> collect lifecycle using real IVotes checkpoints.
53
+ contract TokenDistributorForkTest is Test {
54
+ // -- JB core --
55
+ JBPermissions jbPermissions;
56
+ JBProjects jbProjects;
57
+ JBDirectory jbDirectory;
58
+ JBRulesets jbRulesets;
59
+ JBTokens jbTokens;
60
+ JBPrices jbPrices;
61
+ JBSplits jbSplits;
62
+ JBFundAccessLimits jbFundAccessLimits;
63
+ JBFeelessAddresses jbFeelessAddresses;
64
+ JBController jbController;
65
+ JBTerminalStore jbTerminalStore;
66
+ JBMultiTerminal jbMultiTerminal;
67
+
68
+ // -- Distributor --
69
+ JBTokenDistributor distributor;
70
+
71
+ // -- Actors --
72
+ address multisig;
73
+ address alice;
74
+ address bob;
75
+ address carol;
76
+
77
+ // -- Project state --
78
+ uint256 feeProjectId;
79
+ uint256 projectId;
80
+ IJBToken projectToken;
81
+
82
+ // -- Config --
83
+ uint256 constant ROUND_DURATION = 1 weeks;
84
+ uint256 constant VESTING_ROUNDS = 4;
85
+
86
+ // Mainnet Permit2.
87
+ IPermit2 constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
88
+
89
+ function setUp() public {
90
+ vm.createSelectFork("ethereum");
91
+
92
+ // Create labeled addresses and ensure they're clean EOAs (no mainnet code).
93
+ multisig = makeAddr("test_distributor_multisig");
94
+ alice = makeAddr("test_distributor_alice");
95
+ bob = makeAddr("test_distributor_bob");
96
+ carol = makeAddr("test_distributor_carol");
97
+ vm.etch(multisig, "");
98
+ vm.etch(alice, "");
99
+ vm.etch(bob, "");
100
+ vm.etch(carol, "");
101
+
102
+ _deployJBCore();
103
+ _deployDistributor();
104
+
105
+ // Launch fee project (must be project #1).
106
+ feeProjectId = _launchFeeProject();
107
+ assertEq(feeProjectId, 1, "fee project must be #1");
108
+
109
+ // Launch test project (no splits initially — will add after token deploy).
110
+ projectId = _launchProject();
111
+
112
+ // Deploy JBERC20 for the project.
113
+ vm.prank(multisig);
114
+ projectToken = jbController.deployERC20For(projectId, "Test Token", "TST", bytes32(0));
115
+
116
+ // Pay ETH into the project to build surplus (mints JBERC20 to payers).
117
+ _payProject(alice, 70 ether);
118
+ _payProject(bob, 30 ether);
119
+
120
+ // Delegates must delegate to themselves for IVotes checkpoints.
121
+ vm.prank(alice);
122
+ JBERC20(address(projectToken)).delegate(alice);
123
+
124
+ vm.prank(bob);
125
+ JBERC20(address(projectToken)).delegate(bob);
126
+
127
+ // Advance a block so getPastVotes works.
128
+ vm.roll(block.number + 1);
129
+ }
130
+
131
+ // ======================================================================
132
+ // TESTS
133
+ // ======================================================================
134
+
135
+ /// @notice Direct funding with real JBERC20 delegation: fund -> vest -> full collect.
136
+ function test_fork_directFund_vestCollect() public {
137
+ address hook = address(projectToken);
138
+
139
+ // Fund the distributor directly with ETH.
140
+ distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
141
+
142
+ // Advance to round 1.
143
+ _advanceToRound(1);
144
+
145
+ uint256[] memory tokenIds = new uint256[](2);
146
+ tokenIds[0] = _tokenId(alice);
147
+ tokenIds[1] = _tokenId(bob);
148
+ IERC20[] memory tokens = new IERC20[](1);
149
+ tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
150
+
151
+ distributor.beginVesting(hook, tokenIds, tokens);
152
+
153
+ uint256 aliceClaimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
154
+ uint256 bobClaimed = distributor.claimedFor(hook, _tokenId(bob), IERC20(JBConstants.NATIVE_TOKEN));
155
+
156
+ // Total = 10 ETH. Allocation proportional to voting power.
157
+ assertEq(aliceClaimed + bobClaimed, 10 ether, "Sum should equal funded amount");
158
+ assertGt(aliceClaimed, bobClaimed, "Alice should get more (70% vs 30%)");
159
+
160
+ // Full vest + collect.
161
+ _advanceToRound(1 + VESTING_ROUNDS);
162
+
163
+ uint256 aliceBefore = alice.balance;
164
+ vm.prank(alice);
165
+ distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
166
+ uint256 bobBefore = bob.balance;
167
+ vm.prank(bob);
168
+ distributor.collectVestedRewards(hook, _singleTokenId(bob), tokens, bob);
169
+
170
+ assertEq(alice.balance - aliceBefore, aliceClaimed, "Alice ETH collected");
171
+ assertEq(bob.balance - bobBefore, bobClaimed, "Bob ETH collected");
172
+ }
173
+
174
+ /// @notice Undelegated tokens don't earn rewards — funds not allocated to undelegated holders.
175
+ function test_fork_undelegatedTokens_noRewards() public {
176
+ address hook = address(projectToken);
177
+
178
+ // Carol pays but does NOT delegate.
179
+ _payProject(carol, 50 ether);
180
+ vm.roll(block.number + 1);
181
+
182
+ distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
183
+ _advanceToRound(1);
184
+
185
+ uint256[] memory tokenIds = new uint256[](3);
186
+ tokenIds[0] = _tokenId(alice);
187
+ tokenIds[1] = _tokenId(bob);
188
+ tokenIds[2] = _tokenId(carol);
189
+ IERC20[] memory tokens = new IERC20[](1);
190
+ tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
191
+
192
+ distributor.beginVesting(hook, tokenIds, tokens);
193
+
194
+ // Carol should have 0 claimed (no delegation).
195
+ uint256 carolClaimed = distributor.claimedFor(hook, _tokenId(carol), IERC20(JBConstants.NATIVE_TOKEN));
196
+ assertEq(carolClaimed, 0, "Carol should have 0 (not delegated)");
197
+
198
+ // Undelegated portion (Carol's supply) dilutes total supply, reducing Alice/Bob allocations.
199
+ uint256 totalVesting = distributor.totalVestingAmountOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
200
+ assertLt(totalVesting, 10 ether, "Not all funds distributed (undelegated supply dilutes)");
201
+ }
202
+
203
+ /// @notice Partial vesting — collect mid-way through vesting period.
204
+ function test_fork_partialVesting_linearUnlock() public {
205
+ address hook = address(projectToken);
206
+ distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
207
+ _advanceToRound(1);
208
+
209
+ uint256[] memory tokenIds = new uint256[](1);
210
+ tokenIds[0] = _tokenId(alice);
211
+ IERC20[] memory tokens = new IERC20[](1);
212
+ tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
213
+
214
+ distributor.beginVesting(hook, tokenIds, tokens);
215
+
216
+ uint256 aliceClaimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
217
+ assertGt(aliceClaimed, 0, "Alice claimed something");
218
+
219
+ // After 2 of 4 vesting rounds, 50% collectable.
220
+ _advanceToRound(3);
221
+ uint256 collectable = distributor.collectableFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
222
+ assertApproxEqAbs(collectable, aliceClaimed / 2, 1, "~50% collectable at midpoint");
223
+
224
+ // Collect partial.
225
+ uint256 aliceBefore = alice.balance;
226
+ vm.prank(alice);
227
+ distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
228
+ assertApproxEqAbs(alice.balance - aliceBefore, aliceClaimed / 2, 1, "Alice gets ~50%");
229
+ }
230
+
231
+ /// @notice Multi-round: fund in round 1, fund more in round 3, collect all.
232
+ function test_fork_multiRound_carryOverAndNewFunding() public {
233
+ address hook = address(projectToken);
234
+
235
+ // Fund round 1.
236
+ distributor.fund{value: 5 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 5 ether);
237
+ _advanceToRound(1);
238
+
239
+ uint256[] memory tokenIds = new uint256[](2);
240
+ tokenIds[0] = _tokenId(alice);
241
+ tokenIds[1] = _tokenId(bob);
242
+ IERC20[] memory tokens = new IERC20[](1);
243
+ tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
244
+
245
+ distributor.beginVesting(hook, tokenIds, tokens);
246
+
247
+ uint256 round1AliceClaimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
248
+
249
+ // Fund more in round 3.
250
+ _advanceToRound(3);
251
+ distributor.fund{value: 5 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 5 ether);
252
+
253
+ // Begin vesting round 3 funds.
254
+ _advanceToRound(4);
255
+ distributor.beginVesting(hook, tokenIds, tokens);
256
+
257
+ // Advance past both vesting periods.
258
+ _advanceToRound(1 + VESTING_ROUNDS + VESTING_ROUNDS);
259
+
260
+ // Collect all.
261
+ uint256 aliceBefore = alice.balance;
262
+ vm.prank(alice);
263
+ distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
264
+ uint256 aliceCollected = alice.balance - aliceBefore;
265
+
266
+ uint256 bobBefore = bob.balance;
267
+ vm.prank(bob);
268
+ distributor.collectVestedRewards(hook, _singleTokenId(bob), tokens, bob);
269
+ uint256 bobCollected = bob.balance - bobBefore;
270
+
271
+ // Conservation: alice + bob should account for all distributed funds.
272
+ uint256 totalCollected = aliceCollected + bobCollected;
273
+ assertGt(totalCollected, 0, "Non-zero collection");
274
+ assertLe(totalCollected, 10 ether, "Cannot collect more than funded");
275
+ // Alice got rewards from both rounds.
276
+ assertGt(aliceCollected, round1AliceClaimed, "Alice got more than just round 1");
277
+ }
278
+
279
+ /// @notice Split integration: queue a ruleset with distributor split, trigger payout.
280
+ function test_fork_payoutSplit_fundsDistributor() public {
281
+ address hook = address(projectToken);
282
+
283
+ // Queue a new ruleset with splits configured.
284
+ _queueRulesetWithDistributorSplit(hook);
285
+
286
+ // Pay more ETH into the project to build payout balance.
287
+ _payProject(alice, 20 ether);
288
+ vm.roll(block.number + 1);
289
+
290
+ // Trigger payouts.
291
+ vm.prank(multisig);
292
+ jbMultiTerminal.sendPayoutsOf({
293
+ projectId: projectId,
294
+ token: JBConstants.NATIVE_TOKEN,
295
+ amount: 10 ether,
296
+ currency: uint256(uint160(JBConstants.NATIVE_TOKEN)),
297
+ minTokensPaidOut: 0
298
+ });
299
+
300
+ // Verify distributor received funds.
301
+ uint256 distributorBalance = distributor.balanceOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
302
+ assertGt(distributorBalance, 0, "Distributor should have received ETH from payout");
303
+
304
+ // Vest and collect.
305
+ _advanceToRound(1);
306
+
307
+ uint256[] memory tokenIds = new uint256[](2);
308
+ tokenIds[0] = _tokenId(alice);
309
+ tokenIds[1] = _tokenId(bob);
310
+ IERC20[] memory tokens = new IERC20[](1);
311
+ tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
312
+
313
+ distributor.beginVesting(hook, tokenIds, tokens);
314
+
315
+ uint256 aliceClaimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
316
+ assertGt(aliceClaimed, 0, "Alice claimed from payout-funded distributor");
317
+ }
318
+
319
+ /// @notice Poke records snapshot blocks correctly and is idempotent.
320
+ function test_fork_poke_snapshotConsistency() public {
321
+ _advanceToRound(1);
322
+
323
+ uint256 blockBefore = block.number;
324
+ distributor.poke();
325
+
326
+ uint256 snapshotBlock = distributor.roundSnapshotBlock(1);
327
+ assertEq(snapshotBlock, blockBefore - 1, "Snapshot = block.number - 1");
328
+
329
+ // Next round should also be eagerly locked.
330
+ uint256 nextSnapshot = distributor.roundSnapshotBlock(2);
331
+ assertEq(nextSnapshot, blockBefore - 1, "Eager lock round+1");
332
+
333
+ // Idempotent.
334
+ vm.roll(block.number + 10);
335
+ distributor.poke();
336
+ assertEq(distributor.roundSnapshotBlock(1), snapshotBlock, "Poke idempotent");
337
+ }
338
+
339
+ /// @notice Cannot collect another staker's rewards.
340
+ function test_fork_cannotCollectOthersRewards() public {
341
+ address hook = address(projectToken);
342
+ distributor.fund{value: 5 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 5 ether);
343
+ _advanceToRound(1);
344
+
345
+ uint256[] memory tokenIds = new uint256[](1);
346
+ tokenIds[0] = _tokenId(alice);
347
+ IERC20[] memory tokens = new IERC20[](1);
348
+ tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
349
+ distributor.beginVesting(hook, tokenIds, tokens);
350
+
351
+ _advanceToRound(1 + VESTING_ROUNDS);
352
+
353
+ // Bob tries to collect Alice's rewards.
354
+ vm.prank(bob);
355
+ vm.expectRevert();
356
+ distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, bob);
357
+ }
358
+
359
+ /// @notice Auto-vest: calling collectVestedRewards without explicit beginVesting still works.
360
+ function test_fork_autoVest_collectWithoutExplicitBeginVesting() public {
361
+ address hook = address(projectToken);
362
+ distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
363
+
364
+ // Skip beginVesting — go straight to collect.
365
+ _advanceToRound(1 + VESTING_ROUNDS);
366
+
367
+ IERC20[] memory tokens = new IERC20[](1);
368
+ tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
369
+
370
+ vm.prank(alice);
371
+ distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
372
+
373
+ // Auto-vest should have kicked in — Alice got some rewards.
374
+ uint256 claimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
375
+ assertGt(claimed, 0, "Auto-vest should have created a vesting entry");
376
+ }
377
+
378
+ /// @notice Invariant: totalVestingAmount never exceeds balance.
379
+ function test_fork_conservationInvariant() public {
380
+ address hook = address(projectToken);
381
+ distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
382
+ _advanceToRound(1);
383
+
384
+ uint256[] memory tokenIds = new uint256[](2);
385
+ tokenIds[0] = _tokenId(alice);
386
+ tokenIds[1] = _tokenId(bob);
387
+ IERC20[] memory tokens = new IERC20[](1);
388
+ tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
389
+
390
+ distributor.beginVesting(hook, tokenIds, tokens);
391
+
392
+ uint256 totalVesting = distributor.totalVestingAmountOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
393
+ uint256 balance = distributor.balanceOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
394
+ assertLe(totalVesting, balance, "Vesting <= balance (conservation)");
395
+
396
+ // Partially collect.
397
+ _advanceToRound(3);
398
+ vm.prank(alice);
399
+ distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
400
+
401
+ totalVesting = distributor.totalVestingAmountOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
402
+ balance = distributor.balanceOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
403
+ assertLe(totalVesting, balance, "Vesting <= balance after partial collect");
404
+ }
405
+
406
+ // ======================================================================
407
+ // HELPERS
408
+ // ======================================================================
409
+
410
+ function _tokenId(address staker) internal pure returns (uint256) {
411
+ return uint256(uint160(staker));
412
+ }
413
+
414
+ function _singleTokenId(address staker) internal pure returns (uint256[] memory ids) {
415
+ ids = new uint256[](1);
416
+ ids[0] = _tokenId(staker);
417
+ }
418
+
419
+ function _advanceToRound(uint256 round) internal {
420
+ uint256 target = distributor.roundStartTimestamp(round) + 1;
421
+ if (block.timestamp < target) vm.warp(target);
422
+ vm.roll(block.number + 1);
423
+ }
424
+
425
+ function _payProject(address payer, uint256 amount) internal {
426
+ vm.deal(payer, amount);
427
+ vm.prank(payer);
428
+ jbMultiTerminal.pay{value: amount}({
429
+ projectId: projectId,
430
+ token: JBConstants.NATIVE_TOKEN,
431
+ amount: amount,
432
+ beneficiary: payer,
433
+ minReturnedTokens: 0,
434
+ memo: "",
435
+ metadata: ""
436
+ });
437
+ }
438
+
439
+ // ======================================================================
440
+ // DEPLOYMENT
441
+ // ======================================================================
442
+
443
+ function _deployJBCore() internal {
444
+ jbPermissions = new JBPermissions(address(0));
445
+ jbProjects = new JBProjects(multisig, address(0), address(0));
446
+ jbDirectory = new JBDirectory(jbPermissions, jbProjects, multisig);
447
+
448
+ JBERC20 jbErc20 = new JBERC20(jbPermissions, jbProjects);
449
+ jbTokens = new JBTokens(jbDirectory, jbErc20);
450
+
451
+ jbRulesets = new JBRulesets(jbDirectory);
452
+ jbPrices = new JBPrices(jbDirectory, jbPermissions, jbProjects, multisig, address(0));
453
+
454
+ jbSplits = new JBSplits(jbDirectory);
455
+ jbFundAccessLimits = new JBFundAccessLimits(jbDirectory);
456
+ jbFeelessAddresses = new JBFeelessAddresses(multisig);
457
+
458
+ jbController = new JBController(
459
+ jbDirectory,
460
+ jbFundAccessLimits,
461
+ jbPermissions,
462
+ jbPrices,
463
+ jbProjects,
464
+ jbRulesets,
465
+ jbSplits,
466
+ jbTokens,
467
+ address(0),
468
+ address(0)
469
+ );
470
+
471
+ vm.prank(multisig);
472
+ jbDirectory.setIsAllowedToSetFirstController(address(jbController), true);
473
+
474
+ jbTerminalStore = new JBTerminalStore(jbDirectory, jbPrices, jbRulesets);
475
+
476
+ jbMultiTerminal = new JBMultiTerminal(
477
+ jbFeelessAddresses, jbPermissions, jbProjects, jbSplits, jbTerminalStore, jbTokens, PERMIT2, address(0)
478
+ );
479
+ }
480
+
481
+ function _deployDistributor() internal {
482
+ distributor = new JBTokenDistributor(IJBDirectory(address(jbDirectory)), ROUND_DURATION, VESTING_ROUNDS);
483
+
484
+ // Mark the distributor as feeless so payouts to it aren't reduced by the 2.5% fee.
485
+ vm.prank(multisig);
486
+ jbFeelessAddresses.setFeelessAddress(address(distributor), true);
487
+ }
488
+
489
+ function _launchFeeProject() internal returns (uint256) {
490
+ JBRulesetConfig[] memory rulesets = new JBRulesetConfig[](1);
491
+ rulesets[0] = _basicRulesetConfig(new JBSplitGroup[](0), new JBFundAccessLimitGroup[](0));
492
+
493
+ JBTerminalConfig[] memory terminals = new JBTerminalConfig[](1);
494
+ terminals[0] = JBTerminalConfig({terminal: jbMultiTerminal, accountingContextsToAccept: _ethContext()});
495
+
496
+ vm.prank(multisig);
497
+ return jbController.launchProjectFor(multisig, "", rulesets, terminals, "");
498
+ }
499
+
500
+ function _launchProject() internal returns (uint256) {
501
+ // Payout limit: allows sending 10 ETH in payouts.
502
+ JBCurrencyAmount[] memory payoutLimits = new JBCurrencyAmount[](1);
503
+ payoutLimits[0] = JBCurrencyAmount({amount: 100 ether, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
504
+
505
+ JBFundAccessLimitGroup[] memory fundLimits = new JBFundAccessLimitGroup[](1);
506
+ fundLimits[0] = JBFundAccessLimitGroup({
507
+ terminal: address(jbMultiTerminal),
508
+ token: JBConstants.NATIVE_TOKEN,
509
+ payoutLimits: payoutLimits,
510
+ surplusAllowances: new JBCurrencyAmount[](0)
511
+ });
512
+
513
+ JBRulesetConfig[] memory rulesets = new JBRulesetConfig[](1);
514
+ rulesets[0] = _basicRulesetConfig(new JBSplitGroup[](0), fundLimits);
515
+
516
+ JBTerminalConfig[] memory terminals = new JBTerminalConfig[](1);
517
+ terminals[0] = JBTerminalConfig({terminal: jbMultiTerminal, accountingContextsToAccept: _ethContext()});
518
+
519
+ vm.prank(multisig);
520
+ return jbController.launchProjectFor(multisig, "", rulesets, terminals, "");
521
+ }
522
+
523
+ function _queueRulesetWithDistributorSplit(address hook) internal {
524
+ JBSplit[] memory splits = new JBSplit[](1);
525
+ splits[0] = JBSplit({
526
+ percent: 1_000_000_000,
527
+ projectId: 0,
528
+ beneficiary: payable(hook), // The IVotes token address.
529
+ preferAddToBalance: false,
530
+ lockedUntil: 0,
531
+ hook: IJBSplitHook(address(distributor))
532
+ });
533
+
534
+ JBSplitGroup[] memory splitGroups = new JBSplitGroup[](1);
535
+ splitGroups[0] = JBSplitGroup({groupId: uint256(uint160(JBConstants.NATIVE_TOKEN)), splits: splits});
536
+
537
+ JBCurrencyAmount[] memory payoutLimits = new JBCurrencyAmount[](1);
538
+ payoutLimits[0] = JBCurrencyAmount({amount: 100 ether, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
539
+
540
+ JBFundAccessLimitGroup[] memory fundLimits = new JBFundAccessLimitGroup[](1);
541
+ fundLimits[0] = JBFundAccessLimitGroup({
542
+ terminal: address(jbMultiTerminal),
543
+ token: JBConstants.NATIVE_TOKEN,
544
+ payoutLimits: payoutLimits,
545
+ surplusAllowances: new JBCurrencyAmount[](0)
546
+ });
547
+
548
+ JBRulesetConfig[] memory rulesets = new JBRulesetConfig[](1);
549
+ rulesets[0] = _basicRulesetConfig(splitGroups, fundLimits);
550
+
551
+ vm.prank(multisig);
552
+ jbController.queueRulesetsOf(projectId, rulesets, "");
553
+ }
554
+
555
+ function _basicRulesetConfig(
556
+ JBSplitGroup[] memory splitGroups,
557
+ JBFundAccessLimitGroup[] memory fundLimits
558
+ )
559
+ internal
560
+ pure
561
+ returns (JBRulesetConfig memory)
562
+ {
563
+ JBRulesetMetadata memory metadata = JBRulesetMetadata({
564
+ reservedPercent: 0,
565
+ cashOutTaxRate: 0,
566
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
567
+ pausePay: false,
568
+ pauseCreditTransfers: false,
569
+ allowOwnerMinting: true,
570
+ allowSetCustomToken: true,
571
+ allowTerminalMigration: false,
572
+ allowSetTerminals: false,
573
+ allowSetController: false,
574
+ allowAddAccountingContext: false,
575
+ allowAddPriceFeed: false,
576
+ ownerMustSendPayouts: false,
577
+ holdFees: false,
578
+ useTotalSurplusForCashOuts: false,
579
+ useDataHookForPay: false,
580
+ useDataHookForCashOut: false,
581
+ dataHook: address(0),
582
+ metadata: 0
583
+ });
584
+
585
+ return JBRulesetConfig({
586
+ mustStartAtOrAfter: 0,
587
+ duration: 0,
588
+ weight: 1_000_000e18,
589
+ weightCutPercent: 0,
590
+ approvalHook: IJBRulesetApprovalHook(address(0)),
591
+ metadata: metadata,
592
+ splitGroups: splitGroups,
593
+ fundAccessLimitGroups: fundLimits
594
+ });
595
+ }
596
+
597
+ function _ethContext() internal pure returns (JBAccountingContext[] memory contexts) {
598
+ contexts = new JBAccountingContext[](1);
599
+ contexts[0] = JBAccountingContext({
600
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
601
+ });
602
+ }
603
+ }
@@ -6,7 +6,6 @@ import {StdInvariant} from "forge-std/StdInvariant.sol";
6
6
 
7
7
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
8
8
  import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
9
- import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
10
9
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
11
10
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
12
11
  import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
@@ -14,7 +13,6 @@ import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.
14
13
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
15
14
 
16
15
  import {JB721Distributor} from "../../src/JB721Distributor.sol";
17
- import {JBDistributor} from "../../src/JBDistributor.sol";
18
16
 
19
17
  /// @notice Simple ERC20 token for invariant testing.
20
18
  contract InvariantToken is ERC20 {
@@ -135,16 +133,16 @@ contract DistributorHandler is Test {
135
133
  uint256 public ghost_collectCalls;
136
134
  uint256 public ghost_forfeitCalls;
137
135
  uint256 public ghost_fundCalls;
138
- uint256 public ghost_rollCalls;
136
+ uint256 public ghost_warpCalls;
139
137
 
140
138
  // Track whether tokens are burned.
141
139
  bool public token1Burned;
142
140
  bool public token2Burned;
143
141
 
144
- // Track latest round we vested in per tokenId to avoid AlreadyVesting.
142
+ // Track latest round we vested in per tokenId to avoid double-vest in same round.
145
143
  mapping(uint256 tokenId => uint256 lastVestedRound) public lastVestedRoundOf;
146
144
 
147
- uint256 constant ROUND_DURATION = 100;
145
+ uint256 constant ROUND_DURATION = 100; // 100 seconds per round.
148
146
 
149
147
  constructor(
150
148
  JB721Distributor _distributor,
@@ -172,20 +170,21 @@ contract DistributorHandler is Test {
172
170
  ghost_fundCalls++;
173
171
  }
174
172
 
175
- /// @notice Advance blocks by random amount (0-3 rounds).
176
- function rollForward(uint8 rawRounds) external {
173
+ /// @notice Advance time by random amount (0-3 rounds) and advance block number.
174
+ function warpForward(uint8 rawRounds) external {
177
175
  uint256 rounds = bound(rawRounds, 0, 3);
178
176
  if (rounds > 0) {
179
- vm.roll(block.number + ROUND_DURATION * rounds);
177
+ vm.warp(block.timestamp + ROUND_DURATION * rounds);
178
+ vm.roll(block.number + 1); // Advance block for getPastVotes.
180
179
  }
181
- ghost_rollCalls++;
180
+ ghost_warpCalls++;
182
181
  }
183
182
 
184
183
  /// @notice Begin vesting for one or both tokens.
185
184
  function beginVesting(uint8 tokenSelector) external {
186
185
  uint256 currentRound = distributor.currentRound();
187
186
 
188
- // Determine which tokens to vest (avoid AlreadyVesting).
187
+ // Determine which tokens to vest (skip already-vested — they'll be silently skipped anyway).
189
188
  bool vest1 = !token1Burned && (tokenSelector % 3 != 1) && lastVestedRoundOf[1] != currentRound;
190
189
  bool vest2 = !token2Burned && (tokenSelector % 3 != 0) && lastVestedRoundOf[2] != currentRound;
191
190
 
@@ -284,7 +283,7 @@ contract JB721DistributorInvariantTest is StdInvariant, Test {
284
283
  address alice = makeAddr("alice");
285
284
  address bob = makeAddr("bob");
286
285
 
287
- uint256 constant ROUND_DURATION = 100;
286
+ uint256 constant ROUND_DURATION = 100; // 100 seconds per round.
288
287
  uint256 constant VESTING_ROUNDS = 4;
289
288
 
290
289
  function setUp() public {
@@ -405,6 +404,6 @@ contract JB721DistributorInvariantTest is StdInvariant, Test {
405
404
  handler.ghost_vestingCalls();
406
405
  handler.ghost_collectCalls();
407
406
  handler.ghost_forfeitCalls();
408
- handler.ghost_rollCalls();
407
+ handler.ghost_warpCalls();
409
408
  }
410
409
  }