@bananapus/distributor-v6 0.0.3

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,424 @@
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 blocks 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 block after the start of the given round.
106
+ function _advanceToRound(uint256 round) internal {
107
+ uint256 targetBlock = distributor.roundStartBlock(round) + 1;
108
+ if (block.number < targetBlock) {
109
+ vm.roll(targetBlock);
110
+ }
111
+ }
112
+
113
+ /// @notice Fund the distributor via the direct `fund` method.
114
+ function _fundDistributor(uint256 amount) internal {
115
+ rewardToken.mint(address(this), amount);
116
+ rewardToken.approve(address(distributor), amount);
117
+ distributor.fund(address(votesToken), IERC20(address(rewardToken)), amount);
118
+ }
119
+
120
+ //*********************************************************************//
121
+ // ----------------------------- tests ------------------------------ //
122
+ //*********************************************************************//
123
+
124
+ function test_happyPath_fundVestCollect() public {
125
+ // Alice delegates to self.
126
+ vm.prank(alice);
127
+ votesToken.delegate(alice);
128
+
129
+ // Bob delegates to self.
130
+ vm.prank(bob);
131
+ votesToken.delegate(bob);
132
+
133
+ // Fund the distributor with 1000 reward tokens.
134
+ _fundDistributor(1000 ether);
135
+
136
+ // Advance to round 1 (so round 0's start block is in the past for getPastVotes).
137
+ _advanceToRound(1);
138
+
139
+ // Begin vesting for alice and bob.
140
+ uint256[] memory tokenIds = new uint256[](2);
141
+ tokenIds[0] = _tokenId(alice);
142
+ tokenIds[1] = _tokenId(bob);
143
+ IERC20[] memory tokens = new IERC20[](1);
144
+ tokens[0] = IERC20(address(rewardToken));
145
+
146
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
147
+
148
+ // Alice should have 700/1000 = 70% = 700 tokens claimed.
149
+ uint256 aliceClaimed =
150
+ distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
151
+ assertEq(aliceClaimed, 700 ether, "Alice should have 700 claimed");
152
+
153
+ // Bob should have 300/1000 = 30% = 300 tokens claimed.
154
+ uint256 bobClaimed = distributor.claimedFor(address(votesToken), _tokenId(bob), IERC20(address(rewardToken)));
155
+ assertEq(bobClaimed, 300 ether, "Bob should have 300 claimed");
156
+
157
+ // Advance past full vesting (4 rounds).
158
+ _advanceToRound(1 + VESTING_ROUNDS);
159
+
160
+ // Alice collects.
161
+ vm.prank(alice);
162
+ distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
163
+ assertEq(rewardToken.balanceOf(alice), 700 ether, "Alice should receive 700 tokens");
164
+
165
+ // Bob collects.
166
+ vm.prank(bob);
167
+ distributor.collectVestedRewards(address(votesToken), _singleTokenId(bob), tokens, bob);
168
+ assertEq(rewardToken.balanceOf(bob), 300 ether, "Bob should receive 300 tokens");
169
+ }
170
+
171
+ function test_noDelegation_zeroAllocation() public {
172
+ // Alice does NOT delegate — should get 0 voting power.
173
+ // Bob delegates to self.
174
+ vm.prank(bob);
175
+ votesToken.delegate(bob);
176
+
177
+ _fundDistributor(1000 ether);
178
+ _advanceToRound(1);
179
+
180
+ uint256[] memory tokenIds = new uint256[](2);
181
+ tokenIds[0] = _tokenId(alice);
182
+ tokenIds[1] = _tokenId(bob);
183
+ IERC20[] memory tokens = new IERC20[](1);
184
+ tokens[0] = IERC20(address(rewardToken));
185
+
186
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
187
+
188
+ // Alice has 0 claimed (no delegation = 0 votes).
189
+ uint256 aliceClaimed =
190
+ distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
191
+ assertEq(aliceClaimed, 0, "Alice should have 0 claimed without delegation");
192
+
193
+ // Bob gets all rewards because total supply includes all tokens but alice has 0 votes.
194
+ // getPastVotes(bob) = 300, getPastTotalSupply = 1000 (includes undelegated).
195
+ // So bob gets 300/1000 * 1000 = 300.
196
+ uint256 bobClaimed = distributor.claimedFor(address(votesToken), _tokenId(bob), IERC20(address(rewardToken)));
197
+ assertEq(bobClaimed, 300 ether, "Bob should have 300 claimed (his share of total supply)");
198
+ }
199
+
200
+ function test_nonDelegatedSupply_staysInPool() public {
201
+ // Only bob delegates. Total supply = 1000, bob votes = 300.
202
+ // Bob gets 300/1000 = 30%. The other 70% stays in the pool.
203
+ vm.prank(bob);
204
+ votesToken.delegate(bob);
205
+
206
+ _fundDistributor(1000 ether);
207
+ _advanceToRound(1);
208
+
209
+ uint256[] memory tokenIds = new uint256[](1);
210
+ tokenIds[0] = _tokenId(bob);
211
+ IERC20[] memory tokens = new IERC20[](1);
212
+ tokens[0] = IERC20(address(rewardToken));
213
+
214
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
215
+
216
+ uint256 bobClaimed = distributor.claimedFor(address(votesToken), _tokenId(bob), IERC20(address(rewardToken)));
217
+ assertEq(bobClaimed, 300 ether, "Bob gets 30% of pool");
218
+
219
+ // 700 tokens remain undistributed in the pool.
220
+ uint256 totalVesting = distributor.totalVestingAmountOf(address(votesToken), IERC20(address(rewardToken)));
221
+ assertEq(totalVesting, 300 ether, "Only 300 vesting");
222
+ uint256 balance = distributor.balanceOf(address(votesToken), IERC20(address(rewardToken)));
223
+ assertEq(balance, 1000 ether, "Full balance still held");
224
+ }
225
+
226
+ function test_twoStakers_proRataByVotingPower() public {
227
+ // Alice: 700, Bob: 300. Both delegate to self.
228
+ vm.prank(alice);
229
+ votesToken.delegate(alice);
230
+ vm.prank(bob);
231
+ votesToken.delegate(bob);
232
+
233
+ _fundDistributor(1000 ether);
234
+ _advanceToRound(1);
235
+
236
+ uint256[] memory tokenIds = new uint256[](2);
237
+ tokenIds[0] = _tokenId(alice);
238
+ tokenIds[1] = _tokenId(bob);
239
+ IERC20[] memory tokens = new IERC20[](1);
240
+ tokens[0] = IERC20(address(rewardToken));
241
+
242
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
243
+
244
+ uint256 aliceClaimed =
245
+ distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
246
+ uint256 bobClaimed = distributor.claimedFor(address(votesToken), _tokenId(bob), IERC20(address(rewardToken)));
247
+
248
+ assertEq(aliceClaimed, 700 ether, "Alice gets 70%");
249
+ assertEq(bobClaimed, 300 ether, "Bob gets 30%");
250
+ }
251
+
252
+ function test_processSplitWith_onlyAuthorized() public {
253
+ JBSplit memory split = JBSplit({
254
+ percent: 1_000_000_000,
255
+ projectId: 0,
256
+ beneficiary: payable(address(votesToken)),
257
+ preferAddToBalance: false,
258
+ lockedUntil: 0,
259
+ hook: IJBSplitHook(address(distributor))
260
+ });
261
+
262
+ JBSplitHookContext memory context = JBSplitHookContext({
263
+ token: address(rewardToken), amount: 100 ether, decimals: 18, projectId: projectId, groupId: 0, split: split
264
+ });
265
+
266
+ // Unauthorized caller should revert.
267
+ vm.expectRevert(JBTokenDistributor.JBTokenDistributor_Unauthorized.selector);
268
+ distributor.processSplitWith(context);
269
+
270
+ // Authorized terminal should succeed.
271
+ rewardToken.mint(terminal, 100 ether);
272
+ vm.startPrank(terminal);
273
+ rewardToken.approve(address(distributor), 100 ether);
274
+ distributor.processSplitWith(context);
275
+ vm.stopPrank();
276
+
277
+ assertEq(
278
+ distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
279
+ 100 ether,
280
+ "Balance credited after processSplitWith"
281
+ );
282
+ }
283
+
284
+ function test_releaseForfeitedRewards_alwaysReverts() public {
285
+ // _tokenBurned always returns false, so releaseForfeitedRewards should always revert.
286
+ vm.prank(alice);
287
+ votesToken.delegate(alice);
288
+
289
+ _fundDistributor(1000 ether);
290
+ _advanceToRound(1);
291
+
292
+ uint256[] memory tokenIds = new uint256[](1);
293
+ tokenIds[0] = _tokenId(alice);
294
+ IERC20[] memory tokens = new IERC20[](1);
295
+ tokens[0] = IERC20(address(rewardToken));
296
+
297
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
298
+
299
+ // Attempt to release forfeited rewards — should revert with NoAccess.
300
+ vm.expectRevert(JBDistributor.JBDistributor_NoAccess.selector);
301
+ distributor.releaseForfeitedRewards(address(votesToken), tokenIds, tokens, alice);
302
+ }
303
+
304
+ function test_multiRoundVesting() public {
305
+ vm.prank(alice);
306
+ votesToken.delegate(alice);
307
+ vm.prank(bob);
308
+ votesToken.delegate(bob);
309
+
310
+ _fundDistributor(1000 ether);
311
+ _advanceToRound(1);
312
+
313
+ // Begin vesting in round 1.
314
+ uint256[] memory tokenIds = new uint256[](2);
315
+ tokenIds[0] = _tokenId(alice);
316
+ tokenIds[1] = _tokenId(bob);
317
+ IERC20[] memory tokens = new IERC20[](1);
318
+ tokens[0] = IERC20(address(rewardToken));
319
+
320
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
321
+
322
+ // After 2 of 4 vesting rounds, 50% should be collectable.
323
+ _advanceToRound(3);
324
+
325
+ uint256 aliceCollectable =
326
+ distributor.collectableFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
327
+ // Alice claimed 700 ether, 50% vested = 350.
328
+ assertEq(aliceCollectable, 350 ether, "Alice should have 50% collectable after 2/4 rounds");
329
+
330
+ // Collect partial.
331
+ vm.prank(alice);
332
+ distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
333
+ assertEq(rewardToken.balanceOf(alice), 350 ether, "Alice collected 350");
334
+
335
+ // Fund more for round 2.
336
+ _fundDistributor(500 ether);
337
+
338
+ // Advance to round 2 to vest new funds.
339
+ // Already in round 3, we can begin vesting for round 3 (which looks at round 3's start block).
340
+ // But we need to be past round 3's start block. We're at round 3 + 1 block already.
341
+
342
+ // Begin vesting round 3's rewards.
343
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
344
+
345
+ // Advance past both vesting periods.
346
+ _advanceToRound(1 + VESTING_ROUNDS + VESTING_ROUNDS);
347
+
348
+ // Collect all remaining.
349
+ vm.prank(alice);
350
+ distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
351
+
352
+ vm.prank(bob);
353
+ distributor.collectVestedRewards(address(votesToken), _singleTokenId(bob), tokens, bob);
354
+
355
+ // Alice: 700 (round 1) + 350 (70% of 500 from round 3) = 1050.
356
+ assertEq(rewardToken.balanceOf(alice), 1050 ether, "Alice total after multi-round");
357
+ // Bob: 300 (round 1) + 150 (30% of 500 from round 3) = 450.
358
+ assertEq(rewardToken.balanceOf(bob), 450 ether, "Bob total after multi-round");
359
+ }
360
+
361
+ function test_cannotCollectOtherStakersRewards() public {
362
+ vm.prank(alice);
363
+ votesToken.delegate(alice);
364
+
365
+ _fundDistributor(1000 ether);
366
+ _advanceToRound(1);
367
+
368
+ uint256[] memory tokenIds = new uint256[](1);
369
+ tokenIds[0] = _tokenId(alice);
370
+ IERC20[] memory tokens = new IERC20[](1);
371
+ tokens[0] = IERC20(address(rewardToken));
372
+
373
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
374
+
375
+ _advanceToRound(1 + VESTING_ROUNDS);
376
+
377
+ // Bob tries to collect Alice's rewards — should revert.
378
+ vm.prank(bob);
379
+ vm.expectRevert(JBDistributor.JBDistributor_NoAccess.selector);
380
+ distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, bob);
381
+ }
382
+
383
+ function test_supportsInterface() public view {
384
+ assertTrue(distributor.supportsInterface(type(IJBTokenDistributor).interfaceId), "IJBTokenDistributor");
385
+ assertTrue(distributor.supportsInterface(type(IJBSplitHook).interfaceId), "IJBSplitHook");
386
+ assertTrue(distributor.supportsInterface(type(IERC165).interfaceId), "IERC165");
387
+ }
388
+
389
+ function test_partialVesting_linearUnlock() public {
390
+ vm.prank(alice);
391
+ votesToken.delegate(alice);
392
+
393
+ _fundDistributor(1000 ether);
394
+ _advanceToRound(1);
395
+
396
+ uint256[] memory tokenIds = new uint256[](1);
397
+ tokenIds[0] = _tokenId(alice);
398
+ IERC20[] memory tokens = new IERC20[](1);
399
+ tokens[0] = IERC20(address(rewardToken));
400
+
401
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
402
+
403
+ // After 1 of 4 rounds, 25% should be collectable.
404
+ _advanceToRound(2);
405
+ uint256 collectable =
406
+ distributor.collectableFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
407
+ // Alice has 700 (70% of 1000). 25% of 700 = 175.
408
+ assertEq(collectable, 175 ether, "25% vested after 1/4 rounds");
409
+
410
+ // After 3 of 4 rounds, 75% should be collectable.
411
+ _advanceToRound(4);
412
+ collectable = distributor.collectableFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
413
+ assertEq(collectable, 525 ether, "75% vested after 3/4 rounds");
414
+ }
415
+
416
+ //*********************************************************************//
417
+ // ----------------------------- internal ---------------------------- //
418
+ //*********************************************************************//
419
+
420
+ function _singleTokenId(address staker) internal pure returns (uint256[] memory tokenIds) {
421
+ tokenIds = new uint256[](1);
422
+ tokenIds[0] = _tokenId(staker);
423
+ }
424
+ }