@bananapus/distributor-v6 0.0.6 → 0.0.8

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.
package/SKILLS.md DELETED
@@ -1,36 +0,0 @@
1
- # Juicebox Distributor
2
-
3
- ## Use This File For
4
-
5
- - Use this file when the task involves round-based vesting, split-hook distribution, or snapshot-based payout allocation.
6
- - Start here, then decide whether the issue is in shared vesting logic, `IVotes`-based stake measurement, or 721-based stake measurement.
7
-
8
- ## Read This Next
9
-
10
- | If you need... | Open this next |
11
- |---|---|
12
- | Repo overview and architecture | [`README.md`](./README.md), [`ARCHITECTURE.md`](./ARCHITECTURE.md) |
13
- | Shared vesting engine | [`src/JBDistributor.sol`](./src/JBDistributor.sol), [`src/interfaces/IJBDistributor.sol`](./src/interfaces/IJBDistributor.sol) |
14
- | Token distributor behavior | [`src/JBTokenDistributor.sol`](./src/JBTokenDistributor.sol) |
15
- | 721 distributor behavior | [`src/JB721Distributor.sol`](./src/JB721Distributor.sol) |
16
- | Types and structs | [`src/structs/`](./src/structs/) |
17
- | Main tests | [`test/JBTokenDistributor.t.sol`](./test/JBTokenDistributor.t.sol), [`test/JB721Distributor.t.sol`](./test/JB721Distributor.t.sol), [`test/invariant/JB721DistributorInvariant.t.sol`](./test/invariant/JB721DistributorInvariant.t.sol) |
18
-
19
- ## Repo Map
20
-
21
- | Area | Where to look |
22
- |---|---|
23
- | Main contracts | [`src/`](./src/) |
24
- | Structs and interfaces | [`src/structs/`](./src/structs/), [`src/interfaces/`](./src/interfaces/) |
25
- | Tests | [`test/`](./test/) |
26
-
27
- ## Purpose
28
-
29
- Shared vesting and distribution engine for ERC-20 and 721-based payout flows.
30
-
31
- ## Working Rules
32
-
33
- - Start in [`src/JBDistributor.sol`](./src/JBDistributor.sol) for shared round logic.
34
- - Treat snapshot timing as part of correctness.
35
- - `JBTokenDistributor` and `JB721Distributor` share a vesting engine but not the same ownership model.
36
- - Verify the distributor actually holds the asset it is meant to vest before reasoning about payout correctness.
package/USER_JOURNEYS.md DELETED
@@ -1,122 +0,0 @@
1
- # User Journeys
2
-
3
- ## Repo Purpose
4
-
5
- This repo distributes already-owned assets over time. It snapshots stake, starts vesting rounds, and lets eligible recipients collect what has unlocked.
6
-
7
- ## Primary Actors
8
-
9
- - teams funding a distributor from a split or post-mint allocation
10
- - token holders or NFT holders collecting vested rewards
11
- - operators configuring round timing and deployment shape
12
- - auditors reviewing snapshot timing and stake-accounting correctness
13
-
14
- ## Key Surfaces
15
-
16
- - `JBDistributor`: shared round and vesting engine
17
- - `JBTokenDistributor`: ERC-20 distributor using `IVotes`
18
- - `JB721Distributor`: NFT distributor using tier voting units
19
-
20
- ## Journey 1: Fund A Distributor
21
-
22
- **Actor:** project or payout flow.
23
-
24
- **Intent:** move owned assets into a distributor that will vest them over time.
25
-
26
- **Preconditions**
27
- - the correct asset and distributor type are chosen
28
- - the distributor actually receives the inventory it is expected to vest
29
-
30
- **Main Flow**
31
- 1. Fund the distributor directly or through a payout split.
32
- 2. Confirm the tracked balance matches what the distributor received.
33
- 3. Use the distributor as the vesting surface, not as the source of entitlement logic.
34
-
35
- **Failure Modes**
36
- - wrong asset funded
37
- - underfunded distributor
38
- - caller assumes funding alone starts vesting
39
-
40
- **Postconditions**
41
- - the distributor holds the asset inventory for future rounds
42
-
43
- ## Journey 2: Start A Vesting Round
44
-
45
- **Actor:** any caller.
46
-
47
- **Intent:** snapshot the current round and begin vesting.
48
-
49
- **Preconditions**
50
- - the round timing and parameters are valid
51
- - the stake source is usable and non-zero
52
-
53
- **Main Flow**
54
- 1. Call `beginVesting`.
55
- 2. The distributor snapshots the relevant balance and stake source.
56
- 3. Vesting entries become claimable over the configured schedule.
57
-
58
- **Snapshot timing:** The snapshot for each round is taken when the previous round first sees activity (`poke`, `beginVesting`, or `collectVestedRewards`). To be included in round N's distribution, make sure your tokens are held and delegated before anyone interacts with round N-1. In practice, keep your delegation current — if it is set before the previous round's activity begins, your voting power will be counted for the next round.
59
-
60
- **Failure Modes**
61
- - zero total stake
62
- - bad deployment parameters such as zero round duration or zero vesting rounds
63
- - stake snapshot is stale or surprising to operators
64
-
65
- **Postconditions**
66
- - a new vesting round exists with fixed snapshot assumptions
67
-
68
- ## Journey 3: Collect Vested Rewards
69
-
70
- **Actor:** eligible recipient.
71
-
72
- **Intent:** collect the share that has unlocked for a round.
73
-
74
- **Preconditions**
75
- - the recipient is authorized under the distributor type
76
- - some share has already vested
77
-
78
- **Main Flow**
79
- 1. Call the relevant claim function.
80
- 2. The distributor checks authority and unlocked amount.
81
- 3. The vested share transfers to the claimant.
82
-
83
- **Failure Modes**
84
- - invalid claimant
85
- - claim batch includes invalid 721 token IDs
86
- - reward token transfer fails
87
-
88
- **Postconditions**
89
- - vested rewards move to the claimant
90
-
91
- ## Journey 4: Recycle Forfeited 721 Rewards
92
-
93
- **Actor:** caller using the 721 distributor path.
94
-
95
- **Intent:** release rewards tied to burned NFTs back into the future distribution pool.
96
-
97
- **Preconditions**
98
- - the distributor type is 721-based
99
- - the relevant NFTs are burned or otherwise forfeited under the configured rules
100
-
101
- **Main Flow**
102
- 1. Call the forfeiture-release path.
103
- 2. The distributor reduces current vesting obligations for those forfeited claims.
104
- 3. The value remains in the distributor for future rounds instead of being destroyed.
105
-
106
- **Failure Modes**
107
- - caller expects the same behavior from the token distributor
108
- - off-chain systems treat forfeited value as burned instead of recycled
109
-
110
- **Postconditions**
111
- - forfeited 721 rewards return to the future distributable pool
112
-
113
- ## Trust Boundaries
114
-
115
- - this repo trusts `JBDirectory` for authenticated split-hook caller checks
116
- - `JBTokenDistributor` trusts `IVotes` checkpoints
117
- - `JB721Distributor` trusts the 721 hook's `CHECKPOINTS()` module for historical voting power and the store for tier metadata
118
-
119
- ## Hand-Offs
120
-
121
- - Use the upstream repo that funded the distributor when the question is about why an allocation exists.
122
- - Use [nana-721-hook-v6](../nana-721-hook-v6/USER_JOURNEYS.md) when the stake source is a tiered 721 hook.
@@ -1,10 +0,0 @@
1
- {
2
- "detectors_to_exclude": "timestamp,uninitialized-local,naming-convention,solc-version,shadowing-local",
3
- "exclude_informational": true,
4
- "exclude_low": false,
5
- "exclude_medium": false,
6
- "exclude_high": false,
7
- "disable_color": false,
8
- "filter_paths": "(mocks/|test/|node_modules/|lib/)",
9
- "legacy_ast": false
10
- }
@@ -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
- }