@bananapus/distributor-v6 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/pull_request_template.md +33 -0
- package/.github/workflows/lint.yml +19 -0
- package/.github/workflows/publish.yml +19 -0
- package/.github/workflows/slither.yml +23 -0
- package/.github/workflows/test.yml +26 -0
- package/.gitmodules +3 -0
- package/ADMINISTRATION.md +65 -0
- package/ARCHITECTURE.md +89 -0
- package/AUDIT_INSTRUCTIONS.md +52 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/RISKS.md +78 -0
- package/SKILLS.md +36 -0
- package/USER_JOURNEYS.md +120 -0
- package/foundry.toml +22 -0
- package/package.json +29 -0
- package/references/operations.md +25 -0
- package/references/runtime.md +36 -0
- package/remappings.txt +1 -0
- package/script/Deploy.s.sol +23 -0
- package/slither-ci.config.json +10 -0
- package/src/JB721Distributor.sol +180 -0
- package/src/JBDistributor.sol +563 -0
- package/src/JBTokenDistributor.sol +160 -0
- package/src/interfaces/IJB721Distributor.sol +15 -0
- package/src/interfaces/IJBDistributor.sol +138 -0
- package/src/interfaces/IJBTokenDistributor.sol +16 -0
- package/src/structs/JBTokenSnapshotData.sol +9 -0
- package/src/structs/JBVestingData.sol +11 -0
- package/test/JB721Distributor.t.sol +1985 -0
- package/test/JBTokenDistributor.t.sol +424 -0
- package/test/invariant/JB721DistributorInvariant.t.sol +410 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
import {StdInvariant} from "forge-std/StdInvariant.sol";
|
|
6
|
+
|
|
7
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
8
|
+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
9
|
+
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
10
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
11
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
12
|
+
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
13
|
+
import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
|
|
14
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
15
|
+
|
|
16
|
+
import {JB721Distributor} from "../../src/JB721Distributor.sol";
|
|
17
|
+
import {JBDistributor} from "../../src/JBDistributor.sol";
|
|
18
|
+
|
|
19
|
+
/// @notice Simple ERC20 token for invariant testing.
|
|
20
|
+
contract InvariantToken is ERC20 {
|
|
21
|
+
constructor() ERC20("Reward", "RWD") {}
|
|
22
|
+
|
|
23
|
+
function mint(address to, uint256 amount) external {
|
|
24
|
+
_mint(to, amount);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// @notice Mock 721 tiers hook for invariant testing.
|
|
29
|
+
contract InvariantMockHook {
|
|
30
|
+
InvariantMockStore public immutable _store;
|
|
31
|
+
|
|
32
|
+
mapping(uint256 tokenId => address owner) public owners;
|
|
33
|
+
|
|
34
|
+
constructor(InvariantMockStore store) {
|
|
35
|
+
_store = store;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function STORE() external view returns (InvariantMockStore) {
|
|
39
|
+
return _store;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ownerOf(uint256 tokenId) external view returns (address) {
|
|
43
|
+
address owner = owners[tokenId];
|
|
44
|
+
require(owner != address(0), "ERC721: invalid token ID");
|
|
45
|
+
return owner;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
49
|
+
owners[tokenId] = owner;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function burn(uint256 tokenId) external {
|
|
53
|
+
delete owners[tokenId];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// @notice Mock 721 tiers hook store for invariant testing.
|
|
58
|
+
contract InvariantMockStore {
|
|
59
|
+
uint256 public maxTier;
|
|
60
|
+
mapping(uint256 tierId => JB721Tier) public tiers;
|
|
61
|
+
mapping(uint256 tierId => uint256) public burned;
|
|
62
|
+
mapping(uint256 tokenId => uint256 tierId) public tokenTiers;
|
|
63
|
+
|
|
64
|
+
function setMaxTierIdOf(uint256 maxTierId) external {
|
|
65
|
+
maxTier = maxTierId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function maxTierIdOf(address) external view returns (uint256) {
|
|
69
|
+
return maxTier;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setTier(uint256 tierId, JB721Tier memory tier) external {
|
|
73
|
+
tiers[tierId] = tier;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function tierOf(address, uint256 id, bool) external view returns (JB721Tier memory) {
|
|
77
|
+
return tiers[id];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function setTokenTier(uint256 tokenId, uint256 tierId) external {
|
|
81
|
+
tokenTiers[tokenId] = tierId;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
85
|
+
return tiers[tokenTiers[tokenId]];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function setBurnedFor(uint256 tierId, uint256 count) external {
|
|
89
|
+
burned[tierId] = count;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function numberOfBurnedFor(address, uint256 tierId) external view returns (uint256) {
|
|
93
|
+
return burned[tierId];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// @notice Mock JB directory for invariant testing.
|
|
98
|
+
contract InvariantMockDirectory {
|
|
99
|
+
mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
|
|
100
|
+
mapping(uint256 projectId => address controller) public controllers;
|
|
101
|
+
|
|
102
|
+
function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
|
|
103
|
+
terminals[projectId][terminal] = isTerminal;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function setController(uint256 projectId, address controller) external {
|
|
107
|
+
controllers[projectId] = controller;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
|
|
111
|
+
return terminals[projectId][address(terminal)];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function controllerOf(uint256 projectId) external view returns (IERC165) {
|
|
115
|
+
return IERC165(controllers[projectId]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// @notice Handler that randomly sequences operations against the distributor.
|
|
120
|
+
contract DistributorHandler is Test {
|
|
121
|
+
JB721Distributor public distributor;
|
|
122
|
+
InvariantToken public rewardToken;
|
|
123
|
+
InvariantMockHook public hook;
|
|
124
|
+
InvariantMockStore public store;
|
|
125
|
+
|
|
126
|
+
// Actors.
|
|
127
|
+
address public alice;
|
|
128
|
+
address public bob;
|
|
129
|
+
|
|
130
|
+
// Ghost variables for tracking invariants.
|
|
131
|
+
uint256 public ghost_totalFunded;
|
|
132
|
+
uint256 public ghost_totalCollectedByAlice;
|
|
133
|
+
uint256 public ghost_totalCollectedByBob;
|
|
134
|
+
uint256 public ghost_vestingCalls;
|
|
135
|
+
uint256 public ghost_collectCalls;
|
|
136
|
+
uint256 public ghost_forfeitCalls;
|
|
137
|
+
uint256 public ghost_fundCalls;
|
|
138
|
+
uint256 public ghost_rollCalls;
|
|
139
|
+
|
|
140
|
+
// Track whether tokens are burned.
|
|
141
|
+
bool public token1Burned;
|
|
142
|
+
bool public token2Burned;
|
|
143
|
+
|
|
144
|
+
// Track latest round we vested in per tokenId to avoid AlreadyVesting.
|
|
145
|
+
mapping(uint256 tokenId => uint256 lastVestedRound) public lastVestedRoundOf;
|
|
146
|
+
|
|
147
|
+
uint256 constant ROUND_DURATION = 100;
|
|
148
|
+
|
|
149
|
+
constructor(
|
|
150
|
+
JB721Distributor _distributor,
|
|
151
|
+
InvariantToken _rewardToken,
|
|
152
|
+
InvariantMockHook _hook,
|
|
153
|
+
InvariantMockStore _store,
|
|
154
|
+
address _alice,
|
|
155
|
+
address _bob
|
|
156
|
+
) {
|
|
157
|
+
distributor = _distributor;
|
|
158
|
+
rewardToken = _rewardToken;
|
|
159
|
+
hook = _hook;
|
|
160
|
+
store = _store;
|
|
161
|
+
alice = _alice;
|
|
162
|
+
bob = _bob;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// @notice Fund the distributor with random amount.
|
|
166
|
+
function fund(uint128 rawAmount) external {
|
|
167
|
+
uint256 amount = bound(rawAmount, 0.01 ether, 100 ether);
|
|
168
|
+
rewardToken.mint(address(this), amount);
|
|
169
|
+
rewardToken.approve(address(distributor), amount);
|
|
170
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), amount);
|
|
171
|
+
ghost_totalFunded += amount;
|
|
172
|
+
ghost_fundCalls++;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// @notice Advance blocks by random amount (0-3 rounds).
|
|
176
|
+
function rollForward(uint8 rawRounds) external {
|
|
177
|
+
uint256 rounds = bound(rawRounds, 0, 3);
|
|
178
|
+
if (rounds > 0) {
|
|
179
|
+
vm.roll(block.number + ROUND_DURATION * rounds);
|
|
180
|
+
}
|
|
181
|
+
ghost_rollCalls++;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/// @notice Begin vesting for one or both tokens.
|
|
185
|
+
function beginVesting(uint8 tokenSelector) external {
|
|
186
|
+
uint256 currentRound = distributor.currentRound();
|
|
187
|
+
|
|
188
|
+
// Determine which tokens to vest (avoid AlreadyVesting).
|
|
189
|
+
bool vest1 = !token1Burned && (tokenSelector % 3 != 1) && lastVestedRoundOf[1] != currentRound;
|
|
190
|
+
bool vest2 = !token2Burned && (tokenSelector % 3 != 0) && lastVestedRoundOf[2] != currentRound;
|
|
191
|
+
|
|
192
|
+
if (!vest1 && !vest2) return;
|
|
193
|
+
|
|
194
|
+
uint256 count;
|
|
195
|
+
if (vest1) count++;
|
|
196
|
+
if (vest2) count++;
|
|
197
|
+
|
|
198
|
+
uint256[] memory tokenIds = new uint256[](count);
|
|
199
|
+
uint256 idx;
|
|
200
|
+
if (vest1) {
|
|
201
|
+
tokenIds[idx++] = 1;
|
|
202
|
+
lastVestedRoundOf[1] = currentRound;
|
|
203
|
+
}
|
|
204
|
+
if (vest2) {
|
|
205
|
+
tokenIds[idx++] = 2;
|
|
206
|
+
lastVestedRoundOf[2] = currentRound;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
210
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
211
|
+
|
|
212
|
+
// Only vest if there's a balance to distribute.
|
|
213
|
+
if (
|
|
214
|
+
distributor.balanceOf(address(hook), IERC20(address(rewardToken)))
|
|
215
|
+
> distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken)))
|
|
216
|
+
) {
|
|
217
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
218
|
+
ghost_vestingCalls++;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/// @notice Collect vested rewards for alice (token 1).
|
|
223
|
+
function collectAlice() external {
|
|
224
|
+
if (token1Burned) return;
|
|
225
|
+
|
|
226
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
227
|
+
tokenIds[0] = 1;
|
|
228
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
229
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
230
|
+
|
|
231
|
+
uint256 balanceBefore = rewardToken.balanceOf(alice);
|
|
232
|
+
|
|
233
|
+
vm.prank(alice);
|
|
234
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
|
|
235
|
+
|
|
236
|
+
ghost_totalCollectedByAlice += rewardToken.balanceOf(alice) - balanceBefore;
|
|
237
|
+
ghost_collectCalls++;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// @notice Collect vested rewards for bob (token 2).
|
|
241
|
+
function collectBob() external {
|
|
242
|
+
if (token2Burned) return;
|
|
243
|
+
|
|
244
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
245
|
+
tokenIds[0] = 2;
|
|
246
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
247
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
248
|
+
|
|
249
|
+
uint256 balanceBefore = rewardToken.balanceOf(bob);
|
|
250
|
+
|
|
251
|
+
vm.prank(bob);
|
|
252
|
+
distributor.collectVestedRewards(address(hook), tokenIds, tokens, bob);
|
|
253
|
+
|
|
254
|
+
ghost_totalCollectedByBob += rewardToken.balanceOf(bob) - balanceBefore;
|
|
255
|
+
ghost_collectCalls++;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// @notice Burn token 1 and release forfeited rewards.
|
|
259
|
+
function burnAndForfeit1() external {
|
|
260
|
+
if (token1Burned) return;
|
|
261
|
+
|
|
262
|
+
hook.burn(1);
|
|
263
|
+
token1Burned = true;
|
|
264
|
+
store.setBurnedFor(1, 1); // Tier 1 now has 1 burned.
|
|
265
|
+
|
|
266
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
267
|
+
tokenIds[0] = 1;
|
|
268
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
269
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
270
|
+
|
|
271
|
+
distributor.releaseForfeitedRewards(address(hook), tokenIds, tokens, address(0));
|
|
272
|
+
ghost_forfeitCalls++;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
contract JB721DistributorInvariantTest is StdInvariant, Test {
|
|
277
|
+
JB721Distributor distributor;
|
|
278
|
+
InvariantToken rewardToken;
|
|
279
|
+
InvariantMockHook hook;
|
|
280
|
+
InvariantMockStore store;
|
|
281
|
+
InvariantMockDirectory directory;
|
|
282
|
+
DistributorHandler handler;
|
|
283
|
+
|
|
284
|
+
address alice = makeAddr("alice");
|
|
285
|
+
address bob = makeAddr("bob");
|
|
286
|
+
|
|
287
|
+
uint256 constant ROUND_DURATION = 100;
|
|
288
|
+
uint256 constant VESTING_ROUNDS = 4;
|
|
289
|
+
|
|
290
|
+
function setUp() public {
|
|
291
|
+
store = new InvariantMockStore();
|
|
292
|
+
hook = new InvariantMockHook(store);
|
|
293
|
+
directory = new InvariantMockDirectory();
|
|
294
|
+
|
|
295
|
+
distributor = new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
296
|
+
|
|
297
|
+
rewardToken = new InvariantToken();
|
|
298
|
+
|
|
299
|
+
JB721TierFlags memory flags;
|
|
300
|
+
|
|
301
|
+
store.setMaxTierIdOf(2);
|
|
302
|
+
|
|
303
|
+
store.setTier(
|
|
304
|
+
1,
|
|
305
|
+
JB721Tier({
|
|
306
|
+
id: 1,
|
|
307
|
+
price: 1 ether,
|
|
308
|
+
remainingSupply: 8,
|
|
309
|
+
initialSupply: 10,
|
|
310
|
+
votingUnits: 100,
|
|
311
|
+
reserveFrequency: 0,
|
|
312
|
+
reserveBeneficiary: address(0),
|
|
313
|
+
encodedIPFSUri: bytes32(0),
|
|
314
|
+
category: 0,
|
|
315
|
+
discountPercent: 0,
|
|
316
|
+
flags: flags,
|
|
317
|
+
splitPercent: 0,
|
|
318
|
+
resolvedUri: ""
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
store.setTier(
|
|
323
|
+
2,
|
|
324
|
+
JB721Tier({
|
|
325
|
+
id: 2,
|
|
326
|
+
price: 2 ether,
|
|
327
|
+
remainingSupply: 4,
|
|
328
|
+
initialSupply: 5,
|
|
329
|
+
votingUnits: 200,
|
|
330
|
+
reserveFrequency: 0,
|
|
331
|
+
reserveBeneficiary: address(0),
|
|
332
|
+
encodedIPFSUri: bytes32(0),
|
|
333
|
+
category: 0,
|
|
334
|
+
discountPercent: 0,
|
|
335
|
+
flags: flags,
|
|
336
|
+
splitPercent: 0,
|
|
337
|
+
resolvedUri: ""
|
|
338
|
+
})
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
store.setTokenTier(1, 1);
|
|
342
|
+
hook.setOwner(1, alice);
|
|
343
|
+
store.setTokenTier(2, 2);
|
|
344
|
+
hook.setOwner(2, bob);
|
|
345
|
+
|
|
346
|
+
handler = new DistributorHandler(distributor, rewardToken, hook, store, alice, bob);
|
|
347
|
+
|
|
348
|
+
// Target only the handler for invariant calls.
|
|
349
|
+
targetContract(address(handler));
|
|
350
|
+
|
|
351
|
+
// Seed with some initial funds so early vesting calls work.
|
|
352
|
+
rewardToken.mint(address(handler), 10 ether);
|
|
353
|
+
rewardToken.approve(address(distributor), type(uint256).max);
|
|
354
|
+
vm.prank(address(handler));
|
|
355
|
+
rewardToken.approve(address(distributor), type(uint256).max);
|
|
356
|
+
handler.fund(10); // Track in ghost.
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/// @notice INVARIANT: Total collected by all users never exceeds total funded.
|
|
360
|
+
function invariant_totalCollectedNeverExceedsFunded() public view {
|
|
361
|
+
uint256 totalCollected = handler.ghost_totalCollectedByAlice() + handler.ghost_totalCollectedByBob();
|
|
362
|
+
assertLe(totalCollected, handler.ghost_totalFunded());
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/// @notice INVARIANT: totalVestingAmountOf never exceeds the hook's tracked balance.
|
|
366
|
+
function invariant_vestingNeverExceedsBalance() public view {
|
|
367
|
+
assertLe(
|
|
368
|
+
distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))),
|
|
369
|
+
distributor.balanceOf(address(hook), IERC20(address(rewardToken)))
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// @notice INVARIANT: Token balances are conserved (funded = distributor + alice + bob).
|
|
374
|
+
function invariant_balanceConservation() public view {
|
|
375
|
+
uint256 totalSupply = rewardToken.totalSupply();
|
|
376
|
+
uint256 distributorBal = rewardToken.balanceOf(address(distributor));
|
|
377
|
+
uint256 aliceBal = rewardToken.balanceOf(alice);
|
|
378
|
+
uint256 bobBal = rewardToken.balanceOf(bob);
|
|
379
|
+
uint256 handlerBal = rewardToken.balanceOf(address(handler));
|
|
380
|
+
|
|
381
|
+
// All tokens must be accounted for.
|
|
382
|
+
assertEq(distributorBal + aliceBal + bobBal + handlerBal, totalSupply);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/// @notice INVARIANT: collectableFor never exceeds claimedFor for any token.
|
|
386
|
+
function invariant_collectableNeverExceedsClaimed() public view {
|
|
387
|
+
IERC20 token = IERC20(address(rewardToken));
|
|
388
|
+
|
|
389
|
+
if (!handler.token1Burned()) {
|
|
390
|
+
assertLe(
|
|
391
|
+
distributor.collectableFor(address(hook), 1, token), distributor.claimedFor(address(hook), 1, token)
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
if (!handler.token2Burned()) {
|
|
395
|
+
assertLe(
|
|
396
|
+
distributor.collectableFor(address(hook), 2, token), distributor.claimedFor(address(hook), 2, token)
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/// @notice Log call stats after invariant run.
|
|
402
|
+
function invariant_callSummary() public view {
|
|
403
|
+
// This invariant always passes -- it just logs the handler call distribution.
|
|
404
|
+
handler.ghost_fundCalls();
|
|
405
|
+
handler.ghost_vestingCalls();
|
|
406
|
+
handler.ghost_collectCalls();
|
|
407
|
+
handler.ghost_forfeitCalls();
|
|
408
|
+
handler.ghost_rollCalls();
|
|
409
|
+
}
|
|
410
|
+
}
|