@bananapus/distributor-v6 0.0.7 → 0.0.9

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.
@@ -1,503 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- import {Test} from "forge-std/Test.sol";
5
-
6
- import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7
- import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
8
- import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
9
- import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
10
-
11
- import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
12
- import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
13
- import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
14
- import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
15
- import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
16
- import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
17
-
18
- import {JBTokenDistributor} from "../src/JBTokenDistributor.sol";
19
- import {JBDistributor} from "../src/JBDistributor.sol";
20
- import {IJBTokenDistributor} from "../src/interfaces/IJBTokenDistributor.sol";
21
-
22
- /// @notice Mock JB directory for testing.
23
- contract MockDirectory {
24
- mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
25
- mapping(uint256 projectId => address controller) public controllers;
26
-
27
- function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
28
- terminals[projectId][terminal] = isTerminal;
29
- }
30
-
31
- function setController(uint256 projectId, address controller) external {
32
- controllers[projectId] = controller;
33
- }
34
-
35
- function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
36
- return terminals[projectId][address(terminal)];
37
- }
38
-
39
- function controllerOf(uint256 projectId) external view returns (IERC165) {
40
- return IERC165(controllers[projectId]);
41
- }
42
- }
43
-
44
- /// @notice Simple ERC20 token for reward payouts.
45
- contract MockRewardToken is ERC20 {
46
- constructor() ERC20("Reward", "RWD") {}
47
-
48
- function mint(address to, uint256 amount) external {
49
- _mint(to, amount);
50
- }
51
- }
52
-
53
- /// @notice ERC20Votes token for staking (mock for JBERC20).
54
- contract MockVotesToken is ERC20, ERC20Votes {
55
- constructor() ERC20("StakeToken", "STK") EIP712("StakeToken", "1") {}
56
-
57
- function mint(address to, uint256 amount) external {
58
- _mint(to, amount);
59
- }
60
-
61
- function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
62
- super._update(from, to, value);
63
- }
64
- }
65
-
66
- contract JBTokenDistributorTest is Test {
67
- MockDirectory directory;
68
- MockRewardToken rewardToken;
69
- MockVotesToken votesToken;
70
- JBTokenDistributor distributor;
71
-
72
- address alice = makeAddr("alice");
73
- address bob = makeAddr("bob");
74
- address carol = makeAddr("carol");
75
- address terminal = makeAddr("terminal");
76
- uint256 projectId = 1;
77
-
78
- // 100 seconds per round, 4 vesting rounds.
79
- uint256 constant ROUND_DURATION = 100;
80
- uint256 constant VESTING_ROUNDS = 4;
81
-
82
- function setUp() public {
83
- directory = new MockDirectory();
84
- rewardToken = new MockRewardToken();
85
- votesToken = new MockVotesToken();
86
-
87
- directory.setTerminal(projectId, terminal, true);
88
-
89
- distributor = new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
90
-
91
- // Mint staking tokens.
92
- votesToken.mint(alice, 700 ether);
93
- votesToken.mint(bob, 300 ether);
94
- }
95
-
96
- //*********************************************************************//
97
- // ----------------------------- helpers ----------------------------- //
98
- //*********************************************************************//
99
-
100
- /// @notice Encode a staker address as a tokenId.
101
- function _tokenId(address staker) internal pure returns (uint256) {
102
- return uint256(uint160(staker));
103
- }
104
-
105
- /// @notice Advance to 1 second after the start of the given round, and advance block number too.
106
- function _advanceToRound(uint256 round) internal {
107
- uint256 targetTimestamp = distributor.roundStartTimestamp(round) + 1;
108
- if (block.timestamp < targetTimestamp) {
109
- vm.warp(targetTimestamp);
110
- }
111
- // Also advance block number so getPastVotes works with past blocks.
112
- vm.roll(block.number + 1);
113
- }
114
-
115
- /// @notice Fund the distributor via the direct `fund` method.
116
- function _fundDistributor(uint256 amount) internal {
117
- rewardToken.mint(address(this), amount);
118
- rewardToken.approve(address(distributor), amount);
119
- distributor.fund(address(votesToken), IERC20(address(rewardToken)), amount);
120
- }
121
-
122
- //*********************************************************************//
123
- // ----------------------------- tests ------------------------------ //
124
- //*********************************************************************//
125
-
126
- function test_happyPath_fundVestCollect() public {
127
- // Alice delegates to self.
128
- vm.prank(alice);
129
- votesToken.delegate(alice);
130
-
131
- // Bob delegates to self.
132
- vm.prank(bob);
133
- votesToken.delegate(bob);
134
-
135
- // Fund the distributor with 1000 reward tokens.
136
- _fundDistributor(1000 ether);
137
-
138
- // Advance to round 1 (so snapshot block is in the past for getPastVotes).
139
- _advanceToRound(1);
140
-
141
- // Begin vesting for alice and bob.
142
- uint256[] memory tokenIds = new uint256[](2);
143
- tokenIds[0] = _tokenId(alice);
144
- tokenIds[1] = _tokenId(bob);
145
- IERC20[] memory tokens = new IERC20[](1);
146
- tokens[0] = IERC20(address(rewardToken));
147
-
148
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
149
-
150
- // Alice should have 700/1000 = 70% = 700 tokens claimed.
151
- uint256 aliceClaimed =
152
- distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
153
- assertEq(aliceClaimed, 700 ether, "Alice should have 700 claimed");
154
-
155
- // Bob should have 300/1000 = 30% = 300 tokens claimed.
156
- uint256 bobClaimed = distributor.claimedFor(address(votesToken), _tokenId(bob), IERC20(address(rewardToken)));
157
- assertEq(bobClaimed, 300 ether, "Bob should have 300 claimed");
158
-
159
- // Advance past full vesting (4 rounds).
160
- _advanceToRound(1 + VESTING_ROUNDS);
161
-
162
- // Alice collects.
163
- vm.prank(alice);
164
- distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
165
- assertEq(rewardToken.balanceOf(alice), 700 ether, "Alice should receive 700 tokens");
166
-
167
- // Bob collects.
168
- vm.prank(bob);
169
- distributor.collectVestedRewards(address(votesToken), _singleTokenId(bob), tokens, bob);
170
- assertEq(rewardToken.balanceOf(bob), 300 ether, "Bob should receive 300 tokens");
171
- }
172
-
173
- function test_noDelegation_zeroAllocation() public {
174
- // Alice does NOT delegate — should get 0 voting power.
175
- // Bob delegates to self.
176
- vm.prank(bob);
177
- votesToken.delegate(bob);
178
-
179
- _fundDistributor(1000 ether);
180
- _advanceToRound(1);
181
-
182
- uint256[] memory tokenIds = new uint256[](2);
183
- tokenIds[0] = _tokenId(alice);
184
- tokenIds[1] = _tokenId(bob);
185
- IERC20[] memory tokens = new IERC20[](1);
186
- tokens[0] = IERC20(address(rewardToken));
187
-
188
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
189
-
190
- // Alice has 0 claimed (no delegation = 0 votes).
191
- uint256 aliceClaimed =
192
- distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
193
- assertEq(aliceClaimed, 0, "Alice should have 0 claimed without delegation");
194
-
195
- // Bob gets all rewards because total supply includes all tokens but alice has 0 votes.
196
- // getPastVotes(bob) = 300, getPastTotalSupply = 1000 (includes undelegated).
197
- // So bob gets 300/1000 * 1000 = 300.
198
- uint256 bobClaimed = distributor.claimedFor(address(votesToken), _tokenId(bob), IERC20(address(rewardToken)));
199
- assertEq(bobClaimed, 300 ether, "Bob should have 300 claimed (his share of total supply)");
200
- }
201
-
202
- function test_nonDelegatedSupply_staysInPool() public {
203
- // Only bob delegates. Total supply = 1000, bob votes = 300.
204
- // Bob gets 300/1000 = 30%. The other 70% stays in the pool.
205
- vm.prank(bob);
206
- votesToken.delegate(bob);
207
-
208
- _fundDistributor(1000 ether);
209
- _advanceToRound(1);
210
-
211
- uint256[] memory tokenIds = new uint256[](1);
212
- tokenIds[0] = _tokenId(bob);
213
- IERC20[] memory tokens = new IERC20[](1);
214
- tokens[0] = IERC20(address(rewardToken));
215
-
216
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
217
-
218
- uint256 bobClaimed = distributor.claimedFor(address(votesToken), _tokenId(bob), IERC20(address(rewardToken)));
219
- assertEq(bobClaimed, 300 ether, "Bob gets 30% of pool");
220
-
221
- // 700 tokens remain undistributed in the pool.
222
- uint256 totalVesting = distributor.totalVestingAmountOf(address(votesToken), IERC20(address(rewardToken)));
223
- assertEq(totalVesting, 300 ether, "Only 300 vesting");
224
- uint256 balance = distributor.balanceOf(address(votesToken), IERC20(address(rewardToken)));
225
- assertEq(balance, 1000 ether, "Full balance still held");
226
- }
227
-
228
- function test_twoStakers_proRataByVotingPower() public {
229
- // Alice: 700, Bob: 300. Both delegate to self.
230
- vm.prank(alice);
231
- votesToken.delegate(alice);
232
- vm.prank(bob);
233
- votesToken.delegate(bob);
234
-
235
- _fundDistributor(1000 ether);
236
- _advanceToRound(1);
237
-
238
- uint256[] memory tokenIds = new uint256[](2);
239
- tokenIds[0] = _tokenId(alice);
240
- tokenIds[1] = _tokenId(bob);
241
- IERC20[] memory tokens = new IERC20[](1);
242
- tokens[0] = IERC20(address(rewardToken));
243
-
244
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
245
-
246
- uint256 aliceClaimed =
247
- distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
248
- uint256 bobClaimed = distributor.claimedFor(address(votesToken), _tokenId(bob), IERC20(address(rewardToken)));
249
-
250
- assertEq(aliceClaimed, 700 ether, "Alice gets 70%");
251
- assertEq(bobClaimed, 300 ether, "Bob gets 30%");
252
- }
253
-
254
- function test_processSplitWith_onlyAuthorized() public {
255
- JBSplit memory split = JBSplit({
256
- percent: 1_000_000_000,
257
- projectId: 0,
258
- beneficiary: payable(address(votesToken)),
259
- preferAddToBalance: false,
260
- lockedUntil: 0,
261
- hook: IJBSplitHook(address(distributor))
262
- });
263
-
264
- JBSplitHookContext memory context = JBSplitHookContext({
265
- token: address(rewardToken), amount: 100 ether, decimals: 18, projectId: projectId, groupId: 0, split: split
266
- });
267
-
268
- // Unauthorized caller should revert.
269
- vm.expectRevert(JBTokenDistributor.JBTokenDistributor_Unauthorized.selector);
270
- distributor.processSplitWith(context);
271
-
272
- // Authorized terminal should succeed.
273
- rewardToken.mint(terminal, 100 ether);
274
- vm.startPrank(terminal);
275
- rewardToken.approve(address(distributor), 100 ether);
276
- distributor.processSplitWith(context);
277
- vm.stopPrank();
278
-
279
- assertEq(
280
- distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
281
- 100 ether,
282
- "Balance credited after processSplitWith"
283
- );
284
- }
285
-
286
- function test_releaseForfeitedRewards_alwaysReverts() public {
287
- // _tokenBurned always returns false, so releaseForfeitedRewards should always revert.
288
- vm.prank(alice);
289
- votesToken.delegate(alice);
290
-
291
- _fundDistributor(1000 ether);
292
- _advanceToRound(1);
293
-
294
- uint256[] memory tokenIds = new uint256[](1);
295
- tokenIds[0] = _tokenId(alice);
296
- IERC20[] memory tokens = new IERC20[](1);
297
- tokens[0] = IERC20(address(rewardToken));
298
-
299
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
300
-
301
- // Attempt to release forfeited rewards — should revert with NoAccess.
302
- vm.expectRevert(JBDistributor.JBDistributor_NoAccess.selector);
303
- distributor.releaseForfeitedRewards(address(votesToken), tokenIds, tokens, alice);
304
- }
305
-
306
- function test_multiRoundVesting() public {
307
- vm.prank(alice);
308
- votesToken.delegate(alice);
309
- vm.prank(bob);
310
- votesToken.delegate(bob);
311
-
312
- _fundDistributor(1000 ether);
313
- _advanceToRound(1);
314
-
315
- // Begin vesting in round 1.
316
- uint256[] memory tokenIds = new uint256[](2);
317
- tokenIds[0] = _tokenId(alice);
318
- tokenIds[1] = _tokenId(bob);
319
- IERC20[] memory tokens = new IERC20[](1);
320
- tokens[0] = IERC20(address(rewardToken));
321
-
322
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
323
-
324
- // After 2 of 4 vesting rounds, 50% should be collectable.
325
- _advanceToRound(3);
326
-
327
- uint256 aliceCollectable =
328
- distributor.collectableFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
329
- // Alice claimed 700 ether, 50% vested = 350.
330
- assertEq(aliceCollectable, 350 ether, "Alice should have 50% collectable after 2/4 rounds");
331
-
332
- // Collect partial.
333
- vm.prank(alice);
334
- distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
335
- assertEq(rewardToken.balanceOf(alice), 350 ether, "Alice collected 350");
336
-
337
- // Advance to a new round so a fresh snapshot is taken for the new funds.
338
- _advanceToRound(4);
339
-
340
- // Fund more for the new round.
341
- _fundDistributor(500 ether);
342
-
343
- // Begin vesting round 4's rewards.
344
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
345
-
346
- // Advance past both vesting periods (entry 0 releases at round 5, entry 1 at round 8).
347
- _advanceToRound(1 + VESTING_ROUNDS + VESTING_ROUNDS);
348
-
349
- // Collect all remaining.
350
- vm.prank(alice);
351
- distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
352
-
353
- vm.prank(bob);
354
- distributor.collectVestedRewards(address(votesToken), _singleTokenId(bob), tokens, bob);
355
-
356
- // Alice: 700 (round 1) + 350 (70% of 500 from round 3) = 1050.
357
- assertEq(rewardToken.balanceOf(alice), 1050 ether, "Alice total after multi-round");
358
- // Bob: 300 (round 1) + 150 (30% of 500 from round 3) = 450.
359
- assertEq(rewardToken.balanceOf(bob), 450 ether, "Bob total after multi-round");
360
- }
361
-
362
- function test_cannotCollectOtherStakersRewards() public {
363
- vm.prank(alice);
364
- votesToken.delegate(alice);
365
-
366
- _fundDistributor(1000 ether);
367
- _advanceToRound(1);
368
-
369
- uint256[] memory tokenIds = new uint256[](1);
370
- tokenIds[0] = _tokenId(alice);
371
- IERC20[] memory tokens = new IERC20[](1);
372
- tokens[0] = IERC20(address(rewardToken));
373
-
374
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
375
-
376
- _advanceToRound(1 + VESTING_ROUNDS);
377
-
378
- // Bob tries to collect Alice's rewards — should revert.
379
- vm.prank(bob);
380
- vm.expectRevert(JBDistributor.JBDistributor_NoAccess.selector);
381
- distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, bob);
382
- }
383
-
384
- function test_supportsInterface() public view {
385
- assertTrue(distributor.supportsInterface(type(IJBTokenDistributor).interfaceId), "IJBTokenDistributor");
386
- assertTrue(distributor.supportsInterface(type(IJBSplitHook).interfaceId), "IJBSplitHook");
387
- assertTrue(distributor.supportsInterface(type(IERC165).interfaceId), "IERC165");
388
- }
389
-
390
- function test_partialVesting_linearUnlock() public {
391
- vm.prank(alice);
392
- votesToken.delegate(alice);
393
-
394
- _fundDistributor(1000 ether);
395
- _advanceToRound(1);
396
-
397
- uint256[] memory tokenIds = new uint256[](1);
398
- tokenIds[0] = _tokenId(alice);
399
- IERC20[] memory tokens = new IERC20[](1);
400
- tokens[0] = IERC20(address(rewardToken));
401
-
402
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
403
-
404
- // After 1 of 4 rounds, 25% should be collectable.
405
- _advanceToRound(2);
406
- uint256 collectable =
407
- distributor.collectableFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
408
- // Alice has 700 (70% of 1000). 25% of 700 = 175.
409
- assertEq(collectable, 175 ether, "25% vested after 1/4 rounds");
410
-
411
- // After 3 of 4 rounds, 75% should be collectable.
412
- _advanceToRound(4);
413
- collectable = distributor.collectableFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
414
- assertEq(collectable, 525 ether, "75% vested after 3/4 rounds");
415
- }
416
-
417
- function test_autoVest_collectWithoutBeginVesting() public {
418
- // Alice delegates to self.
419
- vm.prank(alice);
420
- votesToken.delegate(alice);
421
-
422
- // Fund the distributor.
423
- _fundDistributor(1000 ether);
424
-
425
- // Advance to round 1.
426
- _advanceToRound(1);
427
-
428
- // Advance past full vesting WITHOUT calling beginVesting first.
429
- _advanceToRound(1 + VESTING_ROUNDS);
430
-
431
- // Alice calls collectVestedRewards directly — auto-vest should kick in.
432
- IERC20[] memory tokens = new IERC20[](1);
433
- tokens[0] = IERC20(address(rewardToken));
434
-
435
- vm.prank(alice);
436
- distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
437
-
438
- // Alice should have auto-vested for the current round (1 + VESTING_ROUNDS).
439
- // Her claimed amount depends on what round the auto-vest captures.
440
- // The auto-vest happens at the current round, so it creates a new vesting entry for that round.
441
- // Since it's a new vesting entry, it won't be fully vested yet (just started).
442
- // But previous rounds' funds accumulated and the collect at the current round auto-vests them.
443
- uint256 aliceClaimed =
444
- distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
445
- assertGt(aliceClaimed, 0, "Alice should have auto-vested something");
446
- }
447
-
448
- function test_poke_recordsSnapshotBlock() public {
449
- _advanceToRound(1);
450
-
451
- uint256 expectedBlock = block.number - 1;
452
- distributor.poke();
453
-
454
- assertEq(distributor.roundSnapshotBlock(1), expectedBlock, "Snapshot block should be block.number - 1");
455
- }
456
-
457
- function test_poke_idempotent() public {
458
- _advanceToRound(1);
459
-
460
- distributor.poke();
461
- uint256 firstSnapshot = distributor.roundSnapshotBlock(1);
462
-
463
- // Advance block but stay in same round.
464
- vm.roll(block.number + 10);
465
-
466
- distributor.poke();
467
- uint256 secondSnapshot = distributor.roundSnapshotBlock(1);
468
-
469
- assertEq(firstSnapshot, secondSnapshot, "Poke should be idempotent within a round");
470
- }
471
-
472
- function test_skipAlreadyVested_noRevert() public {
473
- vm.prank(alice);
474
- votesToken.delegate(alice);
475
-
476
- _fundDistributor(1000 ether);
477
- _advanceToRound(1);
478
-
479
- uint256[] memory tokenIds = new uint256[](1);
480
- tokenIds[0] = _tokenId(alice);
481
- IERC20[] memory tokens = new IERC20[](1);
482
- tokens[0] = IERC20(address(rewardToken));
483
-
484
- // First vest.
485
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
486
-
487
- // Second vest in same round should NOT revert (skips silently).
488
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
489
-
490
- // Only one vesting entry should exist.
491
- uint256 claimed = distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
492
- assertEq(claimed, 700 ether, "Should have exactly one vesting entry worth 700");
493
- }
494
-
495
- //*********************************************************************//
496
- // ----------------------------- internal ---------------------------- //
497
- //*********************************************************************//
498
-
499
- function _singleTokenId(address staker) internal pure returns (uint256[] memory tokenIds) {
500
- tokenIds = new uint256[](1);
501
- tokenIds[0] = _tokenId(staker);
502
- }
503
- }