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