@bananapus/distributor-v6 0.0.5 → 0.0.7
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/package.json +1 -1
- package/src/JB721Distributor.sol +42 -11
- package/src/JBDistributor.sol +17 -0
- package/src/JBTokenDistributor.sol +15 -4
- package/test/JB721Distributor.t.sol +5 -0
- package/test/audit/CodexNemesisAccountingPoC.t.sol +22 -25
- package/test/audit/CodexNemesisFreshSplitTokenMismatch.t.sol +133 -0
- package/test/audit/CodexNemesisFreshVerification.t.sol +218 -0
- package/test/audit/CodexNemesisPoC.t.sol +191 -0
- package/test/audit/H26VotingPowerCap.t.sol +5 -0
- package/test/audit/Pass12Fixes.t.sol +344 -0
- package/test/audit/PostSnapshotMintTheft.t.sol +413 -0
- package/test/audit/TokenMismatchFix.t.sol +295 -0
- package/test/fork/TokenDistributorFork.t.sol +1 -1
- package/test/invariant/JB721DistributorInvariant.t.sol +5 -0
|
@@ -0,0 +1,413 @@
|
|
|
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 {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
9
|
+
import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
|
|
10
|
+
|
|
11
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
12
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
13
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
14
|
+
|
|
15
|
+
import {JB721Distributor} from "../../src/JB721Distributor.sol";
|
|
16
|
+
import {JBDistributor} from "../../src/JBDistributor.sol";
|
|
17
|
+
|
|
18
|
+
// --- Mocks ---------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
contract VPCapMockDirectory {
|
|
21
|
+
mapping(uint256 => mapping(address => bool)) public terminals;
|
|
22
|
+
|
|
23
|
+
function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
|
|
24
|
+
terminals[projectId][terminal] = isTerminal;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
|
|
28
|
+
return terminals[projectId][address(terminal)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function controllerOf(uint256) external pure returns (IERC165) {
|
|
32
|
+
return IERC165(address(0));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
contract VPCapMockToken is ERC20 {
|
|
37
|
+
constructor() ERC20("Reward", "RWD") {}
|
|
38
|
+
|
|
39
|
+
function mint(address to, uint256 amount) external {
|
|
40
|
+
_mint(to, amount);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
contract VPCapMockStore {
|
|
45
|
+
uint256 public maxTier;
|
|
46
|
+
mapping(uint256 => JB721Tier) public tiers;
|
|
47
|
+
mapping(uint256 => uint256) public burned;
|
|
48
|
+
mapping(uint256 => uint256) public tokenTiers;
|
|
49
|
+
|
|
50
|
+
function setMaxTierIdOf(uint256 v) external {
|
|
51
|
+
maxTier = v;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function maxTierIdOf(address) external view returns (uint256) {
|
|
55
|
+
return maxTier;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function setTier(uint256 tierId, JB721Tier memory tier) external {
|
|
59
|
+
tiers[tierId] = tier;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tierOf(address, uint256 id, bool) external view returns (JB721Tier memory) {
|
|
63
|
+
return tiers[id];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function setTokenTier(uint256 tokenId, uint256 tierId) external {
|
|
67
|
+
tokenTiers[tokenId] = tierId;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
71
|
+
return tiers[tokenTiers[tokenId]];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setBurnedFor(uint256 tierId, uint256 count) external {
|
|
75
|
+
burned[tierId] = count;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function numberOfBurnedFor(address, uint256 tierId) external view returns (uint256) {
|
|
79
|
+
return burned[tierId];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
contract VPCapMockCheckpoints {
|
|
84
|
+
VPCapMockStore public store;
|
|
85
|
+
address public hookAddr;
|
|
86
|
+
|
|
87
|
+
uint256 public totalSupplyOverride;
|
|
88
|
+
|
|
89
|
+
mapping(address => uint256) public votesOverride;
|
|
90
|
+
mapping(address => bool) public votesOverrideSet;
|
|
91
|
+
|
|
92
|
+
constructor(VPCapMockStore _store, address _hook) {
|
|
93
|
+
store = _store;
|
|
94
|
+
hookAddr = _hook;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setTotalSupplyOverride(uint256 value) external {
|
|
98
|
+
totalSupplyOverride = value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function setVotesOverride(address account, uint256 value) external {
|
|
102
|
+
votesOverride[account] = value;
|
|
103
|
+
votesOverrideSet[account] = true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getPastTotalSupply(uint256) external view returns (uint256 total) {
|
|
107
|
+
if (totalSupplyOverride != 0) return totalSupplyOverride;
|
|
108
|
+
uint256 max = store.maxTier();
|
|
109
|
+
for (uint256 i = 1; i <= max; i++) {
|
|
110
|
+
JB721Tier memory tier = store.tierOf(hookAddr, i, false);
|
|
111
|
+
if (tier.id == 0 || tier.initialSupply == 0) continue;
|
|
112
|
+
uint256 b = store.burned(i);
|
|
113
|
+
uint256 held = tier.initialSupply - tier.remainingSupply - b;
|
|
114
|
+
total += held * tier.votingUnits;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getPastVotes(address account, uint256) external view returns (uint256) {
|
|
119
|
+
if (votesOverrideSet[account]) return votesOverride[account];
|
|
120
|
+
return 0; // Default: no historical votes (realistic behavior).
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
contract VPCapMockHook {
|
|
125
|
+
VPCapMockStore public immutable _store;
|
|
126
|
+
VPCapMockCheckpoints public _checkpoints;
|
|
127
|
+
mapping(uint256 => address) public owners;
|
|
128
|
+
|
|
129
|
+
constructor(VPCapMockStore s) {
|
|
130
|
+
_store = s;
|
|
131
|
+
_checkpoints = new VPCapMockCheckpoints(s, address(this));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// solhint-disable-next-line func-name-mixedcase
|
|
135
|
+
function STORE() external view returns (VPCapMockStore) {
|
|
136
|
+
return _store;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// solhint-disable-next-line func-name-mixedcase
|
|
140
|
+
function CHECKPOINTS() external view returns (VPCapMockCheckpoints) {
|
|
141
|
+
return _checkpoints;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function ownerOf(uint256 tokenId) external view returns (address) {
|
|
145
|
+
address o = owners[tokenId];
|
|
146
|
+
require(o != address(0), "ERC721: invalid token ID");
|
|
147
|
+
return o;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
151
|
+
owners[tokenId] = owner;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Tests ---------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/// @title VotingPowerCapSufficiencyTest
|
|
158
|
+
/// @notice Proves that the `_consumedVotesOf` tracking against `getPastVotes` is sufficient
|
|
159
|
+
/// to prevent post-snapshot minted NFTs from extracting excess rewards — no `mintBlockOf`
|
|
160
|
+
/// storage on the 721 hook is needed.
|
|
161
|
+
///
|
|
162
|
+
/// Key invariant: an owner's total vested rewards are bounded by their historical voting
|
|
163
|
+
/// power at the snapshot block, regardless of which specific tokens they vest.
|
|
164
|
+
contract VotingPowerCapSufficiencyTest is Test {
|
|
165
|
+
JB721Distributor distributor;
|
|
166
|
+
VPCapMockToken rewardToken;
|
|
167
|
+
VPCapMockHook hook;
|
|
168
|
+
VPCapMockStore store;
|
|
169
|
+
VPCapMockDirectory directory;
|
|
170
|
+
|
|
171
|
+
address alice = makeAddr("alice");
|
|
172
|
+
address bob = makeAddr("bob");
|
|
173
|
+
address charlie = makeAddr("charlie");
|
|
174
|
+
|
|
175
|
+
uint256 constant ROUND_DURATION = 100;
|
|
176
|
+
uint256 constant VESTING_ROUNDS = 4;
|
|
177
|
+
|
|
178
|
+
function setUp() public {
|
|
179
|
+
store = new VPCapMockStore();
|
|
180
|
+
hook = new VPCapMockHook(store);
|
|
181
|
+
directory = new VPCapMockDirectory();
|
|
182
|
+
distributor = new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
183
|
+
|
|
184
|
+
directory.setTerminal(1, address(this), true);
|
|
185
|
+
rewardToken = new VPCapMockToken();
|
|
186
|
+
|
|
187
|
+
JB721TierFlags memory flags;
|
|
188
|
+
store.setMaxTierIdOf(1);
|
|
189
|
+
|
|
190
|
+
// Tier 1: votingUnits=100, 2 minted (initialSupply=10, remainingSupply=8).
|
|
191
|
+
store.setTier(
|
|
192
|
+
1,
|
|
193
|
+
JB721Tier({
|
|
194
|
+
id: 1,
|
|
195
|
+
price: 1 ether,
|
|
196
|
+
remainingSupply: 8,
|
|
197
|
+
initialSupply: 10,
|
|
198
|
+
votingUnits: 100,
|
|
199
|
+
reserveFrequency: 0,
|
|
200
|
+
reserveBeneficiary: address(0),
|
|
201
|
+
encodedIPFSUri: bytes32(0),
|
|
202
|
+
category: 0,
|
|
203
|
+
discountPercent: 0,
|
|
204
|
+
flags: flags,
|
|
205
|
+
splitPercent: 0,
|
|
206
|
+
resolvedUri: ""
|
|
207
|
+
})
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Token 1 -> alice, Token 2 -> bob (both pre-snapshot).
|
|
211
|
+
store.setTokenTier(1, 1);
|
|
212
|
+
hook.setOwner(1, alice);
|
|
213
|
+
store.setTokenTier(2, 1);
|
|
214
|
+
hook.setOwner(2, bob);
|
|
215
|
+
|
|
216
|
+
// Set realistic historical voting power: each holder had 100 at snapshot.
|
|
217
|
+
hook._checkpoints().setVotesOverride(alice, 100);
|
|
218
|
+
hook._checkpoints().setVotesOverride(bob, 100);
|
|
219
|
+
// Charlie has 0 voting power at snapshot (default).
|
|
220
|
+
|
|
221
|
+
// Fix total supply at 200 so post-snapshot mints don't inflate denominator.
|
|
222
|
+
hook._checkpoints().setTotalSupplyOverride(200);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _advanceToRound(uint256 round) internal {
|
|
226
|
+
uint256 target = distributor.roundStartTimestamp(round) + 1;
|
|
227
|
+
if (block.timestamp < target) vm.warp(target);
|
|
228
|
+
vm.roll(block.number + 1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _fundHook(uint256 amount) internal {
|
|
232
|
+
rewardToken.mint(address(this), amount);
|
|
233
|
+
rewardToken.approve(address(distributor), amount);
|
|
234
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), amount);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/// @notice Post-snapshot mint cannot extract more than the owner's historical voting power.
|
|
238
|
+
/// Alice has 100 votes at snapshot. She mints token 3 after snapshot and vests both.
|
|
239
|
+
/// Total extraction: 500 ether (capped at 100/200 of pool), NOT 1000 ether.
|
|
240
|
+
function test_votingPowerCap_preventsOverExtraction() public {
|
|
241
|
+
_fundHook(1000 ether);
|
|
242
|
+
_advanceToRound(1);
|
|
243
|
+
distributor.poke();
|
|
244
|
+
|
|
245
|
+
// AFTER snapshot: Alice mints token 3.
|
|
246
|
+
vm.roll(block.number + 5);
|
|
247
|
+
store.setTokenTier(3, 1);
|
|
248
|
+
hook.setOwner(3, alice);
|
|
249
|
+
|
|
250
|
+
// Alice vests both tokens 1 (pre-snapshot) and 3 (post-snapshot).
|
|
251
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
252
|
+
tokenIds[0] = 1;
|
|
253
|
+
tokenIds[1] = 3;
|
|
254
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
255
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
256
|
+
|
|
257
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
258
|
+
|
|
259
|
+
// Token 1 consumed all 100 votes. Token 3 gets 0 (budget exhausted).
|
|
260
|
+
uint256 token1Claimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
261
|
+
uint256 token3Claimed = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
|
|
262
|
+
|
|
263
|
+
assertEq(token1Claimed, 500 ether, "Token 1 gets full share (100/200)");
|
|
264
|
+
assertEq(token3Claimed, 0, "Token 3 gets 0 (voting power budget exhausted)");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// @notice Vesting only a post-snapshot token still capped by historical votes.
|
|
268
|
+
/// Alice skips token 1, vests only token 3 (post-snapshot). Gets 500 ether through it.
|
|
269
|
+
/// Then token 1 gets 0 because the budget is spent. Total: still 500.
|
|
270
|
+
function test_votingPowerCap_postSnapshotOnlyToken_sameTotal() public {
|
|
271
|
+
_fundHook(1000 ether);
|
|
272
|
+
_advanceToRound(1);
|
|
273
|
+
distributor.poke();
|
|
274
|
+
|
|
275
|
+
// AFTER snapshot: Alice mints token 3.
|
|
276
|
+
vm.roll(block.number + 5);
|
|
277
|
+
store.setTokenTier(3, 1);
|
|
278
|
+
hook.setOwner(3, alice);
|
|
279
|
+
|
|
280
|
+
// Alice vests ONLY token 3 (post-snapshot).
|
|
281
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
282
|
+
tokenIds[0] = 3;
|
|
283
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
284
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
285
|
+
|
|
286
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
287
|
+
|
|
288
|
+
uint256 token3Claimed = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
|
|
289
|
+
assertEq(token3Claimed, 500 ether, "Token 3 vests using Alice's historical 100 votes");
|
|
290
|
+
|
|
291
|
+
// Now vest token 1. Alice's budget is already consumed.
|
|
292
|
+
tokenIds[0] = 1;
|
|
293
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
294
|
+
|
|
295
|
+
uint256 token1Claimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
296
|
+
assertEq(token1Claimed, 0, "Token 1 gets 0 (budget spent on token 3)");
|
|
297
|
+
|
|
298
|
+
// Total: 500 ether — exactly what Alice is entitled to.
|
|
299
|
+
assertEq(token3Claimed + token1Claimed, 500 ether, "Total extraction bounded by historical votes");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/// @notice No historical voting power → zero rewards, even with a valid NFT.
|
|
303
|
+
function test_votingPowerCap_noHistoricalVotes_zeroRewards() public {
|
|
304
|
+
_fundHook(1000 ether);
|
|
305
|
+
_advanceToRound(1);
|
|
306
|
+
distributor.poke();
|
|
307
|
+
|
|
308
|
+
// AFTER snapshot: Charlie (0 votes at snapshot) mints token 3.
|
|
309
|
+
vm.roll(block.number + 5);
|
|
310
|
+
store.setTokenTier(3, 1);
|
|
311
|
+
hook.setOwner(3, charlie);
|
|
312
|
+
|
|
313
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
314
|
+
tokenIds[0] = 3;
|
|
315
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
316
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
317
|
+
|
|
318
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
319
|
+
|
|
320
|
+
uint256 claimed = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
|
|
321
|
+
assertEq(claimed, 0, "No historical votes = no rewards");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/// @notice Multiple post-snapshot tokens still bounded by historical voting power.
|
|
325
|
+
/// Alice mints 3 new tokens after snapshot. Total extraction: still 500 ether.
|
|
326
|
+
function test_votingPowerCap_multiplePostSnapshotTokens_bounded() public {
|
|
327
|
+
_fundHook(1000 ether);
|
|
328
|
+
_advanceToRound(1);
|
|
329
|
+
distributor.poke();
|
|
330
|
+
|
|
331
|
+
// AFTER snapshot: Alice mints tokens 3, 4, 5.
|
|
332
|
+
vm.roll(block.number + 5);
|
|
333
|
+
for (uint256 i = 3; i <= 5; i++) {
|
|
334
|
+
store.setTokenTier(i, 1);
|
|
335
|
+
hook.setOwner(i, alice);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Alice vests all her tokens (1 pre-snapshot + 3 post-snapshot).
|
|
339
|
+
uint256[] memory tokenIds = new uint256[](4);
|
|
340
|
+
tokenIds[0] = 1;
|
|
341
|
+
tokenIds[1] = 3;
|
|
342
|
+
tokenIds[2] = 4;
|
|
343
|
+
tokenIds[3] = 5;
|
|
344
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
345
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
346
|
+
|
|
347
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
348
|
+
|
|
349
|
+
uint256 total;
|
|
350
|
+
for (uint256 i; i < tokenIds.length; i++) {
|
|
351
|
+
total += distributor.claimedFor(address(hook), tokenIds[i], IERC20(address(rewardToken)));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
assertEq(total, 500 ether, "4 tokens but still capped at 100/200 of pool");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/// @notice Burn-and-remint: Alice burns pre-snapshot token, mints replacement after.
|
|
358
|
+
/// Total extraction: still 500 ether (same as if she kept the original).
|
|
359
|
+
function test_votingPowerCap_burnAndRemint_bounded() public {
|
|
360
|
+
_fundHook(1000 ether);
|
|
361
|
+
_advanceToRound(1);
|
|
362
|
+
distributor.poke();
|
|
363
|
+
|
|
364
|
+
// Simulate burn of token 1 (ownerOf reverts for burned tokens).
|
|
365
|
+
hook.setOwner(1, address(0));
|
|
366
|
+
|
|
367
|
+
// AFTER snapshot: Alice mints token 3 as replacement.
|
|
368
|
+
vm.roll(block.number + 5);
|
|
369
|
+
store.setTokenTier(3, 1);
|
|
370
|
+
hook.setOwner(3, alice);
|
|
371
|
+
|
|
372
|
+
// Vest token 3 only (token 1 is burned).
|
|
373
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
374
|
+
tokenIds[0] = 3;
|
|
375
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
376
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
377
|
+
|
|
378
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
379
|
+
|
|
380
|
+
uint256 claimed = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
|
|
381
|
+
assertEq(claimed, 500 ether, "Replacement token capped at Alice's historical 100 votes");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/// @notice Cross-owner isolation: Alice's post-snapshot mint doesn't affect Bob's rewards.
|
|
385
|
+
function test_votingPowerCap_crossOwnerIsolation() public {
|
|
386
|
+
_fundHook(1000 ether);
|
|
387
|
+
_advanceToRound(1);
|
|
388
|
+
distributor.poke();
|
|
389
|
+
|
|
390
|
+
// AFTER snapshot: Alice mints token 3.
|
|
391
|
+
vm.roll(block.number + 5);
|
|
392
|
+
store.setTokenTier(3, 1);
|
|
393
|
+
hook.setOwner(3, alice);
|
|
394
|
+
|
|
395
|
+
// Vest all three tokens.
|
|
396
|
+
uint256[] memory tokenIds = new uint256[](3);
|
|
397
|
+
tokenIds[0] = 1; // alice
|
|
398
|
+
tokenIds[1] = 2; // bob
|
|
399
|
+
tokenIds[2] = 3; // alice (post-snapshot)
|
|
400
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
401
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
402
|
+
|
|
403
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
404
|
+
|
|
405
|
+
uint256 aliceTotal = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)))
|
|
406
|
+
+ distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
|
|
407
|
+
uint256 bobTotal = distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken)));
|
|
408
|
+
|
|
409
|
+
assertEq(aliceTotal, 500 ether, "Alice gets exactly her 100/200 share");
|
|
410
|
+
assertEq(bobTotal, 500 ether, "Bob gets exactly his 100/200 share");
|
|
411
|
+
assertEq(aliceTotal + bobTotal, 1000 ether, "Full pool distributed, no over-extraction");
|
|
412
|
+
}
|
|
413
|
+
}
|