@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.
- package/.github/pull_request_template.md +33 -0
- package/.github/workflows/lint.yml +19 -0
- package/.github/workflows/publish.yml +19 -0
- package/.github/workflows/slither.yml +23 -0
- package/.github/workflows/test.yml +26 -0
- package/.gitmodules +3 -0
- package/ADMINISTRATION.md +65 -0
- package/ARCHITECTURE.md +89 -0
- package/AUDIT_INSTRUCTIONS.md +52 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/RISKS.md +78 -0
- package/SKILLS.md +36 -0
- package/USER_JOURNEYS.md +120 -0
- package/foundry.toml +22 -0
- package/package.json +29 -0
- package/references/operations.md +25 -0
- package/references/runtime.md +36 -0
- package/remappings.txt +1 -0
- package/script/Deploy.s.sol +23 -0
- package/slither-ci.config.json +10 -0
- package/src/JB721Distributor.sol +180 -0
- package/src/JBDistributor.sol +563 -0
- package/src/JBTokenDistributor.sol +160 -0
- package/src/interfaces/IJB721Distributor.sol +15 -0
- package/src/interfaces/IJBDistributor.sol +138 -0
- package/src/interfaces/IJBTokenDistributor.sol +16 -0
- package/src/structs/JBTokenSnapshotData.sol +9 -0
- package/src/structs/JBVestingData.sol +11 -0
- package/test/JB721Distributor.t.sol +1985 -0
- package/test/JBTokenDistributor.t.sol +424 -0
- package/test/invariant/JB721DistributorInvariant.t.sol +410 -0
|
@@ -0,0 +1,1985 @@
|
|
|
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 {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
9
|
+
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
10
|
+
import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
|
|
11
|
+
|
|
12
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
13
|
+
import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
14
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
15
|
+
import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
|
|
16
|
+
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
17
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
18
|
+
|
|
19
|
+
import {JB721Distributor} from "../src/JB721Distributor.sol";
|
|
20
|
+
import {JBDistributor} from "../src/JBDistributor.sol";
|
|
21
|
+
import {IJBDistributor} from "../src/interfaces/IJBDistributor.sol";
|
|
22
|
+
import {IJB721Distributor} from "../src/interfaces/IJB721Distributor.sol";
|
|
23
|
+
import {JBTokenSnapshotData} from "../src/structs/JBTokenSnapshotData.sol";
|
|
24
|
+
|
|
25
|
+
/// @notice Mock JB directory for testing.
|
|
26
|
+
contract MockDirectory {
|
|
27
|
+
mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
|
|
28
|
+
mapping(uint256 projectId => address controller) public controllers;
|
|
29
|
+
|
|
30
|
+
function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
|
|
31
|
+
terminals[projectId][terminal] = isTerminal;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setController(uint256 projectId, address controller) external {
|
|
35
|
+
controllers[projectId] = controller;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
|
|
39
|
+
return terminals[projectId][address(terminal)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function controllerOf(uint256 projectId) external view returns (IERC165) {
|
|
43
|
+
return IERC165(controllers[projectId]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// @notice Simple ERC20 token for testing.
|
|
48
|
+
contract MockToken is ERC20 {
|
|
49
|
+
constructor() ERC20("Reward", "RWD") {}
|
|
50
|
+
|
|
51
|
+
function mint(address to, uint256 amount) external {
|
|
52
|
+
_mint(to, amount);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// @notice Second reward token.
|
|
57
|
+
contract MockToken2 is ERC20 {
|
|
58
|
+
constructor() ERC20("Reward2", "RWD2") {}
|
|
59
|
+
|
|
60
|
+
function mint(address to, uint256 amount) external {
|
|
61
|
+
_mint(to, amount);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// @notice Mock checkpoints contract that provides IVotes-compatible getPastVotes/getPastTotalSupply.
|
|
66
|
+
/// @dev Computes getPastTotalSupply dynamically from the store (tier data minus burned), matching
|
|
67
|
+
/// the old _totalStake logic. getPastVotes returns type(uint256).max for any address so that
|
|
68
|
+
/// min(votingUnits, pastVotes) always equals votingUnits.
|
|
69
|
+
contract MockCheckpoints {
|
|
70
|
+
MockStore public store;
|
|
71
|
+
address public hookAddr;
|
|
72
|
+
|
|
73
|
+
/// @dev Override: if non-zero, getPastTotalSupply returns this instead of computing from store.
|
|
74
|
+
uint256 public totalSupplyOverride;
|
|
75
|
+
|
|
76
|
+
/// @dev Per-address vote overrides. If set to non-zero, getPastVotes returns this value.
|
|
77
|
+
mapping(address => uint256) public votesOverride;
|
|
78
|
+
|
|
79
|
+
/// @dev Tracks whether a per-address override was explicitly set (to allow setting 0).
|
|
80
|
+
mapping(address => bool) public votesOverrideSet;
|
|
81
|
+
|
|
82
|
+
constructor(MockStore _store, address _hook) {
|
|
83
|
+
store = _store;
|
|
84
|
+
hookAddr = _hook;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function setTotalSupplyOverride(uint256 value) external {
|
|
88
|
+
totalSupplyOverride = value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function setVotesOverride(address account, uint256 value) external {
|
|
92
|
+
votesOverride[account] = value;
|
|
93
|
+
votesOverrideSet[account] = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getPastTotalSupply(uint256) external view returns (uint256 total) {
|
|
97
|
+
if (totalSupplyOverride != 0) return totalSupplyOverride;
|
|
98
|
+
// Dynamically compute from store: sum over tiers of (minted - burned) * votingUnits.
|
|
99
|
+
uint256 maxTier = store.maxTier();
|
|
100
|
+
for (uint256 i = 1; i <= maxTier; i++) {
|
|
101
|
+
JB721Tier memory tier = store.tierOf(hookAddr, i, false);
|
|
102
|
+
if (tier.id == 0 || tier.initialSupply == 0) continue;
|
|
103
|
+
uint256 burned = store.burned(i);
|
|
104
|
+
uint256 held = tier.initialSupply - tier.remainingSupply - burned;
|
|
105
|
+
total += held * tier.votingUnits;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getPastVotes(address account, uint256) external view returns (uint256) {
|
|
110
|
+
if (votesOverrideSet[account]) return votesOverride[account];
|
|
111
|
+
// Default: return max so min(votingUnits, pastVotes) = votingUnits for any holder.
|
|
112
|
+
return type(uint256).max;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// @notice Mock 721 tiers hook for testing.
|
|
117
|
+
contract MockHook {
|
|
118
|
+
MockStore public immutable _store;
|
|
119
|
+
MockCheckpoints public _checkpoints;
|
|
120
|
+
|
|
121
|
+
mapping(uint256 tokenId => address owner) public owners;
|
|
122
|
+
|
|
123
|
+
constructor(MockStore store) {
|
|
124
|
+
_store = store;
|
|
125
|
+
_checkpoints = new MockCheckpoints(store, address(this));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function STORE() external view returns (MockStore) {
|
|
129
|
+
return _store;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function CHECKPOINTS() external view returns (MockCheckpoints) {
|
|
133
|
+
return _checkpoints;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ownerOf(uint256 tokenId) external view returns (address) {
|
|
137
|
+
address owner = owners[tokenId];
|
|
138
|
+
require(owner != address(0), "ERC721: invalid token ID");
|
|
139
|
+
return owner;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
143
|
+
owners[tokenId] = owner;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function burn(uint256 tokenId) external {
|
|
147
|
+
delete owners[tokenId];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// @notice Mock 721 tiers hook store for testing.
|
|
152
|
+
contract MockStore {
|
|
153
|
+
uint256 public maxTier;
|
|
154
|
+
mapping(uint256 tierId => JB721Tier) public tiers;
|
|
155
|
+
mapping(uint256 tierId => uint256) public burned;
|
|
156
|
+
mapping(uint256 tokenId => uint256 tierId) public tokenTiers;
|
|
157
|
+
|
|
158
|
+
function setMaxTierIdOf(uint256 maxTierId) external {
|
|
159
|
+
maxTier = maxTierId;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function maxTierIdOf(address) external view returns (uint256) {
|
|
163
|
+
return maxTier;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function setTier(uint256 tierId, JB721Tier memory tier) external {
|
|
167
|
+
tiers[tierId] = tier;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function tierOf(address, uint256 id, bool) external view returns (JB721Tier memory) {
|
|
171
|
+
return tiers[id];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function setTokenTier(uint256 tokenId, uint256 tierId) external {
|
|
175
|
+
tokenTiers[tokenId] = tierId;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
179
|
+
return tiers[tokenTiers[tokenId]];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function setBurnedFor(uint256 tierId, uint256 count) external {
|
|
183
|
+
burned[tierId] = count;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function numberOfBurnedFor(address, uint256 tierId) external view returns (uint256) {
|
|
187
|
+
return burned[tierId];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
contract JB721DistributorTest is Test {
|
|
192
|
+
JB721Distributor distributor;
|
|
193
|
+
MockToken rewardToken;
|
|
194
|
+
MockToken2 rewardToken2;
|
|
195
|
+
MockHook hook;
|
|
196
|
+
MockStore store;
|
|
197
|
+
MockDirectory directory;
|
|
198
|
+
|
|
199
|
+
address alice = makeAddr("alice");
|
|
200
|
+
address bob = makeAddr("bob");
|
|
201
|
+
address charlie = makeAddr("charlie");
|
|
202
|
+
|
|
203
|
+
uint256 constant PROJECT_ID = 1;
|
|
204
|
+
uint256 constant ROUND_DURATION = 100;
|
|
205
|
+
uint256 constant VESTING_ROUNDS = 4;
|
|
206
|
+
uint256 constant MAX_SHARE = 100_000;
|
|
207
|
+
|
|
208
|
+
// Default setup:
|
|
209
|
+
// Tier 1: initialSupply=10, remainingSupply=8 -> 2 minted, votingUnits=100
|
|
210
|
+
// Tier 2: initialSupply=5, remainingSupply=4 -> 1 minted, votingUnits=200
|
|
211
|
+
// Total stake = 2*100 + 1*200 = 400
|
|
212
|
+
// Token 1 -> tier 1 -> alice (stake weight 100, share = 25%)
|
|
213
|
+
// Token 2 -> tier 2 -> bob (stake weight 200, share = 50%)
|
|
214
|
+
|
|
215
|
+
function setUp() public {
|
|
216
|
+
store = new MockStore();
|
|
217
|
+
hook = new MockHook(store);
|
|
218
|
+
directory = new MockDirectory();
|
|
219
|
+
|
|
220
|
+
distributor = new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
221
|
+
|
|
222
|
+
// Register this test contract as a terminal for PROJECT_ID so processSplitWith works.
|
|
223
|
+
directory.setTerminal(PROJECT_ID, address(this), true);
|
|
224
|
+
|
|
225
|
+
rewardToken = new MockToken();
|
|
226
|
+
rewardToken2 = new MockToken2();
|
|
227
|
+
|
|
228
|
+
JB721TierFlags memory flags;
|
|
229
|
+
|
|
230
|
+
store.setMaxTierIdOf(2);
|
|
231
|
+
|
|
232
|
+
store.setTier(
|
|
233
|
+
1,
|
|
234
|
+
JB721Tier({
|
|
235
|
+
id: 1,
|
|
236
|
+
price: 1 ether,
|
|
237
|
+
remainingSupply: 8,
|
|
238
|
+
initialSupply: 10,
|
|
239
|
+
votingUnits: 100,
|
|
240
|
+
reserveFrequency: 0,
|
|
241
|
+
reserveBeneficiary: address(0),
|
|
242
|
+
encodedIPFSUri: bytes32(0),
|
|
243
|
+
category: 0,
|
|
244
|
+
discountPercent: 0,
|
|
245
|
+
flags: flags,
|
|
246
|
+
splitPercent: 0,
|
|
247
|
+
resolvedUri: ""
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
store.setTier(
|
|
252
|
+
2,
|
|
253
|
+
JB721Tier({
|
|
254
|
+
id: 2,
|
|
255
|
+
price: 2 ether,
|
|
256
|
+
remainingSupply: 4,
|
|
257
|
+
initialSupply: 5,
|
|
258
|
+
votingUnits: 200,
|
|
259
|
+
reserveFrequency: 0,
|
|
260
|
+
reserveBeneficiary: address(0),
|
|
261
|
+
encodedIPFSUri: bytes32(0),
|
|
262
|
+
category: 0,
|
|
263
|
+
discountPercent: 0,
|
|
264
|
+
flags: flags,
|
|
265
|
+
splitPercent: 0,
|
|
266
|
+
resolvedUri: ""
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
store.setTokenTier(1, 1);
|
|
271
|
+
hook.setOwner(1, alice);
|
|
272
|
+
|
|
273
|
+
store.setTokenTier(2, 2);
|
|
274
|
+
hook.setOwner(2, bob);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// =====================================================================
|
|
278
|
+
// Constructor
|
|
279
|
+
// =====================================================================
|
|
280
|
+
|
|
281
|
+
function test_constructor() public view {
|
|
282
|
+
assertEq(distributor.roundDuration(), ROUND_DURATION);
|
|
283
|
+
assertEq(distributor.vestingRounds(), VESTING_ROUNDS);
|
|
284
|
+
assertEq(distributor.startingBlock(), block.number);
|
|
285
|
+
assertEq(distributor.MAX_SHARE(), MAX_SHARE);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// =====================================================================
|
|
289
|
+
// View functions
|
|
290
|
+
// =====================================================================
|
|
291
|
+
|
|
292
|
+
function test_currentRound() public view {
|
|
293
|
+
assertEq(distributor.currentRound(), 0);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function test_currentRound_afterRolling() public {
|
|
297
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
298
|
+
assertEq(distributor.currentRound(), 1);
|
|
299
|
+
|
|
300
|
+
vm.roll(block.number + ROUND_DURATION * 3);
|
|
301
|
+
assertEq(distributor.currentRound(), 4);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function test_roundStartBlock() public view {
|
|
305
|
+
assertEq(distributor.roundStartBlock(0), distributor.startingBlock());
|
|
306
|
+
assertEq(distributor.roundStartBlock(1), distributor.startingBlock() + ROUND_DURATION);
|
|
307
|
+
assertEq(distributor.roundStartBlock(5), distributor.startingBlock() + ROUND_DURATION * 5);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function test_claimedFor_beforeVesting() public view {
|
|
311
|
+
// No vesting started, should be 0.
|
|
312
|
+
assertEq(distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken))), 0);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function test_claimedFor_afterVesting() public {
|
|
316
|
+
_fundHook(1000 ether);
|
|
317
|
+
_beginVestingBoth();
|
|
318
|
+
|
|
319
|
+
// Token 1: mulDiv(1000e18, 100, 400) = 250e18.
|
|
320
|
+
// claimedFor = mulDiv(250e18, 100000, 100000) = 250e18.
|
|
321
|
+
assertEq(distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken))), 250 ether);
|
|
322
|
+
// Token 2: mulDiv(1000e18, 200, 400) = 500e18.
|
|
323
|
+
assertEq(distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken))), 500 ether);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function test_collectableFor_atStart() public {
|
|
327
|
+
_fundHook(1000 ether);
|
|
328
|
+
_beginVestingBoth();
|
|
329
|
+
|
|
330
|
+
// At round 0, releaseRound = 4. lockedShare = (4-0)*100000/4 = 100000.
|
|
331
|
+
// collectableFor = mulDiv(250e18, 100000 - 0 - 100000, 100000) = 0.
|
|
332
|
+
assertEq(distributor.collectableFor(address(hook), 1, IERC20(address(rewardToken))), 0);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function test_collectableFor_atHalf() public {
|
|
336
|
+
_fundHook(1000 ether);
|
|
337
|
+
_beginVestingBoth();
|
|
338
|
+
|
|
339
|
+
vm.roll(block.number + ROUND_DURATION * 2);
|
|
340
|
+
|
|
341
|
+
// lockedShare = (4-2)*100000/4 = 50000.
|
|
342
|
+
// collectableFor = mulDiv(250e18, 100000 - 0 - 50000, 100000) = 125e18.
|
|
343
|
+
assertEq(distributor.collectableFor(address(hook), 1, IERC20(address(rewardToken))), 125 ether);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function test_collectableFor_atFull() public {
|
|
347
|
+
_fundHook(1000 ether);
|
|
348
|
+
_beginVestingBoth();
|
|
349
|
+
|
|
350
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
351
|
+
|
|
352
|
+
// lockedShare = 0. collectableFor = 250e18.
|
|
353
|
+
assertEq(distributor.collectableFor(address(hook), 1, IERC20(address(rewardToken))), 250 ether);
|
|
354
|
+
assertEq(distributor.collectableFor(address(hook), 2, IERC20(address(rewardToken))), 500 ether);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function test_snapshotAtRoundOf() public {
|
|
358
|
+
_fundHook(1000 ether);
|
|
359
|
+
|
|
360
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
361
|
+
tokenIds[0] = 1;
|
|
362
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
363
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
364
|
+
|
|
365
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
366
|
+
|
|
367
|
+
JBTokenSnapshotData memory snapshot =
|
|
368
|
+
distributor.snapshotAtRoundOf(address(hook), IERC20(address(rewardToken)), 0);
|
|
369
|
+
assertEq(snapshot.balance, 1000 ether);
|
|
370
|
+
assertEq(snapshot.vestingAmount, 0); // No prior vesting.
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function test_vestingDataOf() public {
|
|
374
|
+
_fundHook(1000 ether);
|
|
375
|
+
_beginVestingBoth();
|
|
376
|
+
|
|
377
|
+
(uint256 releaseRound, uint256 amount, uint256 shareClaimed) =
|
|
378
|
+
distributor.vestingDataOf(address(hook), 1, IERC20(address(rewardToken)), 0);
|
|
379
|
+
assertEq(releaseRound, VESTING_ROUNDS); // round 0 + vestingRounds.
|
|
380
|
+
assertEq(amount, 250 ether);
|
|
381
|
+
assertEq(shareClaimed, 0);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function test_latestVestedIndexOf() public {
|
|
385
|
+
_fundHook(1000 ether);
|
|
386
|
+
_beginVestingBoth();
|
|
387
|
+
|
|
388
|
+
assertEq(distributor.latestVestedIndexOf(address(hook), 1, IERC20(address(rewardToken))), 0);
|
|
389
|
+
|
|
390
|
+
// Collect fully -> latestVestedIndex should advance.
|
|
391
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
392
|
+
vm.prank(alice);
|
|
393
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
394
|
+
tokenIds[0] = 1;
|
|
395
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
396
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
397
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
398
|
+
|
|
399
|
+
assertEq(distributor.latestVestedIndexOf(address(hook), 1, IERC20(address(rewardToken))), 1);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function test_balanceOf() public {
|
|
403
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), 0);
|
|
404
|
+
|
|
405
|
+
_fundHook(1000 ether);
|
|
406
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), 1000 ether);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// =====================================================================
|
|
410
|
+
// fund
|
|
411
|
+
// =====================================================================
|
|
412
|
+
|
|
413
|
+
function test_fund() public {
|
|
414
|
+
rewardToken.mint(address(this), 500 ether);
|
|
415
|
+
rewardToken.approve(address(distributor), 500 ether);
|
|
416
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), 500 ether);
|
|
417
|
+
|
|
418
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), 500 ether);
|
|
419
|
+
assertEq(rewardToken.balanceOf(address(distributor)), 500 ether);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/// @notice fund() with native ETH credits hook balance.
|
|
423
|
+
function test_fund_nativeETH() public {
|
|
424
|
+
vm.deal(address(this), 10 ether);
|
|
425
|
+
|
|
426
|
+
IERC20 nativeToken = IERC20(address(0x000000000000000000000000000000000000EEEe));
|
|
427
|
+
distributor.fund{value: 10 ether}(address(hook), nativeToken, 0);
|
|
428
|
+
|
|
429
|
+
assertEq(distributor.balanceOf(address(hook), nativeToken), 10 ether);
|
|
430
|
+
assertEq(address(distributor).balance, 10 ether);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/// @notice Native ETH rewards can be vested and collected end-to-end.
|
|
434
|
+
function test_nativeETH_vestAndCollect() public {
|
|
435
|
+
vm.deal(address(this), 100 ether);
|
|
436
|
+
|
|
437
|
+
IERC20 nativeToken = IERC20(address(0x000000000000000000000000000000000000EEEe));
|
|
438
|
+
distributor.fund{value: 100 ether}(address(hook), nativeToken, 0);
|
|
439
|
+
|
|
440
|
+
// Begin vesting for token 1 (alice, 25% share = 25 ETH).
|
|
441
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
442
|
+
tokenIds[0] = 1;
|
|
443
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
444
|
+
tokens[0] = nativeToken;
|
|
445
|
+
|
|
446
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
447
|
+
assertEq(distributor.claimedFor(address(hook), 1, nativeToken), 25 ether);
|
|
448
|
+
|
|
449
|
+
// Advance past full vesting.
|
|
450
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
451
|
+
|
|
452
|
+
// Alice collects.
|
|
453
|
+
uint256 aliceBalBefore = alice.balance;
|
|
454
|
+
vm.prank(alice);
|
|
455
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
456
|
+
assertEq(alice.balance - aliceBalBefore, 25 ether);
|
|
457
|
+
|
|
458
|
+
// Distributor's tracked balance decreased.
|
|
459
|
+
assertEq(distributor.balanceOf(address(hook), nativeToken), 75 ether);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// =====================================================================
|
|
463
|
+
// beginVesting
|
|
464
|
+
// =====================================================================
|
|
465
|
+
|
|
466
|
+
function test_beginVesting_exactAmounts() public {
|
|
467
|
+
_fundHook(1000 ether);
|
|
468
|
+
_beginVestingBoth();
|
|
469
|
+
|
|
470
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 750 ether);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function test_beginVesting_singleToken() public {
|
|
474
|
+
_fundHook(1000 ether);
|
|
475
|
+
|
|
476
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
477
|
+
tokenIds[0] = 1;
|
|
478
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
479
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
480
|
+
|
|
481
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
482
|
+
|
|
483
|
+
// Only token 1 vesting: 250 ether.
|
|
484
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 250 ether);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function test_beginVesting_alreadyVesting_reverts() public {
|
|
488
|
+
_fundHook(1000 ether);
|
|
489
|
+
|
|
490
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
491
|
+
tokenIds[0] = 1;
|
|
492
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
493
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
494
|
+
|
|
495
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
496
|
+
|
|
497
|
+
vm.expectRevert(JBDistributor.JBDistributor_AlreadyVesting.selector);
|
|
498
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function test_beginVesting_nextRound_succeeds() public {
|
|
502
|
+
_fundHook(1000 ether);
|
|
503
|
+
|
|
504
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
505
|
+
tokenIds[0] = 1;
|
|
506
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
507
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
508
|
+
|
|
509
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
510
|
+
|
|
511
|
+
// Move to next round, add more funds, should succeed.
|
|
512
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
513
|
+
_fundHook(500 ether);
|
|
514
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
515
|
+
|
|
516
|
+
// Two vesting entries for token 1.
|
|
517
|
+
(,, uint256 shareClaimed) = distributor.vestingDataOf(address(hook), 1, IERC20(address(rewardToken)), 1);
|
|
518
|
+
assertEq(shareClaimed, 0);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function test_beginVesting_snapshotReuse() public {
|
|
522
|
+
_fundHook(1000 ether);
|
|
523
|
+
|
|
524
|
+
// First call creates snapshot.
|
|
525
|
+
uint256[] memory tokenIds1 = new uint256[](1);
|
|
526
|
+
tokenIds1[0] = 1;
|
|
527
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
528
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
529
|
+
|
|
530
|
+
distributor.beginVesting(address(hook), tokenIds1, tokens);
|
|
531
|
+
|
|
532
|
+
// Mint more tokens — but snapshot already taken for this round.
|
|
533
|
+
_fundHook(500 ether);
|
|
534
|
+
|
|
535
|
+
// Second call in same round with different tokenId should reuse snapshot.
|
|
536
|
+
uint256[] memory tokenIds2 = new uint256[](1);
|
|
537
|
+
tokenIds2[0] = 2;
|
|
538
|
+
|
|
539
|
+
distributor.beginVesting(address(hook), tokenIds2, tokens);
|
|
540
|
+
|
|
541
|
+
// Both should be based on original 1000 ether snapshot.
|
|
542
|
+
// Token 1: 250e18, Token 2: 500e18. Total = 750e18.
|
|
543
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 750 ether);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function test_beginVesting_emitsClaimedEvent() public {
|
|
547
|
+
_fundHook(1000 ether);
|
|
548
|
+
|
|
549
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
550
|
+
tokenIds[0] = 1;
|
|
551
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
552
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
553
|
+
|
|
554
|
+
vm.expectEmit(true, true, false, true);
|
|
555
|
+
emit IJBDistributor.Claimed(address(hook), 1, IERC20(address(rewardToken)), 250 ether, VESTING_ROUNDS);
|
|
556
|
+
|
|
557
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// =====================================================================
|
|
561
|
+
// collectVestedRewards -- exact value assertions
|
|
562
|
+
// =====================================================================
|
|
563
|
+
|
|
564
|
+
function test_collectVestedRewards_fullVesting_exactAmount() public {
|
|
565
|
+
_fundHook(1000 ether);
|
|
566
|
+
|
|
567
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
568
|
+
tokenIds[0] = 1;
|
|
569
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
570
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
571
|
+
|
|
572
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
573
|
+
|
|
574
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
575
|
+
|
|
576
|
+
vm.prank(alice);
|
|
577
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
578
|
+
|
|
579
|
+
assertEq(rewardToken.balanceOf(alice), 250 ether);
|
|
580
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 0);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function test_collectVestedRewards_partialVesting_exactAmounts() public {
|
|
584
|
+
_fundHook(1000 ether);
|
|
585
|
+
|
|
586
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
587
|
+
tokenIds[0] = 1;
|
|
588
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
589
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
590
|
+
|
|
591
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
592
|
+
|
|
593
|
+
// Roll forward 2 of 4 rounds (50%).
|
|
594
|
+
vm.roll(block.number + ROUND_DURATION * 2);
|
|
595
|
+
|
|
596
|
+
vm.prank(alice);
|
|
597
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
598
|
+
|
|
599
|
+
// lockedShare = (4-2)*100000/4 = 50000. claimAmount = mulDiv(250e18, 50000, 100000) = 125e18.
|
|
600
|
+
assertEq(rewardToken.balanceOf(alice), 125 ether);
|
|
601
|
+
|
|
602
|
+
// Roll forward remaining 2 rounds.
|
|
603
|
+
vm.roll(block.number + ROUND_DURATION * 2);
|
|
604
|
+
|
|
605
|
+
vm.prank(alice);
|
|
606
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
607
|
+
|
|
608
|
+
// After partial: shareClaimed = 50000, lockedShare = 0.
|
|
609
|
+
// claimAmount = mulDiv(250e18, 100000-50000, 100000) = 125e18.
|
|
610
|
+
assertEq(rewardToken.balanceOf(alice), 250 ether);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function test_collectVestedRewards_quarterVesting() public {
|
|
614
|
+
_fundHook(1000 ether);
|
|
615
|
+
|
|
616
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
617
|
+
tokenIds[0] = 1;
|
|
618
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
619
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
620
|
+
|
|
621
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
622
|
+
|
|
623
|
+
// Roll forward 1 of 4 rounds (25%).
|
|
624
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
625
|
+
|
|
626
|
+
vm.prank(alice);
|
|
627
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
628
|
+
|
|
629
|
+
// lockedShare = (4-1)*100000/4 = 75000. claimAmount = mulDiv(250e18, 25000, 100000) = 62.5e18.
|
|
630
|
+
assertEq(rewardToken.balanceOf(alice), 62.5 ether);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function test_collectVestedRewards_threeQuarterVesting() public {
|
|
634
|
+
_fundHook(1000 ether);
|
|
635
|
+
|
|
636
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
637
|
+
tokenIds[0] = 1;
|
|
638
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
639
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
640
|
+
|
|
641
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
642
|
+
|
|
643
|
+
// Roll forward 3 of 4 rounds (75%).
|
|
644
|
+
vm.roll(block.number + ROUND_DURATION * 3);
|
|
645
|
+
|
|
646
|
+
vm.prank(alice);
|
|
647
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
648
|
+
|
|
649
|
+
// lockedShare = (4-3)*100000/4 = 25000. claimAmount = mulDiv(250e18, 75000, 100000) = 187.5e18.
|
|
650
|
+
assertEq(rewardToken.balanceOf(alice), 187.5 ether);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function test_collectVestedRewards_noAccess_reverts() public {
|
|
654
|
+
_fundHook(1000 ether);
|
|
655
|
+
|
|
656
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
657
|
+
tokenIds[0] = 1;
|
|
658
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
659
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
660
|
+
|
|
661
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
662
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
663
|
+
|
|
664
|
+
vm.prank(bob);
|
|
665
|
+
vm.expectRevert(JBDistributor.JBDistributor_NoAccess.selector);
|
|
666
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, bob);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function test_collectVestedRewards_nothingToCollect() public {
|
|
670
|
+
// No vesting started -- collecting should succeed with zero transfer.
|
|
671
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
672
|
+
tokenIds[0] = 1;
|
|
673
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
674
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
675
|
+
|
|
676
|
+
vm.prank(alice);
|
|
677
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
678
|
+
|
|
679
|
+
assertEq(rewardToken.balanceOf(alice), 0);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function test_collectVestedRewards_emitsCollectedEvent() public {
|
|
683
|
+
_fundHook(1000 ether);
|
|
684
|
+
|
|
685
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
686
|
+
tokenIds[0] = 1;
|
|
687
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
688
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
689
|
+
|
|
690
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
691
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
692
|
+
|
|
693
|
+
vm.expectEmit(true, true, false, true);
|
|
694
|
+
emit IJBDistributor.Collected(address(hook), 1, IERC20(address(rewardToken)), 250 ether, VESTING_ROUNDS);
|
|
695
|
+
|
|
696
|
+
vm.prank(alice);
|
|
697
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function test_collectVestedRewards_differentBeneficiary() public {
|
|
701
|
+
_fundHook(1000 ether);
|
|
702
|
+
|
|
703
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
704
|
+
tokenIds[0] = 1;
|
|
705
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
706
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
707
|
+
|
|
708
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
709
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
710
|
+
|
|
711
|
+
// Alice sends to charlie.
|
|
712
|
+
vm.prank(alice);
|
|
713
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, charlie);
|
|
714
|
+
|
|
715
|
+
assertEq(rewardToken.balanceOf(charlie), 250 ether);
|
|
716
|
+
assertEq(rewardToken.balanceOf(alice), 0);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// =====================================================================
|
|
720
|
+
// releaseForfeitedRewards
|
|
721
|
+
// =====================================================================
|
|
722
|
+
|
|
723
|
+
function test_releaseForfeitedRewards_fullVesting() public {
|
|
724
|
+
_fundHook(1000 ether);
|
|
725
|
+
|
|
726
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
727
|
+
tokenIds[0] = 1;
|
|
728
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
729
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
730
|
+
|
|
731
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
732
|
+
|
|
733
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 250 ether);
|
|
734
|
+
|
|
735
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
736
|
+
hook.burn(1);
|
|
737
|
+
|
|
738
|
+
distributor.releaseForfeitedRewards(address(hook), tokenIds, tokens, alice);
|
|
739
|
+
|
|
740
|
+
// Vesting amount decremented, tokens NOT sent.
|
|
741
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 0);
|
|
742
|
+
assertEq(rewardToken.balanceOf(alice), 0);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function test_releaseForfeitedRewards_partialVesting() public {
|
|
746
|
+
_fundHook(1000 ether);
|
|
747
|
+
|
|
748
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
749
|
+
tokenIds[0] = 1;
|
|
750
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
751
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
752
|
+
|
|
753
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
754
|
+
|
|
755
|
+
// Roll forward 2 of 4 rounds.
|
|
756
|
+
vm.roll(block.number + ROUND_DURATION * 2);
|
|
757
|
+
hook.burn(1);
|
|
758
|
+
|
|
759
|
+
distributor.releaseForfeitedRewards(address(hook), tokenIds, tokens, alice);
|
|
760
|
+
|
|
761
|
+
// lockedShare = 50000. claimAmount = mulDiv(250e18, 50000, 100000) = 125e18.
|
|
762
|
+
// totalVestingAmountOf decreased by 125e18.
|
|
763
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 125 ether);
|
|
764
|
+
assertEq(rewardToken.balanceOf(alice), 0);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/// @notice Burned token IDs are skipped during beginVesting — no overbooking.
|
|
768
|
+
function test_burnedTokenSkippedDuringVesting() public {
|
|
769
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
770
|
+
tokenIds[0] = 1;
|
|
771
|
+
tokenIds[1] = 2;
|
|
772
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
773
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
774
|
+
|
|
775
|
+
hook.burn(2);
|
|
776
|
+
store.setBurnedFor(2, 1);
|
|
777
|
+
|
|
778
|
+
_fundHook(1000 ether);
|
|
779
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
780
|
+
|
|
781
|
+
// Only token 1 should vest. Total stake = (2)*100 = 200 (burned excluded).
|
|
782
|
+
// Token 1 stake = 100 → 100/200 = 50% of 1000 = 500 ether.
|
|
783
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), 1000 ether);
|
|
784
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 500 ether);
|
|
785
|
+
|
|
786
|
+
// Next round should work fine — no underflow.
|
|
787
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
788
|
+
_fundHook(1 ether);
|
|
789
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function test_releaseForfeitedRewards_notBurned_reverts() public {
|
|
793
|
+
_fundHook(1000 ether);
|
|
794
|
+
|
|
795
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
796
|
+
tokenIds[0] = 1;
|
|
797
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
798
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
799
|
+
|
|
800
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
801
|
+
|
|
802
|
+
vm.expectRevert(JBDistributor.JBDistributor_NoAccess.selector);
|
|
803
|
+
distributor.releaseForfeitedRewards(address(hook), tokenIds, tokens, alice);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// =====================================================================
|
|
807
|
+
// Burned NFTs excluded from total stake
|
|
808
|
+
// =====================================================================
|
|
809
|
+
|
|
810
|
+
function test_burnedNftsExcludedFromTotalStake_exactAmounts() public {
|
|
811
|
+
// Burn 1 NFT from tier 1. Held: tier1 = 2-1=1, tier2 = 1. Total = 1*100 + 1*200 = 300.
|
|
812
|
+
store.setBurnedFor(1, 1);
|
|
813
|
+
|
|
814
|
+
_fundHook(900 ether);
|
|
815
|
+
_beginVestingBoth();
|
|
816
|
+
|
|
817
|
+
// Token 1: mulDiv(900e18, 100, 300) = 300e18.
|
|
818
|
+
// Token 2: mulDiv(900e18, 200, 300) = 600e18.
|
|
819
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 900 ether);
|
|
820
|
+
assertEq(distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken))), 300 ether);
|
|
821
|
+
assertEq(distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken))), 600 ether);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// =====================================================================
|
|
825
|
+
// Multiple reward tokens
|
|
826
|
+
// =====================================================================
|
|
827
|
+
|
|
828
|
+
function test_multipleRewardTokens() public {
|
|
829
|
+
_fundHook(1000 ether);
|
|
830
|
+
// Fund with second token too.
|
|
831
|
+
rewardToken2.mint(address(this), 500 ether);
|
|
832
|
+
rewardToken2.approve(address(distributor), 500 ether);
|
|
833
|
+
distributor.fund(address(hook), IERC20(address(rewardToken2)), 500 ether);
|
|
834
|
+
|
|
835
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
836
|
+
tokenIds[0] = 1;
|
|
837
|
+
IERC20[] memory tokens = new IERC20[](2);
|
|
838
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
839
|
+
tokens[1] = IERC20(address(rewardToken2));
|
|
840
|
+
|
|
841
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
842
|
+
|
|
843
|
+
// Token 1 share = 100/400 = 25%.
|
|
844
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 250 ether);
|
|
845
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken2))), 125 ether);
|
|
846
|
+
|
|
847
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
848
|
+
|
|
849
|
+
vm.prank(alice);
|
|
850
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
851
|
+
|
|
852
|
+
assertEq(rewardToken.balanceOf(alice), 250 ether);
|
|
853
|
+
assertEq(rewardToken2.balanceOf(alice), 125 ether);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// =====================================================================
|
|
857
|
+
// Multiple rounds
|
|
858
|
+
// =====================================================================
|
|
859
|
+
|
|
860
|
+
function test_multipleRounds_exactAmounts() public {
|
|
861
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
862
|
+
tokenIds[0] = 1;
|
|
863
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
864
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
865
|
+
|
|
866
|
+
// Round 0: fund 1000 and vest.
|
|
867
|
+
_fundHook(1000 ether);
|
|
868
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
869
|
+
|
|
870
|
+
// Token 1 gets 250e18 from round 0.
|
|
871
|
+
|
|
872
|
+
// Move to round 1: fund 400 more and vest.
|
|
873
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
874
|
+
_fundHook(400 ether);
|
|
875
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
876
|
+
|
|
877
|
+
// Snapshot at round 1: balance = 1400 ether (1000 funded + 400 new), vestingAmount = 250e18.
|
|
878
|
+
// distributable = 1400e18 - 250e18 = 1150e18.
|
|
879
|
+
// Token 1 gets mulDiv(1150e18, 100, 400) = 287.5e18 from round 1.
|
|
880
|
+
|
|
881
|
+
// Move past all vesting.
|
|
882
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
883
|
+
|
|
884
|
+
vm.prank(alice);
|
|
885
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
886
|
+
|
|
887
|
+
assertEq(rewardToken.balanceOf(alice), 250 ether + 287.5 ether);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// =====================================================================
|
|
891
|
+
// Snapshot edge cases
|
|
892
|
+
// =====================================================================
|
|
893
|
+
|
|
894
|
+
function test_snapshotCreatedOnlyOnce() public {
|
|
895
|
+
_fundHook(1000 ether);
|
|
896
|
+
|
|
897
|
+
uint256[] memory tokenIds1 = new uint256[](1);
|
|
898
|
+
tokenIds1[0] = 1;
|
|
899
|
+
uint256[] memory tokenIds2 = new uint256[](1);
|
|
900
|
+
tokenIds2[0] = 2;
|
|
901
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
902
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
903
|
+
|
|
904
|
+
// First vesting creates snapshot.
|
|
905
|
+
distributor.beginVesting(address(hook), tokenIds1, tokens);
|
|
906
|
+
|
|
907
|
+
// Add more funds.
|
|
908
|
+
_fundHook(500 ether);
|
|
909
|
+
|
|
910
|
+
// Second vesting in same round reuses snapshot.
|
|
911
|
+
distributor.beginVesting(address(hook), tokenIds2, tokens);
|
|
912
|
+
|
|
913
|
+
// Both should be based on original 1000 ether snapshot.
|
|
914
|
+
// Token 1: 250e18, Token 2: 500e18. Total = 750e18.
|
|
915
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 750 ether);
|
|
916
|
+
|
|
917
|
+
JBTokenSnapshotData memory snapshot =
|
|
918
|
+
distributor.snapshotAtRoundOf(address(hook), IERC20(address(rewardToken)), 0);
|
|
919
|
+
assertEq(snapshot.balance, 1000 ether);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// =====================================================================
|
|
923
|
+
// Conservation invariant
|
|
924
|
+
// =====================================================================
|
|
925
|
+
|
|
926
|
+
function test_invariant_totalVestingNeverExceedsBalance() public {
|
|
927
|
+
_fundHook(1000 ether);
|
|
928
|
+
_beginVestingBoth();
|
|
929
|
+
|
|
930
|
+
// Vesting (750) <= balance (1000).
|
|
931
|
+
assertLe(
|
|
932
|
+
distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))),
|
|
933
|
+
distributor.balanceOf(address(hook), IERC20(address(rewardToken)))
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
// After partial collect, still holds.
|
|
937
|
+
vm.roll(block.number + ROUND_DURATION * 2);
|
|
938
|
+
vm.prank(alice);
|
|
939
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
940
|
+
tokenIds[0] = 1;
|
|
941
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
942
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
943
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
944
|
+
|
|
945
|
+
assertLe(
|
|
946
|
+
distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))),
|
|
947
|
+
distributor.balanceOf(address(hook), IERC20(address(rewardToken)))
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function test_invariant_fullCollectDrainsExactAmount() public {
|
|
952
|
+
_fundHook(1000 ether);
|
|
953
|
+
_beginVestingBoth();
|
|
954
|
+
|
|
955
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
956
|
+
|
|
957
|
+
// Collect both.
|
|
958
|
+
uint256[] memory aliceIds = new uint256[](1);
|
|
959
|
+
aliceIds[0] = 1;
|
|
960
|
+
uint256[] memory bobIds = new uint256[](1);
|
|
961
|
+
bobIds[0] = 2;
|
|
962
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
963
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
964
|
+
|
|
965
|
+
vm.prank(alice);
|
|
966
|
+
distributor.collectVestedRewards(address(hook), aliceIds, tokens, alice);
|
|
967
|
+
vm.prank(bob);
|
|
968
|
+
distributor.collectVestedRewards(address(hook), bobIds, tokens, bob);
|
|
969
|
+
|
|
970
|
+
assertEq(rewardToken.balanceOf(alice), 250 ether);
|
|
971
|
+
assertEq(rewardToken.balanceOf(bob), 500 ether);
|
|
972
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 0);
|
|
973
|
+
// 250 ether remains undistributed (remaining 25% of stake not claimed by any token).
|
|
974
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), 250 ether);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// =====================================================================
|
|
978
|
+
// Fuzz tests
|
|
979
|
+
// =====================================================================
|
|
980
|
+
|
|
981
|
+
function testFuzz_beginVesting_proportional(uint128 fundAmount) public {
|
|
982
|
+
vm.assume(fundAmount > 1 ether);
|
|
983
|
+
vm.assume(fundAmount < type(uint128).max);
|
|
984
|
+
|
|
985
|
+
_fundHook(fundAmount);
|
|
986
|
+
_beginVestingBoth();
|
|
987
|
+
|
|
988
|
+
uint256 totalVesting = distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken)));
|
|
989
|
+
uint256 claimed1 = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
990
|
+
uint256 claimed2 = distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken)));
|
|
991
|
+
|
|
992
|
+
// Token 1 is 25% of total stake, Token 2 is 50%.
|
|
993
|
+
// Allow 1 wei rounding tolerance per operation.
|
|
994
|
+
assertEq(claimed1 + claimed2, totalVesting);
|
|
995
|
+
assertApproxEqAbs(claimed1 * 2, claimed2, 2);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function testFuzz_collectVestedRewards_linearVesting(uint128 fundAmount, uint8 roundsForward) public {
|
|
999
|
+
vm.assume(fundAmount > 1 ether);
|
|
1000
|
+
vm.assume(fundAmount < type(uint128).max);
|
|
1001
|
+
// Cap roundsForward to vestingRounds to avoid going past release.
|
|
1002
|
+
uint256 rounds = bound(roundsForward, 1, VESTING_ROUNDS);
|
|
1003
|
+
|
|
1004
|
+
_fundHook(fundAmount);
|
|
1005
|
+
|
|
1006
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1007
|
+
tokenIds[0] = 1;
|
|
1008
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1009
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1010
|
+
|
|
1011
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1012
|
+
|
|
1013
|
+
uint256 totalClaimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1014
|
+
|
|
1015
|
+
vm.roll(block.number + ROUND_DURATION * rounds);
|
|
1016
|
+
|
|
1017
|
+
vm.prank(alice);
|
|
1018
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1019
|
+
|
|
1020
|
+
uint256 received = rewardToken.balanceOf(alice);
|
|
1021
|
+
uint256 expectedFraction = totalClaimed * rounds / VESTING_ROUNDS;
|
|
1022
|
+
|
|
1023
|
+
// Allow small rounding tolerance from mulDiv.
|
|
1024
|
+
assertApproxEqAbs(received, expectedFraction, 2);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function testFuzz_collectVestedRewards_fullVesting_exactRecovery(uint128 fundAmount) public {
|
|
1028
|
+
vm.assume(fundAmount > 1 ether);
|
|
1029
|
+
vm.assume(fundAmount < type(uint128).max);
|
|
1030
|
+
|
|
1031
|
+
_fundHook(fundAmount);
|
|
1032
|
+
|
|
1033
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1034
|
+
tokenIds[0] = 1;
|
|
1035
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1036
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1037
|
+
|
|
1038
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1039
|
+
|
|
1040
|
+
uint256 totalClaimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1041
|
+
|
|
1042
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1043
|
+
|
|
1044
|
+
vm.prank(alice);
|
|
1045
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1046
|
+
|
|
1047
|
+
// Full vesting should recover exact claimed amount.
|
|
1048
|
+
assertEq(rewardToken.balanceOf(alice), totalClaimed);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function testFuzz_collectableEqualsCollected(uint128 fundAmount) public {
|
|
1052
|
+
vm.assume(fundAmount > 1 ether);
|
|
1053
|
+
vm.assume(fundAmount < type(uint128).max);
|
|
1054
|
+
|
|
1055
|
+
_fundHook(fundAmount);
|
|
1056
|
+
|
|
1057
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1058
|
+
tokenIds[0] = 1;
|
|
1059
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1060
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1061
|
+
|
|
1062
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1063
|
+
|
|
1064
|
+
vm.roll(block.number + ROUND_DURATION * 2);
|
|
1065
|
+
|
|
1066
|
+
// Check collectableFor equals what we actually collect.
|
|
1067
|
+
uint256 collectable = distributor.collectableFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1068
|
+
|
|
1069
|
+
vm.prank(alice);
|
|
1070
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1071
|
+
|
|
1072
|
+
assertEq(rewardToken.balanceOf(alice), collectable);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function testFuzz_multiplePartialCollects_sumToFull(uint128 fundAmount) public {
|
|
1076
|
+
vm.assume(fundAmount > 4 ether);
|
|
1077
|
+
vm.assume(fundAmount < type(uint128).max);
|
|
1078
|
+
|
|
1079
|
+
_fundHook(fundAmount);
|
|
1080
|
+
|
|
1081
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1082
|
+
tokenIds[0] = 1;
|
|
1083
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1084
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1085
|
+
|
|
1086
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1087
|
+
|
|
1088
|
+
uint256 totalClaimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1089
|
+
|
|
1090
|
+
// Collect at each round.
|
|
1091
|
+
for (uint256 r = 1; r <= VESTING_ROUNDS; r++) {
|
|
1092
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1093
|
+
vm.prank(alice);
|
|
1094
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// Sum of all partial collects should equal total claimed (within rounding).
|
|
1098
|
+
assertApproxEqAbs(rewardToken.balanceOf(alice), totalClaimed, VESTING_ROUNDS);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// =====================================================================
|
|
1102
|
+
// Tier with zero initialSupply (skipped in _totalStake)
|
|
1103
|
+
// =====================================================================
|
|
1104
|
+
|
|
1105
|
+
function test_tierWithZeroSupply_skipped() public {
|
|
1106
|
+
// Add a tier 3 with zero supply.
|
|
1107
|
+
JB721TierFlags memory flags;
|
|
1108
|
+
store.setMaxTierIdOf(3);
|
|
1109
|
+
store.setTier(
|
|
1110
|
+
3,
|
|
1111
|
+
JB721Tier({
|
|
1112
|
+
id: 3,
|
|
1113
|
+
price: 5 ether,
|
|
1114
|
+
remainingSupply: 0,
|
|
1115
|
+
initialSupply: 0,
|
|
1116
|
+
votingUnits: 1000,
|
|
1117
|
+
reserveFrequency: 0,
|
|
1118
|
+
reserveBeneficiary: address(0),
|
|
1119
|
+
encodedIPFSUri: bytes32(0),
|
|
1120
|
+
category: 0,
|
|
1121
|
+
discountPercent: 0,
|
|
1122
|
+
flags: flags,
|
|
1123
|
+
splitPercent: 0,
|
|
1124
|
+
resolvedUri: ""
|
|
1125
|
+
})
|
|
1126
|
+
);
|
|
1127
|
+
|
|
1128
|
+
// Should not affect total stake (still 400).
|
|
1129
|
+
_fundHook(1000 ether);
|
|
1130
|
+
_beginVestingBoth();
|
|
1131
|
+
|
|
1132
|
+
assertEq(distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken))), 250 ether);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// =====================================================================
|
|
1136
|
+
// Empty arrays
|
|
1137
|
+
// =====================================================================
|
|
1138
|
+
|
|
1139
|
+
function test_beginVesting_emptyTokenIds() public {
|
|
1140
|
+
_fundHook(1000 ether);
|
|
1141
|
+
|
|
1142
|
+
uint256[] memory tokenIds = new uint256[](0);
|
|
1143
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1144
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1145
|
+
|
|
1146
|
+
// Should succeed with no-op for inner loop.
|
|
1147
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1148
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 0);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function test_beginVesting_emptyTokens() public {
|
|
1152
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1153
|
+
tokenIds[0] = 1;
|
|
1154
|
+
IERC20[] memory tokens = new IERC20[](0);
|
|
1155
|
+
|
|
1156
|
+
// Should succeed with no-op for outer loop.
|
|
1157
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function test_collectVestedRewards_emptyArrays() public {
|
|
1161
|
+
uint256[] memory tokenIds = new uint256[](0);
|
|
1162
|
+
IERC20[] memory tokens = new IERC20[](0);
|
|
1163
|
+
|
|
1164
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// =====================================================================
|
|
1168
|
+
// Double-collect is no-op
|
|
1169
|
+
// =====================================================================
|
|
1170
|
+
|
|
1171
|
+
function test_doubleCollect_isNoop() public {
|
|
1172
|
+
_fundHook(1000 ether);
|
|
1173
|
+
|
|
1174
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1175
|
+
tokenIds[0] = 1;
|
|
1176
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1177
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1178
|
+
|
|
1179
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1180
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1181
|
+
|
|
1182
|
+
vm.prank(alice);
|
|
1183
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1184
|
+
assertEq(rewardToken.balanceOf(alice), 250 ether);
|
|
1185
|
+
|
|
1186
|
+
// Second collect should give nothing.
|
|
1187
|
+
vm.prank(alice);
|
|
1188
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1189
|
+
assertEq(rewardToken.balanceOf(alice), 250 ether);
|
|
1190
|
+
|
|
1191
|
+
// latestVestedIndex should have advanced past all entries.
|
|
1192
|
+
assertEq(distributor.latestVestedIndexOf(address(hook), 1, IERC20(address(rewardToken))), 1);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// =====================================================================
|
|
1196
|
+
// Ownership transfer mid-vesting
|
|
1197
|
+
// =====================================================================
|
|
1198
|
+
|
|
1199
|
+
function test_ownershipTransfer_newOwnerCollects() public {
|
|
1200
|
+
_fundHook(1000 ether);
|
|
1201
|
+
|
|
1202
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1203
|
+
tokenIds[0] = 1;
|
|
1204
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1205
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1206
|
+
|
|
1207
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1208
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1209
|
+
|
|
1210
|
+
// Transfer token 1 from alice to charlie.
|
|
1211
|
+
hook.setOwner(1, charlie);
|
|
1212
|
+
|
|
1213
|
+
// Alice can no longer collect.
|
|
1214
|
+
vm.prank(alice);
|
|
1215
|
+
vm.expectRevert(JBDistributor.JBDistributor_NoAccess.selector);
|
|
1216
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1217
|
+
|
|
1218
|
+
// Charlie can collect.
|
|
1219
|
+
vm.prank(charlie);
|
|
1220
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, charlie);
|
|
1221
|
+
assertEq(rewardToken.balanceOf(charlie), 250 ether);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// =====================================================================
|
|
1225
|
+
// Three stacked vesting entries -- loop behavior
|
|
1226
|
+
// =====================================================================
|
|
1227
|
+
|
|
1228
|
+
function test_threeVestingEntries_collectAllAtOnce() public {
|
|
1229
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1230
|
+
tokenIds[0] = 1;
|
|
1231
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1232
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1233
|
+
|
|
1234
|
+
// Round 0: vest
|
|
1235
|
+
_fundHook(1000 ether);
|
|
1236
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1237
|
+
|
|
1238
|
+
// Round 1: vest
|
|
1239
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1240
|
+
_fundHook(400 ether);
|
|
1241
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1242
|
+
|
|
1243
|
+
// Round 2: vest
|
|
1244
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1245
|
+
_fundHook(200 ether);
|
|
1246
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1247
|
+
|
|
1248
|
+
// Roll past all vesting (round 2 + 4 = round 6 releases last entry).
|
|
1249
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1250
|
+
|
|
1251
|
+
uint256 totalClaimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1252
|
+
|
|
1253
|
+
vm.prank(alice);
|
|
1254
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1255
|
+
|
|
1256
|
+
// Should collect all three entries.
|
|
1257
|
+
assertEq(rewardToken.balanceOf(alice), totalClaimed);
|
|
1258
|
+
assertEq(distributor.latestVestedIndexOf(address(hook), 1, IERC20(address(rewardToken))), 3);
|
|
1259
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 0);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function test_threeVestingEntries_partialCollect_skipsLockedEntries() public {
|
|
1263
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1264
|
+
tokenIds[0] = 1;
|
|
1265
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1266
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1267
|
+
|
|
1268
|
+
// Round 0: vest -> releaseRound = 4
|
|
1269
|
+
_fundHook(1000 ether);
|
|
1270
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1271
|
+
uint256 claimed0 = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1272
|
+
|
|
1273
|
+
// Round 1: vest -> releaseRound = 5
|
|
1274
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1275
|
+
_fundHook(400 ether);
|
|
1276
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1277
|
+
|
|
1278
|
+
// Round 2: vest -> releaseRound = 6
|
|
1279
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1280
|
+
_fundHook(200 ether);
|
|
1281
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1282
|
+
|
|
1283
|
+
// Record total claimed before any collection.
|
|
1284
|
+
uint256 totalClaimedBefore = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1285
|
+
|
|
1286
|
+
// Collect at round 4: entry[0] fully vested, entry[1] partially, entry[2] more locked.
|
|
1287
|
+
vm.roll(block.number + ROUND_DURATION * 2); // now at round 4
|
|
1288
|
+
|
|
1289
|
+
vm.prank(alice);
|
|
1290
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1291
|
+
|
|
1292
|
+
// Entry[0] fully collected. Due to loop behavior, entry[2] is skipped this round
|
|
1293
|
+
// but will be caught in a future collect. No funds are lost.
|
|
1294
|
+
uint256 firstCollect = rewardToken.balanceOf(alice);
|
|
1295
|
+
assertGt(firstCollect, 0);
|
|
1296
|
+
// At minimum, entry[0]'s full amount should be collected.
|
|
1297
|
+
assertGe(firstCollect, claimed0);
|
|
1298
|
+
|
|
1299
|
+
// Collect at round 5: entry[1] fully vests, entry[2] partially.
|
|
1300
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1301
|
+
vm.prank(alice);
|
|
1302
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1303
|
+
|
|
1304
|
+
uint256 secondCollect = rewardToken.balanceOf(alice);
|
|
1305
|
+
assertGt(secondCollect, firstCollect);
|
|
1306
|
+
|
|
1307
|
+
// Collect at round 6: entry[2] fully vests.
|
|
1308
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1309
|
+
vm.prank(alice);
|
|
1310
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1311
|
+
|
|
1312
|
+
// All entries should have been fully collected across the three collect calls.
|
|
1313
|
+
// claimedFor returns 0 now because latestVestedIndex has advanced past all entries.
|
|
1314
|
+
assertEq(distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken))), 0);
|
|
1315
|
+
assertEq(distributor.latestVestedIndexOf(address(hook), 1, IERC20(address(rewardToken))), 3);
|
|
1316
|
+
// Total received across all collects should equal what was originally claimed.
|
|
1317
|
+
assertEq(rewardToken.balanceOf(alice), totalClaimedBefore);
|
|
1318
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 0);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/// @notice collectVestedRewards now matches collectableFor even with multiple stacked entries.
|
|
1322
|
+
function test_collectMatchesPreviewWithStackedEntries() public {
|
|
1323
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1324
|
+
tokenIds[0] = 1;
|
|
1325
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1326
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1327
|
+
|
|
1328
|
+
_fundHook(1000 ether);
|
|
1329
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1330
|
+
|
|
1331
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1332
|
+
_fundHook(400 ether);
|
|
1333
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1334
|
+
|
|
1335
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1336
|
+
_fundHook(200 ether);
|
|
1337
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1338
|
+
|
|
1339
|
+
vm.roll(block.number + ROUND_DURATION * 2); // now at round 4
|
|
1340
|
+
|
|
1341
|
+
uint256 preview = distributor.collectableFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1342
|
+
|
|
1343
|
+
vm.prank(alice);
|
|
1344
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1345
|
+
|
|
1346
|
+
uint256 actualCollected = rewardToken.balanceOf(alice);
|
|
1347
|
+
|
|
1348
|
+
// After fix: actual collection matches the preview.
|
|
1349
|
+
assertEq(actualCollected, preview);
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// =====================================================================
|
|
1353
|
+
// Forfeiture after partial collect
|
|
1354
|
+
// =====================================================================
|
|
1355
|
+
|
|
1356
|
+
function test_forfeitAfterPartialCollect() public {
|
|
1357
|
+
_fundHook(1000 ether);
|
|
1358
|
+
|
|
1359
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1360
|
+
tokenIds[0] = 1;
|
|
1361
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1362
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1363
|
+
|
|
1364
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1365
|
+
|
|
1366
|
+
// Partial collect at 50%.
|
|
1367
|
+
vm.roll(block.number + ROUND_DURATION * 2);
|
|
1368
|
+
vm.prank(alice);
|
|
1369
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1370
|
+
assertEq(rewardToken.balanceOf(alice), 125 ether);
|
|
1371
|
+
|
|
1372
|
+
uint256 vestingAfterPartial = distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken)));
|
|
1373
|
+
assertEq(vestingAfterPartial, 125 ether); // 250 - 125 = 125 remaining
|
|
1374
|
+
|
|
1375
|
+
// Burn and release remaining forfeited rewards.
|
|
1376
|
+
hook.burn(1);
|
|
1377
|
+
|
|
1378
|
+
vm.roll(block.number + ROUND_DURATION * 2);
|
|
1379
|
+
distributor.releaseForfeitedRewards(address(hook), tokenIds, tokens, address(0));
|
|
1380
|
+
|
|
1381
|
+
// All vesting should be released.
|
|
1382
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 0);
|
|
1383
|
+
// Alice keeps what she collected, no more sent.
|
|
1384
|
+
assertEq(rewardToken.balanceOf(alice), 125 ether);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// =====================================================================
|
|
1388
|
+
// Forfeited tokens return to distributable pool
|
|
1389
|
+
// =====================================================================
|
|
1390
|
+
|
|
1391
|
+
function test_forfeitedTokensReturnToPool() public {
|
|
1392
|
+
_fundHook(1000 ether);
|
|
1393
|
+
_beginVestingBoth();
|
|
1394
|
+
|
|
1395
|
+
// Token 1 vesting = 250, Token 2 vesting = 500. Total vesting = 750.
|
|
1396
|
+
// Balance = 1000, distributable = 250.
|
|
1397
|
+
|
|
1398
|
+
// Burn token 1 and forfeit after full vest.
|
|
1399
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1400
|
+
hook.burn(1);
|
|
1401
|
+
store.setBurnedFor(1, 1);
|
|
1402
|
+
|
|
1403
|
+
uint256[] memory burnedIds = new uint256[](1);
|
|
1404
|
+
burnedIds[0] = 1;
|
|
1405
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1406
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1407
|
+
|
|
1408
|
+
distributor.releaseForfeitedRewards(address(hook), burnedIds, tokens, address(0));
|
|
1409
|
+
|
|
1410
|
+
// After forfeiture: totalVesting = 500, balance should still be 1000 (forfeited returns to pool).
|
|
1411
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 500 ether);
|
|
1412
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), 1000 ether);
|
|
1413
|
+
// Distributable = 1000 - 500 = 500 (was 250 before forfeiture).
|
|
1414
|
+
|
|
1415
|
+
// Move to next round. The forfeited 250 is now distributable.
|
|
1416
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1417
|
+
|
|
1418
|
+
// Vest token 2 again (new round).
|
|
1419
|
+
uint256[] memory tokenIds2 = new uint256[](1);
|
|
1420
|
+
tokenIds2[0] = 2;
|
|
1421
|
+
distributor.beginVesting(address(hook), tokenIds2, tokens);
|
|
1422
|
+
|
|
1423
|
+
// Token 2 should get 200/200 = 100% of 500 distributable = 500 ether from the new round.
|
|
1424
|
+
// (only 1 tier with holders now: tier 2 with 1 held NFT)
|
|
1425
|
+
JBTokenSnapshotData memory snap =
|
|
1426
|
+
distributor.snapshotAtRoundOf(address(hook), IERC20(address(rewardToken)), distributor.currentRound());
|
|
1427
|
+
assertEq(snap.balance, 1000 ether);
|
|
1428
|
+
assertEq(snap.vestingAmount, 500 ether); // Bob's original vesting from round 0.
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// =====================================================================
|
|
1432
|
+
// Zero voting units tier
|
|
1433
|
+
// =====================================================================
|
|
1434
|
+
|
|
1435
|
+
function test_zeroVotingUnits_getsZeroShare() public {
|
|
1436
|
+
// Change tier 1 to have 0 voting units.
|
|
1437
|
+
JB721TierFlags memory flags;
|
|
1438
|
+
store.setTier(
|
|
1439
|
+
1,
|
|
1440
|
+
JB721Tier({
|
|
1441
|
+
id: 1,
|
|
1442
|
+
price: 1 ether,
|
|
1443
|
+
remainingSupply: 8,
|
|
1444
|
+
initialSupply: 10,
|
|
1445
|
+
votingUnits: 0, // zero!
|
|
1446
|
+
reserveFrequency: 0,
|
|
1447
|
+
reserveBeneficiary: address(0),
|
|
1448
|
+
encodedIPFSUri: bytes32(0),
|
|
1449
|
+
category: 0,
|
|
1450
|
+
discountPercent: 0,
|
|
1451
|
+
flags: flags,
|
|
1452
|
+
splitPercent: 0,
|
|
1453
|
+
resolvedUri: ""
|
|
1454
|
+
})
|
|
1455
|
+
);
|
|
1456
|
+
|
|
1457
|
+
_fundHook(1000 ether);
|
|
1458
|
+
_beginVestingBoth();
|
|
1459
|
+
|
|
1460
|
+
// Token 1 has 0 voting units, gets nothing. Total stake = 0 + 200 = 200.
|
|
1461
|
+
// Token 1: mulDiv(1000e18, 0, 200) = 0.
|
|
1462
|
+
// Token 2: mulDiv(1000e18, 200, 200) = 1000e18.
|
|
1463
|
+
assertEq(distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken))), 0);
|
|
1464
|
+
assertEq(distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken))), 1000 ether);
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// =====================================================================
|
|
1468
|
+
// Collect multiple tokenIds in one call (same owner)
|
|
1469
|
+
// =====================================================================
|
|
1470
|
+
|
|
1471
|
+
function test_collectMultipleTokenIds_sameOwner() public {
|
|
1472
|
+
// Give alice both tokens.
|
|
1473
|
+
hook.setOwner(2, alice);
|
|
1474
|
+
|
|
1475
|
+
_fundHook(1000 ether);
|
|
1476
|
+
_beginVestingBoth();
|
|
1477
|
+
|
|
1478
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1479
|
+
|
|
1480
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
1481
|
+
tokenIds[0] = 1;
|
|
1482
|
+
tokenIds[1] = 2;
|
|
1483
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1484
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1485
|
+
|
|
1486
|
+
vm.prank(alice);
|
|
1487
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1488
|
+
|
|
1489
|
+
// Alice gets both shares in one call.
|
|
1490
|
+
assertEq(rewardToken.balanceOf(alice), 750 ether);
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// =====================================================================
|
|
1494
|
+
// collectableFor == claimedFor after full vest (invariant)
|
|
1495
|
+
// =====================================================================
|
|
1496
|
+
|
|
1497
|
+
function test_collectableEqualsClaimedAfterFullVest() public {
|
|
1498
|
+
_fundHook(1000 ether);
|
|
1499
|
+
_beginVestingBoth();
|
|
1500
|
+
|
|
1501
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1502
|
+
|
|
1503
|
+
IERC20 token = IERC20(address(rewardToken));
|
|
1504
|
+
assertEq(distributor.collectableFor(address(hook), 1, token), distributor.claimedFor(address(hook), 1, token));
|
|
1505
|
+
assertEq(distributor.collectableFor(address(hook), 2, token), distributor.claimedFor(address(hook), 2, token));
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// =====================================================================
|
|
1509
|
+
// Snapshot captures vesting amount correctly across rounds
|
|
1510
|
+
// =====================================================================
|
|
1511
|
+
|
|
1512
|
+
function test_snapshotWithExistingVesting() public {
|
|
1513
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1514
|
+
tokenIds[0] = 1;
|
|
1515
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1516
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1517
|
+
|
|
1518
|
+
// Round 0: fund and vest.
|
|
1519
|
+
_fundHook(1000 ether);
|
|
1520
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1521
|
+
|
|
1522
|
+
JBTokenSnapshotData memory snap0 = distributor.snapshotAtRoundOf(address(hook), IERC20(address(rewardToken)), 0);
|
|
1523
|
+
assertEq(snap0.balance, 1000 ether);
|
|
1524
|
+
assertEq(snap0.vestingAmount, 0); // No prior vesting when round 0 snapshot taken.
|
|
1525
|
+
|
|
1526
|
+
// Round 1: snapshot should reflect vesting from round 0.
|
|
1527
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1528
|
+
_fundHook(500 ether);
|
|
1529
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1530
|
+
|
|
1531
|
+
JBTokenSnapshotData memory snap1 = distributor.snapshotAtRoundOf(address(hook), IERC20(address(rewardToken)), 1);
|
|
1532
|
+
assertEq(snap1.balance, 1500 ether); // 1000 + 500
|
|
1533
|
+
assertEq(snap1.vestingAmount, 250 ether); // Token 1's round-0 vesting
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// =====================================================================
|
|
1537
|
+
// Reentrancy via malicious ERC20
|
|
1538
|
+
// =====================================================================
|
|
1539
|
+
|
|
1540
|
+
function test_reentrancy_maliciousToken() public {
|
|
1541
|
+
// Deploy a reentrant token.
|
|
1542
|
+
ReentrantToken maliciousToken = new ReentrantToken(address(distributor));
|
|
1543
|
+
|
|
1544
|
+
// Fund the hook with the malicious token.
|
|
1545
|
+
maliciousToken.mint(address(this), 1000 ether);
|
|
1546
|
+
maliciousToken.approve(address(distributor), 1000 ether);
|
|
1547
|
+
distributor.fund(address(hook), IERC20(address(maliciousToken)), 1000 ether);
|
|
1548
|
+
|
|
1549
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1550
|
+
tokenIds[0] = 1;
|
|
1551
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1552
|
+
tokens[0] = IERC20(address(maliciousToken));
|
|
1553
|
+
|
|
1554
|
+
// Vest.
|
|
1555
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1556
|
+
|
|
1557
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1558
|
+
|
|
1559
|
+
// Set up reentrancy: on transfer, the token calls collectVestedRewards again.
|
|
1560
|
+
maliciousToken.setReentrancyTarget(address(hook), tokenIds, tokens, alice);
|
|
1561
|
+
|
|
1562
|
+
// The reentrant call should not yield extra tokens because:
|
|
1563
|
+
// After first collect, shareClaimed = MAX_SHARE and latestVestedIndex advances.
|
|
1564
|
+
// The reentrant collect call processes the same entry but gets claimAmount = 0.
|
|
1565
|
+
vm.prank(alice);
|
|
1566
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
1567
|
+
|
|
1568
|
+
// Alice should only get 250 ether (25% of 1000), not double.
|
|
1569
|
+
assertEq(maliciousToken.balanceOf(alice), 250 ether);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// =====================================================================
|
|
1573
|
+
// Fuzz: collect at any round produces correct amount
|
|
1574
|
+
// =====================================================================
|
|
1575
|
+
|
|
1576
|
+
function testFuzz_collectableFor_atAnyRound(uint8 roundsForward) public {
|
|
1577
|
+
uint256 rounds = bound(roundsForward, 0, VESTING_ROUNDS);
|
|
1578
|
+
|
|
1579
|
+
_fundHook(1000 ether);
|
|
1580
|
+
|
|
1581
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1582
|
+
tokenIds[0] = 1;
|
|
1583
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1584
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1585
|
+
|
|
1586
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1587
|
+
|
|
1588
|
+
vm.roll(block.number + ROUND_DURATION * rounds);
|
|
1589
|
+
|
|
1590
|
+
uint256 collectable = distributor.collectableFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1591
|
+
uint256 claimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
1592
|
+
|
|
1593
|
+
// Collectable should always be <= claimed.
|
|
1594
|
+
assertLe(collectable, claimed);
|
|
1595
|
+
|
|
1596
|
+
// At full vesting, collectable == claimed.
|
|
1597
|
+
if (rounds == VESTING_ROUNDS) {
|
|
1598
|
+
assertEq(collectable, claimed);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Collectable should be proportional to rounds passed.
|
|
1602
|
+
uint256 expectedCollectable = claimed * rounds / VESTING_ROUNDS;
|
|
1603
|
+
assertApproxEqAbs(collectable, expectedCollectable, 1);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// =====================================================================
|
|
1607
|
+
// Fuzz: conservation -- total distributed never exceeds funded
|
|
1608
|
+
// =====================================================================
|
|
1609
|
+
|
|
1610
|
+
function testFuzz_conservation(uint128 rawFund1, uint128 rawFund2) public {
|
|
1611
|
+
uint256 fund1 = bound(rawFund1, 1 ether, type(uint64).max);
|
|
1612
|
+
uint256 fund2 = bound(rawFund2, 1 ether, type(uint64).max);
|
|
1613
|
+
|
|
1614
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
1615
|
+
tokenIds[0] = 1;
|
|
1616
|
+
tokenIds[1] = 2;
|
|
1617
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1618
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1619
|
+
|
|
1620
|
+
// Round 0.
|
|
1621
|
+
_fundHook(fund1);
|
|
1622
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1623
|
+
|
|
1624
|
+
// Round 1.
|
|
1625
|
+
vm.roll(block.number + ROUND_DURATION);
|
|
1626
|
+
_fundHook(fund2);
|
|
1627
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1628
|
+
|
|
1629
|
+
// Full vest.
|
|
1630
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1631
|
+
|
|
1632
|
+
uint256[] memory aliceIds = new uint256[](1);
|
|
1633
|
+
aliceIds[0] = 1;
|
|
1634
|
+
uint256[] memory bobIds = new uint256[](1);
|
|
1635
|
+
bobIds[0] = 2;
|
|
1636
|
+
|
|
1637
|
+
vm.prank(alice);
|
|
1638
|
+
distributor.collectVestedRewards(address(hook), aliceIds, tokens, alice);
|
|
1639
|
+
vm.prank(bob);
|
|
1640
|
+
distributor.collectVestedRewards(address(hook), bobIds, tokens, bob);
|
|
1641
|
+
|
|
1642
|
+
uint256 totalDistributed = rewardToken.balanceOf(alice) + rewardToken.balanceOf(bob);
|
|
1643
|
+
uint256 totalFunded = uint256(fund1) + uint256(fund2);
|
|
1644
|
+
|
|
1645
|
+
// Total distributed should never exceed total funded.
|
|
1646
|
+
assertLe(totalDistributed, totalFunded);
|
|
1647
|
+
// Distributor should hold the remainder.
|
|
1648
|
+
assertEq(rewardToken.balanceOf(address(distributor)), totalFunded - totalDistributed);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// =====================================================================
|
|
1652
|
+
// Multi-hook isolation
|
|
1653
|
+
// =====================================================================
|
|
1654
|
+
|
|
1655
|
+
function test_multiHook_fundsIsolated() public {
|
|
1656
|
+
// Create a second hook.
|
|
1657
|
+
MockStore store2 = new MockStore();
|
|
1658
|
+
MockHook hook2 = new MockHook(store2);
|
|
1659
|
+
|
|
1660
|
+
JB721TierFlags memory flags;
|
|
1661
|
+
|
|
1662
|
+
store2.setMaxTierIdOf(1);
|
|
1663
|
+
store2.setTier(
|
|
1664
|
+
1,
|
|
1665
|
+
JB721Tier({
|
|
1666
|
+
id: 1,
|
|
1667
|
+
price: 1 ether,
|
|
1668
|
+
remainingSupply: 9,
|
|
1669
|
+
initialSupply: 10,
|
|
1670
|
+
votingUnits: 100,
|
|
1671
|
+
reserveFrequency: 0,
|
|
1672
|
+
reserveBeneficiary: address(0),
|
|
1673
|
+
encodedIPFSUri: bytes32(0),
|
|
1674
|
+
category: 0,
|
|
1675
|
+
discountPercent: 0,
|
|
1676
|
+
flags: flags,
|
|
1677
|
+
splitPercent: 0,
|
|
1678
|
+
resolvedUri: ""
|
|
1679
|
+
})
|
|
1680
|
+
);
|
|
1681
|
+
store2.setTokenTier(1, 1);
|
|
1682
|
+
hook2.setOwner(1, charlie);
|
|
1683
|
+
|
|
1684
|
+
// Fund hook1 with 1000, hook2 with 500.
|
|
1685
|
+
_fundHook(1000 ether);
|
|
1686
|
+
rewardToken.mint(address(this), 500 ether);
|
|
1687
|
+
rewardToken.approve(address(distributor), 500 ether);
|
|
1688
|
+
distributor.fund(address(hook2), IERC20(address(rewardToken)), 500 ether);
|
|
1689
|
+
|
|
1690
|
+
// Balances are isolated.
|
|
1691
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), 1000 ether);
|
|
1692
|
+
assertEq(distributor.balanceOf(address(hook2), IERC20(address(rewardToken))), 500 ether);
|
|
1693
|
+
|
|
1694
|
+
// Vest on hook1.
|
|
1695
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1696
|
+
tokenIds[0] = 1;
|
|
1697
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1698
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1699
|
+
|
|
1700
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1701
|
+
|
|
1702
|
+
// Vest on hook2.
|
|
1703
|
+
distributor.beginVesting(address(hook2), tokenIds, tokens);
|
|
1704
|
+
|
|
1705
|
+
// Vesting amounts are isolated.
|
|
1706
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 250 ether);
|
|
1707
|
+
assertEq(distributor.totalVestingAmountOf(address(hook2), IERC20(address(rewardToken))), 500 ether);
|
|
1708
|
+
|
|
1709
|
+
// Collect from hook2 -- should not affect hook1.
|
|
1710
|
+
vm.roll(block.number + ROUND_DURATION * VESTING_ROUNDS);
|
|
1711
|
+
vm.prank(charlie);
|
|
1712
|
+
distributor.collectVestedRewards(address(hook2), tokenIds, tokens, charlie);
|
|
1713
|
+
|
|
1714
|
+
assertEq(rewardToken.balanceOf(charlie), 500 ether);
|
|
1715
|
+
// hook1 balance unchanged (only hook1 vesting amount decremented from its pool).
|
|
1716
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), 1000 ether);
|
|
1717
|
+
assertEq(distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))), 250 ether);
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// =====================================================================
|
|
1721
|
+
// Helpers
|
|
1722
|
+
// =====================================================================
|
|
1723
|
+
|
|
1724
|
+
function _fundHook(uint256 amount) internal {
|
|
1725
|
+
rewardToken.mint(address(this), amount);
|
|
1726
|
+
rewardToken.approve(address(distributor), amount);
|
|
1727
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), amount);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function _beginVestingBoth() internal {
|
|
1731
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
1732
|
+
tokenIds[0] = 1;
|
|
1733
|
+
tokenIds[1] = 2;
|
|
1734
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1735
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1736
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// =====================================================================
|
|
1740
|
+
// Split Hook
|
|
1741
|
+
// =====================================================================
|
|
1742
|
+
|
|
1743
|
+
function _splitContext(address token, uint256 amount) internal view returns (JBSplitHookContext memory) {
|
|
1744
|
+
return JBSplitHookContext({
|
|
1745
|
+
token: token,
|
|
1746
|
+
amount: amount,
|
|
1747
|
+
decimals: 18,
|
|
1748
|
+
projectId: 1,
|
|
1749
|
+
groupId: uint256(uint160(token)),
|
|
1750
|
+
split: JBSplit({
|
|
1751
|
+
percent: 500_000_000, // 50%
|
|
1752
|
+
projectId: 0,
|
|
1753
|
+
beneficiary: payable(address(hook)), // hook address as beneficiary
|
|
1754
|
+
preferAddToBalance: false,
|
|
1755
|
+
lockedUntil: 0,
|
|
1756
|
+
hook: IJBSplitHook(address(distributor))
|
|
1757
|
+
})
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/// @notice processSplitWith pulls ERC-20 tokens via transferFrom and credits hook balance.
|
|
1762
|
+
function test_processSplitWith_erc20() public {
|
|
1763
|
+
uint256 amount = 10 ether;
|
|
1764
|
+
rewardToken.mint(address(this), amount);
|
|
1765
|
+
rewardToken.approve(address(distributor), amount);
|
|
1766
|
+
|
|
1767
|
+
distributor.processSplitWith(_splitContext(address(rewardToken), amount));
|
|
1768
|
+
|
|
1769
|
+
assertEq(rewardToken.balanceOf(address(distributor)), amount);
|
|
1770
|
+
assertEq(rewardToken.balanceOf(address(this)), 0);
|
|
1771
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), amount);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/// @notice processSplitWith accepts native ETH via msg.value.
|
|
1775
|
+
function test_processSplitWith_nativeETH() public {
|
|
1776
|
+
uint256 amount = 5 ether;
|
|
1777
|
+
vm.deal(address(this), amount);
|
|
1778
|
+
|
|
1779
|
+
uint256 balBefore = address(distributor).balance;
|
|
1780
|
+
|
|
1781
|
+
distributor.processSplitWith{value: amount}(
|
|
1782
|
+
_splitContext(address(0x000000000000000000000000000000000000EEEe), amount)
|
|
1783
|
+
);
|
|
1784
|
+
|
|
1785
|
+
assertEq(address(distributor).balance, balBefore + amount);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/// @notice processSplitWith with zero amount is a no-op for ERC-20.
|
|
1789
|
+
function test_processSplitWith_zeroAmount() public {
|
|
1790
|
+
distributor.processSplitWith(_splitContext(address(rewardToken), 0));
|
|
1791
|
+
// No revert, no balance change.
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/// @notice ERC-20 tokens received via processSplitWith are distributable in the next beginVesting.
|
|
1795
|
+
function test_processSplitWith_tokensDistributableViaVesting() public {
|
|
1796
|
+
uint256 amount = 100 ether;
|
|
1797
|
+
rewardToken.mint(address(this), amount);
|
|
1798
|
+
rewardToken.approve(address(distributor), amount);
|
|
1799
|
+
|
|
1800
|
+
// Send tokens via split hook.
|
|
1801
|
+
distributor.processSplitWith(_splitContext(address(rewardToken), amount));
|
|
1802
|
+
|
|
1803
|
+
// Verify tokens are in the distributor and credited to hook.
|
|
1804
|
+
assertEq(rewardToken.balanceOf(address(distributor)), amount);
|
|
1805
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), amount);
|
|
1806
|
+
|
|
1807
|
+
// Now begin vesting -- tokens should be distributed pro-rata.
|
|
1808
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
1809
|
+
tokenIds[0] = 1;
|
|
1810
|
+
tokenIds[1] = 2;
|
|
1811
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1812
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1813
|
+
|
|
1814
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1815
|
+
|
|
1816
|
+
// Token 1 (tier1, 100 voting units) gets 100/400 = 25% of 100 ether = 25 ether.
|
|
1817
|
+
// Token 2 (tier2, 200 voting units) gets 200/400 = 50% of 100 ether = 50 ether.
|
|
1818
|
+
assertEq(distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken))), 25 ether);
|
|
1819
|
+
assertEq(distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken))), 50 ether);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/// @notice supportsInterface returns true for IJBSplitHook, IJB721Distributor, and IERC165.
|
|
1823
|
+
function test_supportsInterface() public view {
|
|
1824
|
+
assertTrue(distributor.supportsInterface(type(IJBSplitHook).interfaceId));
|
|
1825
|
+
assertTrue(distributor.supportsInterface(type(IJB721Distributor).interfaceId));
|
|
1826
|
+
assertTrue(distributor.supportsInterface(type(IERC165).interfaceId));
|
|
1827
|
+
// Random interface should return false.
|
|
1828
|
+
assertFalse(distributor.supportsInterface(0xdeadbeef));
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/// @notice processSplitWith with allowance credits balance (terminal/controller pull pattern).
|
|
1832
|
+
function test_processSplitWith_erc20_noAllowance_creditsBalance() public {
|
|
1833
|
+
uint256 amount = 10 ether;
|
|
1834
|
+
// Mint to caller and approve distributor (pull pattern).
|
|
1835
|
+
rewardToken.mint(address(this), amount);
|
|
1836
|
+
rewardToken.approve(address(distributor), amount);
|
|
1837
|
+
|
|
1838
|
+
distributor.processSplitWith(_splitContext(address(rewardToken), amount));
|
|
1839
|
+
// Balance credited to hook via pull.
|
|
1840
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), amount);
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/// @notice Controller pattern: controller approves and processSplitWith pulls tokens.
|
|
1844
|
+
function test_processSplitWith_controllerPattern() public {
|
|
1845
|
+
// Register this test as controller (not just terminal) for the controller path.
|
|
1846
|
+
directory.setTerminal(PROJECT_ID, address(this), false);
|
|
1847
|
+
directory.setController(PROJECT_ID, address(this));
|
|
1848
|
+
|
|
1849
|
+
uint256 amount = 50 ether;
|
|
1850
|
+
// Controller mints to itself and approves the distributor.
|
|
1851
|
+
rewardToken.mint(address(this), amount);
|
|
1852
|
+
rewardToken.approve(address(distributor), amount);
|
|
1853
|
+
|
|
1854
|
+
// Controller calls processSplitWith -- distributor pulls via allowance.
|
|
1855
|
+
distributor.processSplitWith(_splitContext(address(rewardToken), amount));
|
|
1856
|
+
|
|
1857
|
+
// Balance credited to hook.
|
|
1858
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), amount);
|
|
1859
|
+
|
|
1860
|
+
// Tokens are distributable via vesting.
|
|
1861
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
1862
|
+
tokenIds[0] = 1;
|
|
1863
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
1864
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
1865
|
+
|
|
1866
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
1867
|
+
assertEq(distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken))), amount * 100 / 400);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/// @notice Fuzz: processSplitWith with random ERC-20 amounts.
|
|
1871
|
+
function testFuzz_processSplitWith_erc20(uint128 rawAmount) public {
|
|
1872
|
+
uint256 amount = bound(rawAmount, 1, type(uint128).max);
|
|
1873
|
+
rewardToken.mint(address(this), amount);
|
|
1874
|
+
rewardToken.approve(address(distributor), amount);
|
|
1875
|
+
|
|
1876
|
+
distributor.processSplitWith(_splitContext(address(rewardToken), amount));
|
|
1877
|
+
|
|
1878
|
+
assertEq(rewardToken.balanceOf(address(distributor)), amount);
|
|
1879
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), amount);
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
/// @notice processSplitWith routes to correct hook via split.beneficiary.
|
|
1883
|
+
function test_processSplitWith_routesViaBeneficiary() public {
|
|
1884
|
+
// Create a second hook.
|
|
1885
|
+
MockStore store2 = new MockStore();
|
|
1886
|
+
MockHook hook2 = new MockHook(store2);
|
|
1887
|
+
|
|
1888
|
+
uint256 amount = 100 ether;
|
|
1889
|
+
rewardToken.mint(address(this), amount);
|
|
1890
|
+
rewardToken.approve(address(distributor), amount);
|
|
1891
|
+
|
|
1892
|
+
// Split context with hook2 as beneficiary.
|
|
1893
|
+
JBSplitHookContext memory ctx = JBSplitHookContext({
|
|
1894
|
+
token: address(rewardToken),
|
|
1895
|
+
amount: amount,
|
|
1896
|
+
decimals: 18,
|
|
1897
|
+
projectId: 1,
|
|
1898
|
+
groupId: uint256(uint160(address(rewardToken))),
|
|
1899
|
+
split: JBSplit({
|
|
1900
|
+
percent: 500_000_000,
|
|
1901
|
+
projectId: 0,
|
|
1902
|
+
beneficiary: payable(address(hook2)),
|
|
1903
|
+
preferAddToBalance: false,
|
|
1904
|
+
lockedUntil: 0,
|
|
1905
|
+
hook: IJBSplitHook(address(distributor))
|
|
1906
|
+
})
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
distributor.processSplitWith(ctx);
|
|
1910
|
+
|
|
1911
|
+
// Funds credited to hook2, not hook1.
|
|
1912
|
+
assertEq(distributor.balanceOf(address(hook2), IERC20(address(rewardToken))), amount);
|
|
1913
|
+
assertEq(distributor.balanceOf(address(hook), IERC20(address(rewardToken))), 0);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/// @notice processSplitWith reverts when caller is not a terminal or controller.
|
|
1917
|
+
function test_processSplitWith_unauthorized_reverts() public {
|
|
1918
|
+
address unauthorized = makeAddr("unauthorized");
|
|
1919
|
+
uint256 amount = 10 ether;
|
|
1920
|
+
rewardToken.mint(unauthorized, amount);
|
|
1921
|
+
|
|
1922
|
+
vm.startPrank(unauthorized);
|
|
1923
|
+
rewardToken.approve(address(distributor), amount);
|
|
1924
|
+
|
|
1925
|
+
vm.expectRevert(JB721Distributor.JB721Distributor_Unauthorized.selector);
|
|
1926
|
+
distributor.processSplitWith(_splitContext(address(rewardToken), amount));
|
|
1927
|
+
vm.stopPrank();
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/// @notice ERC20 that reenters collectVestedRewards on transfer.
|
|
1932
|
+
contract ReentrantToken is ERC20 {
|
|
1933
|
+
address public distributor;
|
|
1934
|
+
bool public reentrancyArmed;
|
|
1935
|
+
address public reentrantHook;
|
|
1936
|
+
uint256[] public reentrantTokenIds;
|
|
1937
|
+
IERC20[] public reentrantTokens;
|
|
1938
|
+
address public reentrantBeneficiary;
|
|
1939
|
+
|
|
1940
|
+
constructor(address _distributor) ERC20("Reentrant", "REENT") {
|
|
1941
|
+
distributor = _distributor;
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
function mint(address to, uint256 amount) external {
|
|
1945
|
+
_mint(to, amount);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
function setReentrancyTarget(
|
|
1949
|
+
address hook,
|
|
1950
|
+
uint256[] memory tokenIds,
|
|
1951
|
+
IERC20[] memory tokens,
|
|
1952
|
+
address beneficiary
|
|
1953
|
+
)
|
|
1954
|
+
external
|
|
1955
|
+
{
|
|
1956
|
+
reentrantHook = hook;
|
|
1957
|
+
// Store for reentrancy.
|
|
1958
|
+
delete reentrantTokenIds;
|
|
1959
|
+
delete reentrantTokens;
|
|
1960
|
+
for (uint256 i; i < tokenIds.length; i++) {
|
|
1961
|
+
reentrantTokenIds.push(tokenIds[i]);
|
|
1962
|
+
}
|
|
1963
|
+
for (uint256 i; i < tokens.length; i++) {
|
|
1964
|
+
reentrantTokens.push(tokens[i]);
|
|
1965
|
+
}
|
|
1966
|
+
reentrantBeneficiary = beneficiary;
|
|
1967
|
+
reentrancyArmed = true;
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function transfer(address to, uint256 amount) public override returns (bool) {
|
|
1971
|
+
// Perform the actual transfer first.
|
|
1972
|
+
bool result = super.transfer(to, amount);
|
|
1973
|
+
|
|
1974
|
+
// Attempt reentrancy once.
|
|
1975
|
+
if (reentrancyArmed) {
|
|
1976
|
+
reentrancyArmed = false; // Prevent infinite recursion.
|
|
1977
|
+
// Try to re-collect. This should be a no-op because state was already updated.
|
|
1978
|
+
try JBDistributor(distributor)
|
|
1979
|
+
.collectVestedRewards(reentrantHook, reentrantTokenIds, reentrantTokens, reentrantBeneficiary) {}
|
|
1980
|
+
catch {}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
return result;
|
|
1984
|
+
}
|
|
1985
|
+
}
|