@bananapus/distributor-v6 0.0.4 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -13,7 +13,7 @@ What new trust boundary, failure mode, operational dependency, or integration ca
|
|
|
13
13
|
- [ ] Updated `/v6/evm/RISKS.md` because ecosystem behavior changed
|
|
14
14
|
- [ ] If no `RISKS.md` update was needed, I explained why in this PR
|
|
15
15
|
|
|
16
|
-
Reference: [`/v6/evm/
|
|
16
|
+
Reference: [`/v6/evm/RISKS_MAINTENANCE.md`](../../RISKS_MAINTENANCE.md)
|
|
17
17
|
|
|
18
18
|
## Checklist
|
|
19
19
|
|
|
@@ -22,5 +22,7 @@ jobs:
|
|
|
22
22
|
uses: foundry-rs/foundry-toolchain@v1
|
|
23
23
|
- name: Run tests
|
|
24
24
|
run: forge test --fail-fast --summary --detailed --skip "*/script/**"
|
|
25
|
+
env:
|
|
26
|
+
RPC_ETHEREUM_MAINNET: ${{ secrets.RPC_ETHEREUM_MAINNET }}
|
|
25
27
|
- name: Check contract sizes
|
|
26
28
|
run: forge build --sizes --skip "*/test/**" --skip "*/script/**" --skip SphinxUtils
|
package/USER_JOURNEYS.md
CHANGED
|
@@ -55,6 +55,8 @@ This repo distributes already-owned assets over time. It snapshots stake, starts
|
|
|
55
55
|
2. The distributor snapshots the relevant balance and stake source.
|
|
56
56
|
3. Vesting entries become claimable over the configured schedule.
|
|
57
57
|
|
|
58
|
+
**Snapshot timing:** The snapshot for each round is taken when the previous round first sees activity (`poke`, `beginVesting`, or `collectVestedRewards`). To be included in round N's distribution, make sure your tokens are held and delegated before anyone interacts with round N-1. In practice, keep your delegation current — if it is set before the previous round's activity begins, your voting power will be counted for the next round.
|
|
59
|
+
|
|
58
60
|
**Failure Modes**
|
|
59
61
|
- zero total stake
|
|
60
62
|
- bad deployment parameters such as zero round duration or zero vesting rounds
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/distributor-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"deploy:testnets": "source ./.env && npx sphinx propose ./script/Deploy.s.sol --networks testnets"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@bananapus/721-hook-v6": "^0.0.
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
22
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
20
|
+
"@bananapus/721-hook-v6": "^0.0.38",
|
|
21
|
+
"@bananapus/core-v6": "^0.0.36",
|
|
22
|
+
"@bananapus/permission-ids-v6": "^0.0.19",
|
|
23
23
|
"@openzeppelin/contracts": "^5.6.1",
|
|
24
24
|
"@prb/math": "^4.1.0"
|
|
25
25
|
},
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
import {stdError} from "forge-std/StdError.sol";
|
|
6
|
+
|
|
7
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
8
|
+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
9
|
+
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
|
|
10
|
+
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
|
|
11
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
12
|
+
|
|
13
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
14
|
+
import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
15
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
16
|
+
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
17
|
+
import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
|
|
18
|
+
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
19
|
+
import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
|
|
20
|
+
|
|
21
|
+
import {JB721Distributor} from "../../src/JB721Distributor.sol";
|
|
22
|
+
import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
|
|
23
|
+
|
|
24
|
+
contract CodexNemesisDirectory {
|
|
25
|
+
mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
|
|
26
|
+
mapping(uint256 projectId => address controller) public controllers;
|
|
27
|
+
|
|
28
|
+
function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
|
|
29
|
+
terminals[projectId][terminal] = isTerminal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function setController(uint256 projectId, address controller) external {
|
|
33
|
+
controllers[projectId] = controller;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
|
|
37
|
+
return terminals[projectId][address(terminal)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function controllerOf(uint256 projectId) external view returns (IERC165) {
|
|
41
|
+
return IERC165(controllers[projectId]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
contract CodexNemesisRewardToken is ERC20 {
|
|
46
|
+
constructor() ERC20("Reward", "RWD") {}
|
|
47
|
+
|
|
48
|
+
function mint(address to, uint256 amount) external {
|
|
49
|
+
_mint(to, amount);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
contract CodexNemesisVotesToken is ERC20, ERC20Votes {
|
|
54
|
+
constructor() ERC20("Votes", "VOTE") EIP712("Votes", "1") {}
|
|
55
|
+
|
|
56
|
+
function mint(address to, uint256 amount) external {
|
|
57
|
+
_mint(to, amount);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
|
|
61
|
+
super._update(from, to, value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
contract CodexNemesisStore {
|
|
66
|
+
uint256 public maxTier;
|
|
67
|
+
mapping(uint256 tierId => JB721Tier) public tiers;
|
|
68
|
+
mapping(uint256 tokenId => uint256 tierId) public tokenTiers;
|
|
69
|
+
|
|
70
|
+
function setMaxTierIdOf(uint256 maxTierId) external {
|
|
71
|
+
maxTier = maxTierId;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setTier(uint256 tierId, JB721Tier memory tier) external {
|
|
75
|
+
tiers[tierId] = tier;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setTokenTier(uint256 tokenId, uint256 tierId) external {
|
|
79
|
+
tokenTiers[tokenId] = tierId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
83
|
+
return tiers[tokenTiers[tokenId]];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
contract CodexNemesisCheckpoints {
|
|
88
|
+
uint256 public totalSupplyAtSnapshot;
|
|
89
|
+
mapping(address account => uint256 votes) public votesAtSnapshot;
|
|
90
|
+
|
|
91
|
+
function setTotalSupply(uint256 totalSupply) external {
|
|
92
|
+
totalSupplyAtSnapshot = totalSupply;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function setVotes(address account, uint256 votes) external {
|
|
96
|
+
votesAtSnapshot[account] = votes;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getPastTotalSupply(uint256) external view returns (uint256) {
|
|
100
|
+
return totalSupplyAtSnapshot;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getPastVotes(address account, uint256) external view returns (uint256) {
|
|
104
|
+
return votesAtSnapshot[account];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
contract CodexNemesisHook {
|
|
109
|
+
CodexNemesisStore public immutable STORE;
|
|
110
|
+
CodexNemesisCheckpoints public immutable CHECKPOINTS;
|
|
111
|
+
|
|
112
|
+
mapping(uint256 tokenId => address owner) public owners;
|
|
113
|
+
|
|
114
|
+
constructor(CodexNemesisStore store, CodexNemesisCheckpoints checkpoints) {
|
|
115
|
+
STORE = store;
|
|
116
|
+
CHECKPOINTS = checkpoints;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function ownerOf(uint256 tokenId) external view returns (address) {
|
|
120
|
+
address owner = owners[tokenId];
|
|
121
|
+
require(owner != address(0), "NO_OWNER");
|
|
122
|
+
return owner;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
126
|
+
owners[tokenId] = owner;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
contract CodexNemesisAccountingPoCTest is Test {
|
|
131
|
+
uint256 internal constant PROJECT_ID = 1;
|
|
132
|
+
uint256 internal constant ROUND_DURATION = 100;
|
|
133
|
+
uint256 internal constant VESTING_ROUNDS = 4;
|
|
134
|
+
|
|
135
|
+
address internal attacker = makeAddr("attacker");
|
|
136
|
+
address internal honest = makeAddr("honest");
|
|
137
|
+
address internal maliciousController = makeAddr("maliciousController");
|
|
138
|
+
|
|
139
|
+
CodexNemesisDirectory internal directory;
|
|
140
|
+
CodexNemesisRewardToken internal rewardToken;
|
|
141
|
+
|
|
142
|
+
function setUp() public {
|
|
143
|
+
directory = new CodexNemesisDirectory();
|
|
144
|
+
rewardToken = new CodexNemesisRewardToken();
|
|
145
|
+
directory.setController(PROJECT_ID, maliciousController);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function test_controllerCanCreditUndeliveredTokensAndDrainRealInventory() public {
|
|
149
|
+
JBTokenDistributor distributor =
|
|
150
|
+
new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
151
|
+
CodexNemesisVotesToken votesToken = new CodexNemesisVotesToken();
|
|
152
|
+
|
|
153
|
+
votesToken.mint(attacker, 10 ether);
|
|
154
|
+
votesToken.mint(honest, 990 ether);
|
|
155
|
+
|
|
156
|
+
vm.prank(attacker);
|
|
157
|
+
votesToken.delegate(attacker);
|
|
158
|
+
vm.prank(honest);
|
|
159
|
+
votesToken.delegate(honest);
|
|
160
|
+
vm.roll(block.number + 1);
|
|
161
|
+
|
|
162
|
+
rewardToken.mint(address(this), 1000 ether);
|
|
163
|
+
rewardToken.approve(address(distributor), 1000 ether);
|
|
164
|
+
distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
|
|
165
|
+
|
|
166
|
+
JBSplit memory split = JBSplit({
|
|
167
|
+
percent: 1_000_000_000,
|
|
168
|
+
projectId: 0,
|
|
169
|
+
beneficiary: payable(address(votesToken)),
|
|
170
|
+
preferAddToBalance: false,
|
|
171
|
+
lockedUntil: 0,
|
|
172
|
+
hook: IJBSplitHook(address(distributor))
|
|
173
|
+
});
|
|
174
|
+
JBSplitHookContext memory fakeContext = JBSplitHookContext({
|
|
175
|
+
token: address(rewardToken),
|
|
176
|
+
amount: 99_000 ether,
|
|
177
|
+
decimals: 18,
|
|
178
|
+
projectId: PROJECT_ID,
|
|
179
|
+
groupId: 0,
|
|
180
|
+
split: split
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
vm.prank(maliciousController);
|
|
184
|
+
distributor.processSplitWith(fakeContext);
|
|
185
|
+
|
|
186
|
+
assertEq(rewardToken.balanceOf(address(distributor)), 1000 ether, "no additional reward tokens arrived");
|
|
187
|
+
assertEq(
|
|
188
|
+
distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
189
|
+
100_000 ether,
|
|
190
|
+
"tracked balance was inflated by context.amount"
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
194
|
+
tokenIds[0] = uint256(uint160(attacker));
|
|
195
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
196
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
197
|
+
|
|
198
|
+
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
199
|
+
assertEq(distributor.claimedFor(address(votesToken), tokenIds[0], tokens[0]), 1000 ether);
|
|
200
|
+
|
|
201
|
+
vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
|
|
202
|
+
vm.roll(block.number + 1);
|
|
203
|
+
vm.prank(attacker);
|
|
204
|
+
distributor.collectVestedRewards(address(votesToken), tokenIds, tokens, attacker);
|
|
205
|
+
|
|
206
|
+
assertEq(rewardToken.balanceOf(attacker), 1000 ether, "attacker drained the real inventory");
|
|
207
|
+
assertEq(rewardToken.balanceOf(address(distributor)), 0, "honest claimants are left unfunded");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function test_721LateMintCanUseOwnersPastVotesAndDrainRound() public {
|
|
211
|
+
JB721Distributor distributor =
|
|
212
|
+
new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
213
|
+
CodexNemesisStore store = new CodexNemesisStore();
|
|
214
|
+
CodexNemesisCheckpoints checkpoints = new CodexNemesisCheckpoints();
|
|
215
|
+
CodexNemesisHook hook = new CodexNemesisHook(store, checkpoints);
|
|
216
|
+
|
|
217
|
+
JB721TierFlags memory flags;
|
|
218
|
+
store.setMaxTierIdOf(1);
|
|
219
|
+
store.setTier({
|
|
220
|
+
tierId: 1,
|
|
221
|
+
tier: JB721Tier({
|
|
222
|
+
id: 1,
|
|
223
|
+
price: 1 ether,
|
|
224
|
+
remainingSupply: 97,
|
|
225
|
+
initialSupply: 100,
|
|
226
|
+
votingUnits: 100,
|
|
227
|
+
reserveFrequency: 0,
|
|
228
|
+
reserveBeneficiary: address(0),
|
|
229
|
+
encodedIPFSUri: bytes32(0),
|
|
230
|
+
category: 0,
|
|
231
|
+
discountPercent: 0,
|
|
232
|
+
flags: flags,
|
|
233
|
+
splitPercent: 0,
|
|
234
|
+
resolvedUri: ""
|
|
235
|
+
})
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
store.setTokenTier(1, 1);
|
|
239
|
+
store.setTokenTier(2, 1);
|
|
240
|
+
store.setTokenTier(3, 1);
|
|
241
|
+
hook.setOwner(1, attacker);
|
|
242
|
+
hook.setOwner(2, attacker);
|
|
243
|
+
hook.setOwner(3, attacker);
|
|
244
|
+
|
|
245
|
+
checkpoints.setTotalSupply(100);
|
|
246
|
+
checkpoints.setVotes(attacker, 100);
|
|
247
|
+
|
|
248
|
+
rewardToken.mint(address(this), 1000 ether);
|
|
249
|
+
rewardToken.approve(address(distributor), 1000 ether);
|
|
250
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), 1000 ether);
|
|
251
|
+
|
|
252
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
253
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
254
|
+
|
|
255
|
+
uint256[] memory firstLateMint = new uint256[](1);
|
|
256
|
+
firstLateMint[0] = 2;
|
|
257
|
+
distributor.beginVesting(address(hook), firstLateMint, tokens);
|
|
258
|
+
|
|
259
|
+
assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
|
|
260
|
+
assertEq(
|
|
261
|
+
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
262
|
+
distributor.balanceOf(address(hook), tokens[0]),
|
|
263
|
+
"one post-snapshot token consumed the whole snapshot"
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
|
|
267
|
+
vm.roll(block.number + 1);
|
|
268
|
+
vm.prank(attacker);
|
|
269
|
+
distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
|
|
270
|
+
|
|
271
|
+
assertEq(rewardToken.balanceOf(attacker), 1000 ether, "one late-minted token drained the funded balance");
|
|
272
|
+
assertEq(rewardToken.balanceOf(address(distributor)), 0, "honest snapshot stake is left unfunded");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function test_721SnapshotVotesCanBeReusedAcrossSeparateLateMintClaims() public {
|
|
276
|
+
JB721Distributor distributor =
|
|
277
|
+
new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
278
|
+
CodexNemesisStore store = new CodexNemesisStore();
|
|
279
|
+
CodexNemesisCheckpoints checkpoints = new CodexNemesisCheckpoints();
|
|
280
|
+
CodexNemesisHook hook = new CodexNemesisHook(store, checkpoints);
|
|
281
|
+
|
|
282
|
+
JB721TierFlags memory flags;
|
|
283
|
+
store.setMaxTierIdOf(1);
|
|
284
|
+
store.setTier({
|
|
285
|
+
tierId: 1,
|
|
286
|
+
tier: JB721Tier({
|
|
287
|
+
id: 1,
|
|
288
|
+
price: 1 ether,
|
|
289
|
+
remainingSupply: 97,
|
|
290
|
+
initialSupply: 100,
|
|
291
|
+
votingUnits: 100,
|
|
292
|
+
reserveFrequency: 0,
|
|
293
|
+
reserveBeneficiary: address(0),
|
|
294
|
+
encodedIPFSUri: bytes32(0),
|
|
295
|
+
category: 0,
|
|
296
|
+
discountPercent: 0,
|
|
297
|
+
flags: flags,
|
|
298
|
+
splitPercent: 0,
|
|
299
|
+
resolvedUri: ""
|
|
300
|
+
})
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
store.setTokenTier(1, 1);
|
|
304
|
+
store.setTokenTier(2, 1);
|
|
305
|
+
store.setTokenTier(3, 1);
|
|
306
|
+
hook.setOwner(1, attacker);
|
|
307
|
+
hook.setOwner(2, attacker);
|
|
308
|
+
hook.setOwner(3, attacker);
|
|
309
|
+
|
|
310
|
+
checkpoints.setTotalSupply(100);
|
|
311
|
+
checkpoints.setVotes(attacker, 100);
|
|
312
|
+
|
|
313
|
+
rewardToken.mint(address(this), 1000 ether);
|
|
314
|
+
rewardToken.approve(address(distributor), 1000 ether);
|
|
315
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), 1000 ether);
|
|
316
|
+
|
|
317
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
318
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
319
|
+
|
|
320
|
+
uint256[] memory firstLateMint = new uint256[](1);
|
|
321
|
+
firstLateMint[0] = 2;
|
|
322
|
+
distributor.beginVesting(address(hook), firstLateMint, tokens);
|
|
323
|
+
|
|
324
|
+
uint256[] memory secondLateMint = new uint256[](1);
|
|
325
|
+
secondLateMint[0] = 3;
|
|
326
|
+
distributor.beginVesting(address(hook), secondLateMint, tokens);
|
|
327
|
+
|
|
328
|
+
assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
|
|
329
|
+
assertEq(distributor.claimedFor(address(hook), 3, tokens[0]), 1000 ether);
|
|
330
|
+
assertEq(
|
|
331
|
+
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
332
|
+
2000 ether,
|
|
333
|
+
"same 100 snapshot votes were consumed twice in separate calls"
|
|
334
|
+
);
|
|
335
|
+
assertGt(
|
|
336
|
+
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
337
|
+
distributor.balanceOf(address(hook), tokens[0]),
|
|
338
|
+
"vesting obligations exceed funded balance"
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
|
|
342
|
+
vm.roll(block.number + 1);
|
|
343
|
+
vm.prank(attacker);
|
|
344
|
+
vm.expectRevert(stdError.arithmeticError);
|
|
345
|
+
distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
// Core contracts.
|
|
7
|
+
import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
|
|
8
|
+
import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
|
|
9
|
+
import {JBDirectory} from "@bananapus/core-v6/src/JBDirectory.sol";
|
|
10
|
+
import {JBController} from "@bananapus/core-v6/src/JBController.sol";
|
|
11
|
+
import {JBMultiTerminal} from "@bananapus/core-v6/src/JBMultiTerminal.sol";
|
|
12
|
+
import {JBTerminalStore} from "@bananapus/core-v6/src/JBTerminalStore.sol";
|
|
13
|
+
import {JBRulesets} from "@bananapus/core-v6/src/JBRulesets.sol";
|
|
14
|
+
import {JBTokens} from "@bananapus/core-v6/src/JBTokens.sol";
|
|
15
|
+
import {JBPrices} from "@bananapus/core-v6/src/JBPrices.sol";
|
|
16
|
+
import {JBSplits} from "@bananapus/core-v6/src/JBSplits.sol";
|
|
17
|
+
import {JBFundAccessLimits} from "@bananapus/core-v6/src/JBFundAccessLimits.sol";
|
|
18
|
+
import {JBFeelessAddresses} from "@bananapus/core-v6/src/JBFeelessAddresses.sol";
|
|
19
|
+
import {JBERC20} from "@bananapus/core-v6/src/JBERC20.sol";
|
|
20
|
+
|
|
21
|
+
// Core interfaces.
|
|
22
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
23
|
+
import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
|
|
24
|
+
import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
25
|
+
import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
|
|
26
|
+
|
|
27
|
+
// Core structs.
|
|
28
|
+
import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
|
|
29
|
+
import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
|
|
30
|
+
import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
|
|
31
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
32
|
+
import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
|
|
33
|
+
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
34
|
+
import {JBFundAccessLimitGroup} from "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
|
|
35
|
+
import {JBCurrencyAmount} from "@bananapus/core-v6/src/structs/JBCurrencyAmount.sol";
|
|
36
|
+
|
|
37
|
+
// Core libraries.
|
|
38
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
39
|
+
|
|
40
|
+
// OZ.
|
|
41
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
42
|
+
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
|
|
43
|
+
|
|
44
|
+
// Permit2.
|
|
45
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
46
|
+
|
|
47
|
+
// Distributor.
|
|
48
|
+
import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
|
|
49
|
+
|
|
50
|
+
/// @notice Fork tests for JBTokenDistributor against real JB core on mainnet fork.
|
|
51
|
+
/// @dev Deploys full JB core, launches a project with JBERC20, and tests the complete
|
|
52
|
+
/// fund -> vest -> collect lifecycle using real IVotes checkpoints.
|
|
53
|
+
contract TokenDistributorForkTest is Test {
|
|
54
|
+
// -- JB core --
|
|
55
|
+
JBPermissions jbPermissions;
|
|
56
|
+
JBProjects jbProjects;
|
|
57
|
+
JBDirectory jbDirectory;
|
|
58
|
+
JBRulesets jbRulesets;
|
|
59
|
+
JBTokens jbTokens;
|
|
60
|
+
JBPrices jbPrices;
|
|
61
|
+
JBSplits jbSplits;
|
|
62
|
+
JBFundAccessLimits jbFundAccessLimits;
|
|
63
|
+
JBFeelessAddresses jbFeelessAddresses;
|
|
64
|
+
JBController jbController;
|
|
65
|
+
JBTerminalStore jbTerminalStore;
|
|
66
|
+
JBMultiTerminal jbMultiTerminal;
|
|
67
|
+
|
|
68
|
+
// -- Distributor --
|
|
69
|
+
JBTokenDistributor distributor;
|
|
70
|
+
|
|
71
|
+
// -- Actors --
|
|
72
|
+
address multisig;
|
|
73
|
+
address alice;
|
|
74
|
+
address bob;
|
|
75
|
+
address carol;
|
|
76
|
+
|
|
77
|
+
// -- Project state --
|
|
78
|
+
uint256 feeProjectId;
|
|
79
|
+
uint256 projectId;
|
|
80
|
+
IJBToken projectToken;
|
|
81
|
+
|
|
82
|
+
// -- Config --
|
|
83
|
+
uint256 constant ROUND_DURATION = 1 weeks;
|
|
84
|
+
uint256 constant VESTING_ROUNDS = 4;
|
|
85
|
+
|
|
86
|
+
// Mainnet Permit2.
|
|
87
|
+
IPermit2 constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
|
|
88
|
+
|
|
89
|
+
function setUp() public {
|
|
90
|
+
vm.createSelectFork("ethereum");
|
|
91
|
+
|
|
92
|
+
// Create labeled addresses and ensure they're clean EOAs (no mainnet code).
|
|
93
|
+
multisig = makeAddr("test_distributor_multisig");
|
|
94
|
+
alice = makeAddr("test_distributor_alice");
|
|
95
|
+
bob = makeAddr("test_distributor_bob");
|
|
96
|
+
carol = makeAddr("test_distributor_carol");
|
|
97
|
+
vm.etch(multisig, "");
|
|
98
|
+
vm.etch(alice, "");
|
|
99
|
+
vm.etch(bob, "");
|
|
100
|
+
vm.etch(carol, "");
|
|
101
|
+
|
|
102
|
+
_deployJBCore();
|
|
103
|
+
_deployDistributor();
|
|
104
|
+
|
|
105
|
+
// Launch fee project (must be project #1).
|
|
106
|
+
feeProjectId = _launchFeeProject();
|
|
107
|
+
assertEq(feeProjectId, 1, "fee project must be #1");
|
|
108
|
+
|
|
109
|
+
// Launch test project (no splits initially — will add after token deploy).
|
|
110
|
+
projectId = _launchProject();
|
|
111
|
+
|
|
112
|
+
// Deploy JBERC20 for the project.
|
|
113
|
+
vm.prank(multisig);
|
|
114
|
+
projectToken = jbController.deployERC20For(projectId, "Test Token", "TST", bytes32(0));
|
|
115
|
+
|
|
116
|
+
// Pay ETH into the project to build surplus (mints JBERC20 to payers).
|
|
117
|
+
_payProject(alice, 70 ether);
|
|
118
|
+
_payProject(bob, 30 ether);
|
|
119
|
+
|
|
120
|
+
// Delegates must delegate to themselves for IVotes checkpoints.
|
|
121
|
+
vm.prank(alice);
|
|
122
|
+
JBERC20(address(projectToken)).delegate(alice);
|
|
123
|
+
|
|
124
|
+
vm.prank(bob);
|
|
125
|
+
JBERC20(address(projectToken)).delegate(bob);
|
|
126
|
+
|
|
127
|
+
// Advance a block so getPastVotes works.
|
|
128
|
+
vm.roll(block.number + 1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ======================================================================
|
|
132
|
+
// TESTS
|
|
133
|
+
// ======================================================================
|
|
134
|
+
|
|
135
|
+
/// @notice Direct funding with real JBERC20 delegation: fund -> vest -> full collect.
|
|
136
|
+
function test_fork_directFund_vestCollect() public {
|
|
137
|
+
address hook = address(projectToken);
|
|
138
|
+
|
|
139
|
+
// Fund the distributor directly with ETH.
|
|
140
|
+
distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
|
|
141
|
+
|
|
142
|
+
// Advance to round 1.
|
|
143
|
+
_advanceToRound(1);
|
|
144
|
+
|
|
145
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
146
|
+
tokenIds[0] = _tokenId(alice);
|
|
147
|
+
tokenIds[1] = _tokenId(bob);
|
|
148
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
149
|
+
tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
|
|
150
|
+
|
|
151
|
+
distributor.beginVesting(hook, tokenIds, tokens);
|
|
152
|
+
|
|
153
|
+
uint256 aliceClaimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
|
|
154
|
+
uint256 bobClaimed = distributor.claimedFor(hook, _tokenId(bob), IERC20(JBConstants.NATIVE_TOKEN));
|
|
155
|
+
|
|
156
|
+
// Total = 10 ETH. Allocation proportional to voting power.
|
|
157
|
+
assertEq(aliceClaimed + bobClaimed, 10 ether, "Sum should equal funded amount");
|
|
158
|
+
assertGt(aliceClaimed, bobClaimed, "Alice should get more (70% vs 30%)");
|
|
159
|
+
|
|
160
|
+
// Full vest + collect.
|
|
161
|
+
_advanceToRound(1 + VESTING_ROUNDS);
|
|
162
|
+
|
|
163
|
+
uint256 aliceBefore = alice.balance;
|
|
164
|
+
vm.prank(alice);
|
|
165
|
+
distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
|
|
166
|
+
uint256 bobBefore = bob.balance;
|
|
167
|
+
vm.prank(bob);
|
|
168
|
+
distributor.collectVestedRewards(hook, _singleTokenId(bob), tokens, bob);
|
|
169
|
+
|
|
170
|
+
assertEq(alice.balance - aliceBefore, aliceClaimed, "Alice ETH collected");
|
|
171
|
+
assertEq(bob.balance - bobBefore, bobClaimed, "Bob ETH collected");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// @notice Undelegated tokens don't earn rewards — funds not allocated to undelegated holders.
|
|
175
|
+
function test_fork_undelegatedTokens_noRewards() public {
|
|
176
|
+
address hook = address(projectToken);
|
|
177
|
+
|
|
178
|
+
// Carol pays but does NOT delegate.
|
|
179
|
+
_payProject(carol, 50 ether);
|
|
180
|
+
vm.roll(block.number + 1);
|
|
181
|
+
|
|
182
|
+
distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
|
|
183
|
+
_advanceToRound(1);
|
|
184
|
+
|
|
185
|
+
uint256[] memory tokenIds = new uint256[](3);
|
|
186
|
+
tokenIds[0] = _tokenId(alice);
|
|
187
|
+
tokenIds[1] = _tokenId(bob);
|
|
188
|
+
tokenIds[2] = _tokenId(carol);
|
|
189
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
190
|
+
tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
|
|
191
|
+
|
|
192
|
+
distributor.beginVesting(hook, tokenIds, tokens);
|
|
193
|
+
|
|
194
|
+
// Carol should have 0 claimed (no delegation).
|
|
195
|
+
uint256 carolClaimed = distributor.claimedFor(hook, _tokenId(carol), IERC20(JBConstants.NATIVE_TOKEN));
|
|
196
|
+
assertEq(carolClaimed, 0, "Carol should have 0 (not delegated)");
|
|
197
|
+
|
|
198
|
+
// Undelegated portion (Carol's supply) dilutes total supply, reducing Alice/Bob allocations.
|
|
199
|
+
uint256 totalVesting = distributor.totalVestingAmountOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
|
|
200
|
+
assertLt(totalVesting, 10 ether, "Not all funds distributed (undelegated supply dilutes)");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/// @notice Partial vesting — collect mid-way through vesting period.
|
|
204
|
+
function test_fork_partialVesting_linearUnlock() public {
|
|
205
|
+
address hook = address(projectToken);
|
|
206
|
+
distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
|
|
207
|
+
_advanceToRound(1);
|
|
208
|
+
|
|
209
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
210
|
+
tokenIds[0] = _tokenId(alice);
|
|
211
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
212
|
+
tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
|
|
213
|
+
|
|
214
|
+
distributor.beginVesting(hook, tokenIds, tokens);
|
|
215
|
+
|
|
216
|
+
uint256 aliceClaimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
|
|
217
|
+
assertGt(aliceClaimed, 0, "Alice claimed something");
|
|
218
|
+
|
|
219
|
+
// After 2 of 4 vesting rounds, 50% collectable.
|
|
220
|
+
_advanceToRound(3);
|
|
221
|
+
uint256 collectable = distributor.collectableFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
|
|
222
|
+
assertApproxEqAbs(collectable, aliceClaimed / 2, 1, "~50% collectable at midpoint");
|
|
223
|
+
|
|
224
|
+
// Collect partial.
|
|
225
|
+
uint256 aliceBefore = alice.balance;
|
|
226
|
+
vm.prank(alice);
|
|
227
|
+
distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
|
|
228
|
+
assertApproxEqAbs(alice.balance - aliceBefore, aliceClaimed / 2, 1, "Alice gets ~50%");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// @notice Multi-round: fund in round 1, fund more in round 3, collect all.
|
|
232
|
+
function test_fork_multiRound_carryOverAndNewFunding() public {
|
|
233
|
+
address hook = address(projectToken);
|
|
234
|
+
|
|
235
|
+
// Fund round 1.
|
|
236
|
+
distributor.fund{value: 5 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 5 ether);
|
|
237
|
+
_advanceToRound(1);
|
|
238
|
+
|
|
239
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
240
|
+
tokenIds[0] = _tokenId(alice);
|
|
241
|
+
tokenIds[1] = _tokenId(bob);
|
|
242
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
243
|
+
tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
|
|
244
|
+
|
|
245
|
+
distributor.beginVesting(hook, tokenIds, tokens);
|
|
246
|
+
|
|
247
|
+
uint256 round1AliceClaimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
|
|
248
|
+
|
|
249
|
+
// Fund more in round 3.
|
|
250
|
+
_advanceToRound(3);
|
|
251
|
+
distributor.fund{value: 5 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 5 ether);
|
|
252
|
+
|
|
253
|
+
// Begin vesting round 3 funds.
|
|
254
|
+
_advanceToRound(4);
|
|
255
|
+
distributor.beginVesting(hook, tokenIds, tokens);
|
|
256
|
+
|
|
257
|
+
// Advance past both vesting periods.
|
|
258
|
+
_advanceToRound(1 + VESTING_ROUNDS + VESTING_ROUNDS);
|
|
259
|
+
|
|
260
|
+
// Collect all.
|
|
261
|
+
uint256 aliceBefore = alice.balance;
|
|
262
|
+
vm.prank(alice);
|
|
263
|
+
distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
|
|
264
|
+
uint256 aliceCollected = alice.balance - aliceBefore;
|
|
265
|
+
|
|
266
|
+
uint256 bobBefore = bob.balance;
|
|
267
|
+
vm.prank(bob);
|
|
268
|
+
distributor.collectVestedRewards(hook, _singleTokenId(bob), tokens, bob);
|
|
269
|
+
uint256 bobCollected = bob.balance - bobBefore;
|
|
270
|
+
|
|
271
|
+
// Conservation: alice + bob should account for all distributed funds.
|
|
272
|
+
uint256 totalCollected = aliceCollected + bobCollected;
|
|
273
|
+
assertGt(totalCollected, 0, "Non-zero collection");
|
|
274
|
+
assertLe(totalCollected, 10 ether, "Cannot collect more than funded");
|
|
275
|
+
// Alice got rewards from both rounds.
|
|
276
|
+
assertGt(aliceCollected, round1AliceClaimed, "Alice got more than just round 1");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// @notice Split integration: queue a ruleset with distributor split, trigger payout.
|
|
280
|
+
function test_fork_payoutSplit_fundsDistributor() public {
|
|
281
|
+
address hook = address(projectToken);
|
|
282
|
+
|
|
283
|
+
// Queue a new ruleset with splits configured.
|
|
284
|
+
_queueRulesetWithDistributorSplit(hook);
|
|
285
|
+
|
|
286
|
+
// Pay more ETH into the project to build payout balance.
|
|
287
|
+
_payProject(alice, 20 ether);
|
|
288
|
+
vm.roll(block.number + 1);
|
|
289
|
+
|
|
290
|
+
// Trigger payouts.
|
|
291
|
+
vm.prank(multisig);
|
|
292
|
+
jbMultiTerminal.sendPayoutsOf({
|
|
293
|
+
projectId: projectId,
|
|
294
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
295
|
+
amount: 10 ether,
|
|
296
|
+
currency: uint256(uint160(JBConstants.NATIVE_TOKEN)),
|
|
297
|
+
minTokensPaidOut: 0
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Verify distributor received funds.
|
|
301
|
+
uint256 distributorBalance = distributor.balanceOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
|
|
302
|
+
assertGt(distributorBalance, 0, "Distributor should have received ETH from payout");
|
|
303
|
+
|
|
304
|
+
// Vest and collect.
|
|
305
|
+
_advanceToRound(1);
|
|
306
|
+
|
|
307
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
308
|
+
tokenIds[0] = _tokenId(alice);
|
|
309
|
+
tokenIds[1] = _tokenId(bob);
|
|
310
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
311
|
+
tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
|
|
312
|
+
|
|
313
|
+
distributor.beginVesting(hook, tokenIds, tokens);
|
|
314
|
+
|
|
315
|
+
uint256 aliceClaimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
|
|
316
|
+
assertGt(aliceClaimed, 0, "Alice claimed from payout-funded distributor");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/// @notice Poke records snapshot blocks correctly and is idempotent.
|
|
320
|
+
function test_fork_poke_snapshotConsistency() public {
|
|
321
|
+
_advanceToRound(1);
|
|
322
|
+
|
|
323
|
+
uint256 blockBefore = block.number;
|
|
324
|
+
distributor.poke();
|
|
325
|
+
|
|
326
|
+
uint256 snapshotBlock = distributor.roundSnapshotBlock(1);
|
|
327
|
+
assertEq(snapshotBlock, blockBefore - 1, "Snapshot = block.number - 1");
|
|
328
|
+
|
|
329
|
+
// Next round should also be eagerly locked.
|
|
330
|
+
uint256 nextSnapshot = distributor.roundSnapshotBlock(2);
|
|
331
|
+
assertEq(nextSnapshot, blockBefore - 1, "Eager lock round+1");
|
|
332
|
+
|
|
333
|
+
// Idempotent.
|
|
334
|
+
vm.roll(block.number + 10);
|
|
335
|
+
distributor.poke();
|
|
336
|
+
assertEq(distributor.roundSnapshotBlock(1), snapshotBlock, "Poke idempotent");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// @notice Cannot collect another staker's rewards.
|
|
340
|
+
function test_fork_cannotCollectOthersRewards() public {
|
|
341
|
+
address hook = address(projectToken);
|
|
342
|
+
distributor.fund{value: 5 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 5 ether);
|
|
343
|
+
_advanceToRound(1);
|
|
344
|
+
|
|
345
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
346
|
+
tokenIds[0] = _tokenId(alice);
|
|
347
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
348
|
+
tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
|
|
349
|
+
distributor.beginVesting(hook, tokenIds, tokens);
|
|
350
|
+
|
|
351
|
+
_advanceToRound(1 + VESTING_ROUNDS);
|
|
352
|
+
|
|
353
|
+
// Bob tries to collect Alice's rewards.
|
|
354
|
+
vm.prank(bob);
|
|
355
|
+
vm.expectRevert();
|
|
356
|
+
distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, bob);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/// @notice Auto-vest: calling collectVestedRewards without explicit beginVesting still works.
|
|
360
|
+
function test_fork_autoVest_collectWithoutExplicitBeginVesting() public {
|
|
361
|
+
address hook = address(projectToken);
|
|
362
|
+
distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
|
|
363
|
+
|
|
364
|
+
// Skip beginVesting — go straight to collect.
|
|
365
|
+
_advanceToRound(1 + VESTING_ROUNDS);
|
|
366
|
+
|
|
367
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
368
|
+
tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
|
|
369
|
+
|
|
370
|
+
vm.prank(alice);
|
|
371
|
+
distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
|
|
372
|
+
|
|
373
|
+
// Auto-vest should have kicked in — Alice got some rewards.
|
|
374
|
+
uint256 claimed = distributor.claimedFor(hook, _tokenId(alice), IERC20(JBConstants.NATIVE_TOKEN));
|
|
375
|
+
assertGt(claimed, 0, "Auto-vest should have created a vesting entry");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/// @notice Invariant: totalVestingAmount never exceeds balance.
|
|
379
|
+
function test_fork_conservationInvariant() public {
|
|
380
|
+
address hook = address(projectToken);
|
|
381
|
+
distributor.fund{value: 10 ether}(hook, IERC20(JBConstants.NATIVE_TOKEN), 10 ether);
|
|
382
|
+
_advanceToRound(1);
|
|
383
|
+
|
|
384
|
+
uint256[] memory tokenIds = new uint256[](2);
|
|
385
|
+
tokenIds[0] = _tokenId(alice);
|
|
386
|
+
tokenIds[1] = _tokenId(bob);
|
|
387
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
388
|
+
tokens[0] = IERC20(JBConstants.NATIVE_TOKEN);
|
|
389
|
+
|
|
390
|
+
distributor.beginVesting(hook, tokenIds, tokens);
|
|
391
|
+
|
|
392
|
+
uint256 totalVesting = distributor.totalVestingAmountOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
|
|
393
|
+
uint256 balance = distributor.balanceOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
|
|
394
|
+
assertLe(totalVesting, balance, "Vesting <= balance (conservation)");
|
|
395
|
+
|
|
396
|
+
// Partially collect.
|
|
397
|
+
_advanceToRound(3);
|
|
398
|
+
vm.prank(alice);
|
|
399
|
+
distributor.collectVestedRewards(hook, _singleTokenId(alice), tokens, alice);
|
|
400
|
+
|
|
401
|
+
totalVesting = distributor.totalVestingAmountOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
|
|
402
|
+
balance = distributor.balanceOf(hook, IERC20(JBConstants.NATIVE_TOKEN));
|
|
403
|
+
assertLe(totalVesting, balance, "Vesting <= balance after partial collect");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ======================================================================
|
|
407
|
+
// HELPERS
|
|
408
|
+
// ======================================================================
|
|
409
|
+
|
|
410
|
+
function _tokenId(address staker) internal pure returns (uint256) {
|
|
411
|
+
return uint256(uint160(staker));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function _singleTokenId(address staker) internal pure returns (uint256[] memory ids) {
|
|
415
|
+
ids = new uint256[](1);
|
|
416
|
+
ids[0] = _tokenId(staker);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function _advanceToRound(uint256 round) internal {
|
|
420
|
+
uint256 target = distributor.roundStartTimestamp(round) + 1;
|
|
421
|
+
if (block.timestamp < target) vm.warp(target);
|
|
422
|
+
vm.roll(block.number + 1);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function _payProject(address payer, uint256 amount) internal {
|
|
426
|
+
vm.deal(payer, amount);
|
|
427
|
+
vm.prank(payer);
|
|
428
|
+
jbMultiTerminal.pay{value: amount}({
|
|
429
|
+
projectId: projectId,
|
|
430
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
431
|
+
amount: amount,
|
|
432
|
+
beneficiary: payer,
|
|
433
|
+
minReturnedTokens: 0,
|
|
434
|
+
memo: "",
|
|
435
|
+
metadata: ""
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ======================================================================
|
|
440
|
+
// DEPLOYMENT
|
|
441
|
+
// ======================================================================
|
|
442
|
+
|
|
443
|
+
function _deployJBCore() internal {
|
|
444
|
+
jbPermissions = new JBPermissions(address(0));
|
|
445
|
+
jbProjects = new JBProjects(multisig, address(0), address(0));
|
|
446
|
+
jbDirectory = new JBDirectory(jbPermissions, jbProjects, multisig);
|
|
447
|
+
|
|
448
|
+
JBERC20 jbErc20 = new JBERC20(jbPermissions, jbProjects);
|
|
449
|
+
jbTokens = new JBTokens(jbDirectory, jbErc20);
|
|
450
|
+
|
|
451
|
+
jbRulesets = new JBRulesets(jbDirectory);
|
|
452
|
+
jbPrices = new JBPrices(jbDirectory, jbPermissions, jbProjects, multisig, address(0));
|
|
453
|
+
|
|
454
|
+
jbSplits = new JBSplits(jbDirectory);
|
|
455
|
+
jbFundAccessLimits = new JBFundAccessLimits(jbDirectory);
|
|
456
|
+
jbFeelessAddresses = new JBFeelessAddresses(multisig);
|
|
457
|
+
|
|
458
|
+
jbController = new JBController(
|
|
459
|
+
jbDirectory,
|
|
460
|
+
jbFundAccessLimits,
|
|
461
|
+
jbPermissions,
|
|
462
|
+
jbPrices,
|
|
463
|
+
jbProjects,
|
|
464
|
+
jbRulesets,
|
|
465
|
+
jbSplits,
|
|
466
|
+
jbTokens,
|
|
467
|
+
address(0),
|
|
468
|
+
address(0)
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
vm.prank(multisig);
|
|
472
|
+
jbDirectory.setIsAllowedToSetFirstController(address(jbController), true);
|
|
473
|
+
|
|
474
|
+
jbTerminalStore = new JBTerminalStore(jbDirectory, jbPrices, jbRulesets);
|
|
475
|
+
|
|
476
|
+
jbMultiTerminal = new JBMultiTerminal(
|
|
477
|
+
jbFeelessAddresses, jbPermissions, jbProjects, jbSplits, jbTerminalStore, jbTokens, PERMIT2, address(0)
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function _deployDistributor() internal {
|
|
482
|
+
distributor = new JBTokenDistributor(IJBDirectory(address(jbDirectory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
483
|
+
|
|
484
|
+
// Mark the distributor as feeless so payouts to it aren't reduced by the 2.5% fee.
|
|
485
|
+
vm.prank(multisig);
|
|
486
|
+
jbFeelessAddresses.setFeelessAddress(address(distributor), true);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function _launchFeeProject() internal returns (uint256) {
|
|
490
|
+
JBRulesetConfig[] memory rulesets = new JBRulesetConfig[](1);
|
|
491
|
+
rulesets[0] = _basicRulesetConfig(new JBSplitGroup[](0), new JBFundAccessLimitGroup[](0));
|
|
492
|
+
|
|
493
|
+
JBTerminalConfig[] memory terminals = new JBTerminalConfig[](1);
|
|
494
|
+
terminals[0] = JBTerminalConfig({terminal: jbMultiTerminal, accountingContextsToAccept: _ethContext()});
|
|
495
|
+
|
|
496
|
+
vm.prank(multisig);
|
|
497
|
+
return jbController.launchProjectFor(multisig, "", rulesets, terminals, "");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function _launchProject() internal returns (uint256) {
|
|
501
|
+
// Payout limit: allows sending 10 ETH in payouts.
|
|
502
|
+
JBCurrencyAmount[] memory payoutLimits = new JBCurrencyAmount[](1);
|
|
503
|
+
payoutLimits[0] = JBCurrencyAmount({amount: 100 ether, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
|
|
504
|
+
|
|
505
|
+
JBFundAccessLimitGroup[] memory fundLimits = new JBFundAccessLimitGroup[](1);
|
|
506
|
+
fundLimits[0] = JBFundAccessLimitGroup({
|
|
507
|
+
terminal: address(jbMultiTerminal),
|
|
508
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
509
|
+
payoutLimits: payoutLimits,
|
|
510
|
+
surplusAllowances: new JBCurrencyAmount[](0)
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
JBRulesetConfig[] memory rulesets = new JBRulesetConfig[](1);
|
|
514
|
+
rulesets[0] = _basicRulesetConfig(new JBSplitGroup[](0), fundLimits);
|
|
515
|
+
|
|
516
|
+
JBTerminalConfig[] memory terminals = new JBTerminalConfig[](1);
|
|
517
|
+
terminals[0] = JBTerminalConfig({terminal: jbMultiTerminal, accountingContextsToAccept: _ethContext()});
|
|
518
|
+
|
|
519
|
+
vm.prank(multisig);
|
|
520
|
+
return jbController.launchProjectFor(multisig, "", rulesets, terminals, "");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function _queueRulesetWithDistributorSplit(address hook) internal {
|
|
524
|
+
JBSplit[] memory splits = new JBSplit[](1);
|
|
525
|
+
splits[0] = JBSplit({
|
|
526
|
+
percent: 1_000_000_000,
|
|
527
|
+
projectId: 0,
|
|
528
|
+
beneficiary: payable(hook), // The IVotes token address.
|
|
529
|
+
preferAddToBalance: false,
|
|
530
|
+
lockedUntil: 0,
|
|
531
|
+
hook: IJBSplitHook(address(distributor))
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
JBSplitGroup[] memory splitGroups = new JBSplitGroup[](1);
|
|
535
|
+
splitGroups[0] = JBSplitGroup({groupId: uint256(uint160(JBConstants.NATIVE_TOKEN)), splits: splits});
|
|
536
|
+
|
|
537
|
+
JBCurrencyAmount[] memory payoutLimits = new JBCurrencyAmount[](1);
|
|
538
|
+
payoutLimits[0] = JBCurrencyAmount({amount: 100 ether, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
|
|
539
|
+
|
|
540
|
+
JBFundAccessLimitGroup[] memory fundLimits = new JBFundAccessLimitGroup[](1);
|
|
541
|
+
fundLimits[0] = JBFundAccessLimitGroup({
|
|
542
|
+
terminal: address(jbMultiTerminal),
|
|
543
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
544
|
+
payoutLimits: payoutLimits,
|
|
545
|
+
surplusAllowances: new JBCurrencyAmount[](0)
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
JBRulesetConfig[] memory rulesets = new JBRulesetConfig[](1);
|
|
549
|
+
rulesets[0] = _basicRulesetConfig(splitGroups, fundLimits);
|
|
550
|
+
|
|
551
|
+
vm.prank(multisig);
|
|
552
|
+
jbController.queueRulesetsOf(projectId, rulesets, "");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function _basicRulesetConfig(
|
|
556
|
+
JBSplitGroup[] memory splitGroups,
|
|
557
|
+
JBFundAccessLimitGroup[] memory fundLimits
|
|
558
|
+
)
|
|
559
|
+
internal
|
|
560
|
+
pure
|
|
561
|
+
returns (JBRulesetConfig memory)
|
|
562
|
+
{
|
|
563
|
+
JBRulesetMetadata memory metadata = JBRulesetMetadata({
|
|
564
|
+
reservedPercent: 0,
|
|
565
|
+
cashOutTaxRate: 0,
|
|
566
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
567
|
+
pausePay: false,
|
|
568
|
+
pauseCreditTransfers: false,
|
|
569
|
+
allowOwnerMinting: true,
|
|
570
|
+
allowSetCustomToken: true,
|
|
571
|
+
allowTerminalMigration: false,
|
|
572
|
+
allowSetTerminals: false,
|
|
573
|
+
allowSetController: false,
|
|
574
|
+
allowAddAccountingContext: false,
|
|
575
|
+
allowAddPriceFeed: false,
|
|
576
|
+
ownerMustSendPayouts: false,
|
|
577
|
+
holdFees: false,
|
|
578
|
+
useTotalSurplusForCashOuts: false,
|
|
579
|
+
useDataHookForPay: false,
|
|
580
|
+
useDataHookForCashOut: false,
|
|
581
|
+
dataHook: address(0),
|
|
582
|
+
metadata: 0
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return JBRulesetConfig({
|
|
586
|
+
mustStartAtOrAfter: 0,
|
|
587
|
+
duration: 0,
|
|
588
|
+
weight: 1_000_000e18,
|
|
589
|
+
weightCutPercent: 0,
|
|
590
|
+
approvalHook: IJBRulesetApprovalHook(address(0)),
|
|
591
|
+
metadata: metadata,
|
|
592
|
+
splitGroups: splitGroups,
|
|
593
|
+
fundAccessLimitGroups: fundLimits
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function _ethContext() internal pure returns (JBAccountingContext[] memory contexts) {
|
|
598
|
+
contexts = new JBAccountingContext[](1);
|
|
599
|
+
contexts[0] = JBAccountingContext({
|
|
600
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
}
|