@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,429 +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 audit fix tests.
23
- contract AuditFixMockDirectory {
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 AuditFixMockRewardToken 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.
54
- contract AuditFixMockVotesToken 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
- /// @notice Tests for controller-prepaid split funds, zero-stake vesting, and empty claim array handling in
67
- /// JBTokenDistributor / JBDistributor.
68
- contract AuditFixesTest is Test {
69
- AuditFixMockDirectory directory;
70
- AuditFixMockRewardToken rewardToken;
71
- AuditFixMockVotesToken votesToken;
72
- JBTokenDistributor distributor;
73
-
74
- address alice = makeAddr("alice");
75
- address bob = makeAddr("bob");
76
- address terminal = makeAddr("terminal");
77
- address controller = makeAddr("controller");
78
- uint256 projectId = 1;
79
-
80
- uint256 constant ROUND_DURATION = 100;
81
- uint256 constant VESTING_ROUNDS = 4;
82
-
83
- function setUp() public {
84
- directory = new AuditFixMockDirectory();
85
- rewardToken = new AuditFixMockRewardToken();
86
- votesToken = new AuditFixMockVotesToken();
87
-
88
- directory.setTerminal(projectId, terminal, true);
89
- directory.setController(projectId, controller);
90
-
91
- distributor = new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
92
-
93
- // Mint staking tokens and delegate.
94
- votesToken.mint(alice, 700 ether);
95
- votesToken.mint(bob, 300 ether);
96
- }
97
-
98
- //*********************************************************************//
99
- // ----------------------------- helpers ----------------------------- //
100
- //*********************************************************************//
101
-
102
- /// @notice Encode a staker address as a tokenId.
103
- function _tokenId(address staker) internal pure returns (uint256) {
104
- return uint256(uint160(staker));
105
- }
106
-
107
- /// @notice Advance to 1 second after the start of the given round.
108
- function _advanceToRound(uint256 round) internal {
109
- uint256 targetTimestamp = distributor.roundStartTimestamp(round) + 1;
110
- if (block.timestamp < targetTimestamp) {
111
- vm.warp(targetTimestamp);
112
- }
113
- vm.roll(block.number + 1);
114
- }
115
-
116
- /// @notice Build a JBSplitHookContext for the given token and amount.
117
- function _buildContext(address token, uint256 amount) internal view returns (JBSplitHookContext memory) {
118
- JBSplit memory split = JBSplit({
119
- percent: 1_000_000_000,
120
- projectId: 0,
121
- beneficiary: payable(address(votesToken)),
122
- preferAddToBalance: false,
123
- lockedUntil: 0,
124
- hook: IJBSplitHook(address(distributor))
125
- });
126
-
127
- return JBSplitHookContext({
128
- token: token, amount: amount, decimals: 18, projectId: projectId, groupId: 0, split: split
129
- });
130
- }
131
-
132
- //*********************************************************************//
133
- // ------- Controller-Prepaid ERC20 Split Funds ---------------------- //
134
- //*********************************************************************//
135
-
136
- /// @notice Terminal path: ERC20 credited via allowance + transferFrom.
137
- function test_controllerPrepaidSplits_processSplitWith_terminalPath_creditsViaAllowance() public {
138
- uint256 amount = 500 ether;
139
- JBSplitHookContext memory context = _buildContext(address(rewardToken), amount);
140
-
141
- // Terminal mints tokens and approves the distributor before calling.
142
- rewardToken.mint(terminal, amount);
143
- vm.startPrank(terminal);
144
- rewardToken.approve(address(distributor), amount);
145
- distributor.processSplitWith(context);
146
- vm.stopPrank();
147
-
148
- // Balance should be credited.
149
- assertEq(
150
- distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
151
- amount,
152
- "Terminal path: balance should be credited via transferFrom"
153
- );
154
- // Tokens should be held by the distributor.
155
- assertEq(rewardToken.balanceOf(address(distributor)), amount, "Tokens should be in the distributor");
156
- }
157
-
158
- /// @notice Controller-prepaid path: ERC20 credited when tokens are sent before processSplitWith.
159
- function test_controllerPrepaidSplits_processSplitWith_controllerPrepaidPath_creditsDirectly() public {
160
- uint256 amount = 500 ether;
161
- JBSplitHookContext memory context = _buildContext(address(rewardToken), amount);
162
-
163
- // Controller transfers tokens directly to the distributor (no approval).
164
- rewardToken.mint(controller, amount);
165
- vm.prank(controller);
166
- rewardToken.transfer(address(distributor), amount);
167
-
168
- // Controller calls processSplitWith WITHOUT granting an allowance.
169
- vm.prank(controller);
170
- distributor.processSplitWith(context);
171
-
172
- // Balance should be credited via the controller-prepaid path.
173
- assertEq(
174
- distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
175
- amount,
176
- "Controller-prepaid path: balance should be credited directly"
177
- );
178
- }
179
-
180
- /// @notice Verifies that the controller-prepaid path allows end-to-end vesting and collection.
181
- function test_controllerPrepaidSplits_controllerPrepaidPath_endToEndVestAndCollect() public {
182
- uint256 amount = 1000 ether;
183
-
184
- // Alice delegates to self so she has voting power.
185
- vm.prank(alice);
186
- votesToken.delegate(alice);
187
- vm.prank(bob);
188
- votesToken.delegate(bob);
189
-
190
- // Controller sends tokens directly and calls processSplitWith.
191
- JBSplitHookContext memory context = _buildContext(address(rewardToken), amount);
192
- rewardToken.mint(controller, amount);
193
- vm.prank(controller);
194
- rewardToken.transfer(address(distributor), amount);
195
- vm.prank(controller);
196
- distributor.processSplitWith(context);
197
-
198
- // Advance to round 1 and begin vesting.
199
- _advanceToRound(1);
200
-
201
- uint256[] memory tokenIds = new uint256[](2);
202
- tokenIds[0] = _tokenId(alice);
203
- tokenIds[1] = _tokenId(bob);
204
- IERC20[] memory tokens = new IERC20[](1);
205
- tokens[0] = IERC20(address(rewardToken));
206
-
207
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
208
-
209
- // Advance past full vesting.
210
- _advanceToRound(1 + VESTING_ROUNDS);
211
-
212
- // Alice collects her 70%.
213
- uint256[] memory aliceIds = new uint256[](1);
214
- aliceIds[0] = _tokenId(alice);
215
- vm.prank(alice);
216
- distributor.collectVestedRewards(address(votesToken), aliceIds, tokens, alice);
217
- assertEq(rewardToken.balanceOf(alice), 700 ether, "Alice should collect 70% of controller-prepaid funds");
218
-
219
- // Bob collects his 30%.
220
- uint256[] memory bobIds = new uint256[](1);
221
- bobIds[0] = _tokenId(bob);
222
- vm.prank(bob);
223
- distributor.collectVestedRewards(address(votesToken), bobIds, tokens, bob);
224
- assertEq(rewardToken.balanceOf(bob), 300 ether, "Bob should collect 30% of controller-prepaid funds");
225
- }
226
-
227
- //*********************************************************************//
228
- // ------- Zero totalStake Causes beginVesting Revert ---------------- //
229
- //*********************************************************************//
230
-
231
- /// @notice beginVesting with zero totalStake should silently return (no revert).
232
- function test_zeroTotalStake_beginVesting_zeroTotalStake_doesNotRevert() public {
233
- // Nobody delegates, so getPastTotalSupply will return 0. But we use the mock votes token
234
- // which returns totalSupply via getPastTotalSupply. We need to ensure totalSupply is 0.
235
- // Since votesToken was minted in setUp but nobody delegated, getPastTotalSupply returns
236
- // the total supply of delegated votes. With no delegation, this is 0 for ERC20Votes.
237
-
238
- // Fund the distributor.
239
- rewardToken.mint(address(this), 1000 ether);
240
- rewardToken.approve(address(distributor), 1000 ether);
241
- distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
242
-
243
- // Advance to round 1.
244
- _advanceToRound(1);
245
-
246
- uint256[] memory tokenIds = new uint256[](1);
247
- tokenIds[0] = _tokenId(alice);
248
- IERC20[] memory tokens = new IERC20[](1);
249
- tokens[0] = IERC20(address(rewardToken));
250
-
251
- // Should NOT revert even though totalStake == 0. Funds carry over.
252
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
253
-
254
- // No vesting should have occurred.
255
- assertEq(
256
- distributor.totalVestingAmountOf(address(votesToken), IERC20(address(rewardToken))),
257
- 0,
258
- "Nothing should be vesting when totalStake is zero"
259
- );
260
-
261
- // Balance should still be intact for future rounds.
262
- assertEq(
263
- distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
264
- 1000 ether,
265
- "Funds should carry over when totalStake is zero"
266
- );
267
- }
268
-
269
- /// @notice After zero-stake round passes, a round with stakers should distribute normally.
270
- function test_zeroTotalStake_zeroTotalStake_fundsCarryOverToNextRound() public {
271
- // Fund the distributor.
272
- rewardToken.mint(address(this), 1000 ether);
273
- rewardToken.approve(address(distributor), 1000 ether);
274
- distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
275
-
276
- // Round 1: no one has delegated — zero total stake.
277
- _advanceToRound(1);
278
-
279
- uint256[] memory tokenIds = new uint256[](1);
280
- tokenIds[0] = _tokenId(alice);
281
- IERC20[] memory tokens = new IERC20[](1);
282
- tokens[0] = IERC20(address(rewardToken));
283
-
284
- // beginVesting with zero stake — silently returns. H-25: eagerly locks round 2 snapshot.
285
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
286
-
287
- // Alice delegates (after round 2 snapshot is already locked by H-25 eager fix).
288
- vm.prank(alice);
289
- votesToken.delegate(alice);
290
-
291
- // Round 2: Alice's delegation not captured (round 2 snapshot precedes her delegation).
292
- // Zero stake again — silently returns. H-25: eagerly locks round 3 snapshot (AFTER delegation).
293
- _advanceToRound(2);
294
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
295
-
296
- // Round 3: Alice IS eligible (round 3 snapshot was set after her delegation).
297
- // Funds from rounds 1 and 2 carry over since no vesting was recorded.
298
- _advanceToRound(3);
299
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
300
-
301
- // Alice should have claimed her share of the full 1000 ether (700/1000 total supply).
302
- uint256 aliceClaimed =
303
- distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
304
- assertEq(aliceClaimed, 700 ether, "Alice should claim 70% of carried-over funds");
305
- }
306
-
307
- //*********************************************************************//
308
- // ------- Empty Claim Arrays Freeze Round Snapshot ------------------ //
309
- //*********************************************************************//
310
-
311
- /// @notice beginVesting with empty tokenIds should revert.
312
- function test_emptyClaimArrays_beginVesting_emptyTokenIds_reverts() public {
313
- uint256[] memory tokenIds = new uint256[](0);
314
- IERC20[] memory tokens = new IERC20[](1);
315
- tokens[0] = IERC20(address(rewardToken));
316
-
317
- vm.expectRevert(JBDistributor.JBDistributor_EmptyTokenIds.selector);
318
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
319
- }
320
-
321
- /// @notice collectVestedRewards with empty tokenIds should revert.
322
- function test_emptyClaimArrays_collectVestedRewards_emptyTokenIds_reverts() public {
323
- uint256[] memory tokenIds = new uint256[](0);
324
- IERC20[] memory tokens = new IERC20[](1);
325
- tokens[0] = IERC20(address(rewardToken));
326
-
327
- vm.expectRevert(JBDistributor.JBDistributor_EmptyTokenIds.selector);
328
- distributor.collectVestedRewards(address(votesToken), tokenIds, tokens, alice);
329
- }
330
-
331
- /// @notice Empty tokenIds should not cause a snapshot to be recorded.
332
- function test_emptyClaimArrays_emptyTokenIds_doesNotFreezeSnapshot() public {
333
- _advanceToRound(1);
334
-
335
- uint256[] memory tokenIds = new uint256[](0);
336
- IERC20[] memory tokens = new IERC20[](1);
337
- tokens[0] = IERC20(address(rewardToken));
338
-
339
- // beginVesting reverts before _ensureSnapshotBlock is called.
340
- vm.expectRevert(JBDistributor.JBDistributor_EmptyTokenIds.selector);
341
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
342
-
343
- // No snapshot should have been recorded.
344
- assertEq(distributor.roundSnapshotBlock(1), 0, "Snapshot should not be recorded after empty-array revert");
345
- }
346
-
347
- //*********************************************************************//
348
- // ------- H-25: Eager Snapshot --------------------------------------- //
349
- //*********************************************************************//
350
-
351
- /// @notice Calling poke() in round N should eagerly set the snapshot for round N+1.
352
- function test_h25_pokeEagerlySetsFutureSnapshot() public {
353
- // Advance to round 1.
354
- _advanceToRound(1);
355
-
356
- // poke() in round 1 should set snapshot for round 1 AND eagerly set round 2.
357
- distributor.poke();
358
-
359
- assertGt(distributor.roundSnapshotBlock(1), 0, "Round 1 snapshot should be set");
360
- assertGt(distributor.roundSnapshotBlock(2), 0, "Round 2 snapshot should be eagerly set by poke()");
361
- }
362
-
363
- /// @notice beginVesting locks the next round's snapshot. A later call in round N+1
364
- /// should use that same snapshot, not overwrite it with a fresher block.
365
- function test_h25_lateJoinerCannotManipulateSnapshot() public {
366
- // Alice delegates to self so she has voting power.
367
- vm.prank(alice);
368
- votesToken.delegate(alice);
369
-
370
- // Fund the distributor.
371
- rewardToken.mint(address(this), 1000 ether);
372
- rewardToken.approve(address(distributor), 1000 ether);
373
- distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
374
-
375
- // Advance to round 1 and call beginVesting — this locks round 1 AND eagerly locks round 2 snapshot.
376
- _advanceToRound(1);
377
-
378
- uint256[] memory tokenIds = new uint256[](1);
379
- tokenIds[0] = _tokenId(alice);
380
- IERC20[] memory tokens = new IERC20[](1);
381
- tokens[0] = IERC20(address(rewardToken));
382
-
383
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
384
-
385
- // Record the eagerly-set round 2 snapshot.
386
- uint256 eagerSnapshot = distributor.roundSnapshotBlock(2);
387
- assertGt(eagerSnapshot, 0, "Round 2 snapshot should be eagerly set");
388
-
389
- // Advance many blocks (simulating a late joiner trying to push the snapshot forward).
390
- vm.roll(block.number + 100);
391
-
392
- // Advance to round 2.
393
- _advanceToRound(2);
394
-
395
- // Fund more so beginVesting has something to distribute.
396
- rewardToken.mint(address(this), 500 ether);
397
- rewardToken.approve(address(distributor), 500 ether);
398
- distributor.fund(address(votesToken), IERC20(address(rewardToken)), 500 ether);
399
-
400
- // beginVesting in round 2 should NOT overwrite the eagerly-set snapshot.
401
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
402
-
403
- assertEq(
404
- distributor.roundSnapshotBlock(2),
405
- eagerSnapshot,
406
- "Late joiner should not overwrite eagerly-set round 2 snapshot"
407
- );
408
- }
409
-
410
- /// @notice Calling poke() twice in the same round should not change the next round's snapshot.
411
- function test_h25_eagerSnapshotIdempotent() public {
412
- // Advance to round 1.
413
- _advanceToRound(1);
414
-
415
- // First poke sets round 1 and eagerly sets round 2 snapshot.
416
- distributor.poke();
417
- uint256 firstEagerSnapshot = distributor.roundSnapshotBlock(2);
418
- assertGt(firstEagerSnapshot, 0, "Round 2 snapshot should be set after first poke");
419
-
420
- // Advance some blocks within round 1.
421
- vm.roll(block.number + 50);
422
-
423
- // Second poke in the same round should NOT change the round 2 snapshot.
424
- distributor.poke();
425
- uint256 secondEagerSnapshot = distributor.roundSnapshotBlock(2);
426
-
427
- assertEq(firstEagerSnapshot, secondEagerSnapshot, "Eager snapshot should be idempotent across multiple pokes");
428
- }
429
- }