@bananapus/distributor-v6 0.0.3 → 0.0.4
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/foundry.lock +5 -0
- package/package.json +1 -1
- package/src/JB721Distributor.sol +236 -22
- package/src/JBDistributor.sol +293 -213
- package/src/JBTokenDistributor.sol +32 -27
- package/src/interfaces/IJBDistributor.sol +37 -24
- package/test/AuditFixes.t.sol +429 -0
- package/test/JB721Distributor.t.sol +232 -163
- package/test/JBTokenDistributor.t.sol +92 -13
- package/test/audit/H26VotingPowerCap.t.sol +338 -0
- package/test/invariant/JB721DistributorInvariant.t.sol +11 -12
|
@@ -75,7 +75,7 @@ contract JBTokenDistributorTest is Test {
|
|
|
75
75
|
address terminal = makeAddr("terminal");
|
|
76
76
|
uint256 projectId = 1;
|
|
77
77
|
|
|
78
|
-
// 100
|
|
78
|
+
// 100 seconds per round, 4 vesting rounds.
|
|
79
79
|
uint256 constant ROUND_DURATION = 100;
|
|
80
80
|
uint256 constant VESTING_ROUNDS = 4;
|
|
81
81
|
|
|
@@ -102,12 +102,14 @@ contract JBTokenDistributorTest is Test {
|
|
|
102
102
|
return uint256(uint160(staker));
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
/// @notice Advance to 1
|
|
105
|
+
/// @notice Advance to 1 second after the start of the given round, and advance block number too.
|
|
106
106
|
function _advanceToRound(uint256 round) internal {
|
|
107
|
-
uint256
|
|
108
|
-
if (block.
|
|
109
|
-
vm.
|
|
107
|
+
uint256 targetTimestamp = distributor.roundStartTimestamp(round) + 1;
|
|
108
|
+
if (block.timestamp < targetTimestamp) {
|
|
109
|
+
vm.warp(targetTimestamp);
|
|
110
110
|
}
|
|
111
|
+
// Also advance block number so getPastVotes works with past blocks.
|
|
112
|
+
vm.roll(block.number + 1);
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
/// @notice Fund the distributor via the direct `fund` method.
|
|
@@ -133,7 +135,7 @@ contract JBTokenDistributorTest is Test {
|
|
|
133
135
|
// Fund the distributor with 1000 reward tokens.
|
|
134
136
|
_fundDistributor(1000 ether);
|
|
135
137
|
|
|
136
|
-
// Advance to round 1 (so
|
|
138
|
+
// Advance to round 1 (so snapshot block is in the past for getPastVotes).
|
|
137
139
|
_advanceToRound(1);
|
|
138
140
|
|
|
139
141
|
// Begin vesting for alice and bob.
|
|
@@ -332,17 +334,16 @@ contract JBTokenDistributorTest is Test {
|
|
|
332
334
|
distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
|
|
333
335
|
assertEq(rewardToken.balanceOf(alice), 350 ether, "Alice collected 350");
|
|
334
336
|
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
+
// Advance to a new round so a fresh snapshot is taken for the new funds.
|
|
338
|
+
_advanceToRound(4);
|
|
337
339
|
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
// But we need to be past round 3's start block. We're at round 3 + 1 block already.
|
|
340
|
+
// Fund more for the new round.
|
|
341
|
+
_fundDistributor(500 ether);
|
|
341
342
|
|
|
342
|
-
// Begin vesting round
|
|
343
|
+
// Begin vesting round 4's rewards.
|
|
343
344
|
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
344
345
|
|
|
345
|
-
// Advance past both vesting periods.
|
|
346
|
+
// Advance past both vesting periods (entry 0 releases at round 5, entry 1 at round 8).
|
|
346
347
|
_advanceToRound(1 + VESTING_ROUNDS + VESTING_ROUNDS);
|
|
347
348
|
|
|
348
349
|
// Collect all remaining.
|
|
@@ -413,6 +414,84 @@ contract JBTokenDistributorTest is Test {
|
|
|
413
414
|
assertEq(collectable, 525 ether, "75% vested after 3/4 rounds");
|
|
414
415
|
}
|
|
415
416
|
|
|
417
|
+
function test_autoVest_collectWithoutBeginVesting() public {
|
|
418
|
+
// Alice delegates to self.
|
|
419
|
+
vm.prank(alice);
|
|
420
|
+
votesToken.delegate(alice);
|
|
421
|
+
|
|
422
|
+
// Fund the distributor.
|
|
423
|
+
_fundDistributor(1000 ether);
|
|
424
|
+
|
|
425
|
+
// Advance to round 1.
|
|
426
|
+
_advanceToRound(1);
|
|
427
|
+
|
|
428
|
+
// Advance past full vesting WITHOUT calling beginVesting first.
|
|
429
|
+
_advanceToRound(1 + VESTING_ROUNDS);
|
|
430
|
+
|
|
431
|
+
// Alice calls collectVestedRewards directly — auto-vest should kick in.
|
|
432
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
433
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
434
|
+
|
|
435
|
+
vm.prank(alice);
|
|
436
|
+
distributor.collectVestedRewards(address(votesToken), _singleTokenId(alice), tokens, alice);
|
|
437
|
+
|
|
438
|
+
// Alice should have auto-vested for the current round (1 + VESTING_ROUNDS).
|
|
439
|
+
// Her claimed amount depends on what round the auto-vest captures.
|
|
440
|
+
// The auto-vest happens at the current round, so it creates a new vesting entry for that round.
|
|
441
|
+
// Since it's a new vesting entry, it won't be fully vested yet (just started).
|
|
442
|
+
// But previous rounds' funds accumulated and the collect at the current round auto-vests them.
|
|
443
|
+
uint256 aliceClaimed =
|
|
444
|
+
distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
|
|
445
|
+
assertGt(aliceClaimed, 0, "Alice should have auto-vested something");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function test_poke_recordsSnapshotBlock() public {
|
|
449
|
+
_advanceToRound(1);
|
|
450
|
+
|
|
451
|
+
uint256 expectedBlock = block.number - 1;
|
|
452
|
+
distributor.poke();
|
|
453
|
+
|
|
454
|
+
assertEq(distributor.roundSnapshotBlock(1), expectedBlock, "Snapshot block should be block.number - 1");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function test_poke_idempotent() public {
|
|
458
|
+
_advanceToRound(1);
|
|
459
|
+
|
|
460
|
+
distributor.poke();
|
|
461
|
+
uint256 firstSnapshot = distributor.roundSnapshotBlock(1);
|
|
462
|
+
|
|
463
|
+
// Advance block but stay in same round.
|
|
464
|
+
vm.roll(block.number + 10);
|
|
465
|
+
|
|
466
|
+
distributor.poke();
|
|
467
|
+
uint256 secondSnapshot = distributor.roundSnapshotBlock(1);
|
|
468
|
+
|
|
469
|
+
assertEq(firstSnapshot, secondSnapshot, "Poke should be idempotent within a round");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function test_skipAlreadyVested_noRevert() public {
|
|
473
|
+
vm.prank(alice);
|
|
474
|
+
votesToken.delegate(alice);
|
|
475
|
+
|
|
476
|
+
_fundDistributor(1000 ether);
|
|
477
|
+
_advanceToRound(1);
|
|
478
|
+
|
|
479
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
480
|
+
tokenIds[0] = _tokenId(alice);
|
|
481
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
482
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
483
|
+
|
|
484
|
+
// First vest.
|
|
485
|
+
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
486
|
+
|
|
487
|
+
// Second vest in same round should NOT revert (skips silently).
|
|
488
|
+
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
489
|
+
|
|
490
|
+
// Only one vesting entry should exist.
|
|
491
|
+
uint256 claimed = distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
|
|
492
|
+
assertEq(claimed, 700 ether, "Should have exactly one vesting entry worth 700");
|
|
493
|
+
}
|
|
494
|
+
|
|
416
495
|
//*********************************************************************//
|
|
417
496
|
// ----------------------------- internal ---------------------------- //
|
|
418
497
|
//*********************************************************************//
|
|
@@ -0,0 +1,338 @@
|
|
|
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
|
+
|
|
22
|
+
/// @notice Mock JB directory for H-26 tests.
|
|
23
|
+
contract H26MockDirectory {
|
|
24
|
+
mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
|
|
25
|
+
mapping(uint256 projectId => address controller) public controllers;
|
|
26
|
+
|
|
27
|
+
function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
|
|
28
|
+
terminals[projectId][terminal] = isTerminal;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setController(uint256 projectId, address controller) external {
|
|
32
|
+
controllers[projectId] = controller;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
|
|
36
|
+
return terminals[projectId][address(terminal)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function controllerOf(uint256 projectId) external view returns (IERC165) {
|
|
40
|
+
return IERC165(controllers[projectId]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// @notice Simple ERC20 reward token for H-26 tests.
|
|
45
|
+
contract H26MockRewardToken is ERC20 {
|
|
46
|
+
constructor() ERC20("Reward", "RWD") {}
|
|
47
|
+
|
|
48
|
+
function mint(address to, uint256 amount) external {
|
|
49
|
+
_mint(to, amount);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// @notice Mock store that tracks tiers and token-to-tier mappings.
|
|
54
|
+
contract H26MockStore {
|
|
55
|
+
uint256 public maxTier;
|
|
56
|
+
mapping(uint256 tierId => JB721Tier) public tiers;
|
|
57
|
+
mapping(uint256 tierId => uint256) public burned;
|
|
58
|
+
mapping(uint256 tokenId => uint256 tierId) public tokenTiers;
|
|
59
|
+
|
|
60
|
+
function setMaxTierIdOf(uint256 maxTierId) external {
|
|
61
|
+
maxTier = maxTierId;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function maxTierIdOf(address) external view returns (uint256) {
|
|
65
|
+
return maxTier;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setTier(uint256 tierId, JB721Tier memory tier) external {
|
|
69
|
+
tiers[tierId] = tier;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function tierOf(address, uint256 id, bool) external view returns (JB721Tier memory) {
|
|
73
|
+
return tiers[id];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setTokenTier(uint256 tokenId, uint256 tierId) external {
|
|
77
|
+
tokenTiers[tokenId] = tierId;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
81
|
+
return tiers[tokenTiers[tokenId]];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function setBurnedFor(uint256 tierId, uint256 count) external {
|
|
85
|
+
burned[tierId] = count;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function numberOfBurnedFor(address, uint256 tierId) external view returns (uint256) {
|
|
89
|
+
return burned[tierId];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// @notice Mock checkpoints with explicit per-address vote overrides for H-26 testing.
|
|
94
|
+
/// @dev getPastTotalSupply computes from the store; getPastVotes uses explicit overrides.
|
|
95
|
+
contract H26MockCheckpoints {
|
|
96
|
+
H26MockStore public store;
|
|
97
|
+
address public hookAddr;
|
|
98
|
+
|
|
99
|
+
/// @dev Override: if non-zero, getPastTotalSupply returns this instead of computing from store.
|
|
100
|
+
uint256 public totalSupplyOverride;
|
|
101
|
+
|
|
102
|
+
/// @dev Per-address vote overrides. If set, getPastVotes returns this value.
|
|
103
|
+
mapping(address => uint256) public votesOverride;
|
|
104
|
+
|
|
105
|
+
/// @dev Tracks whether a per-address override was explicitly set (to allow setting 0).
|
|
106
|
+
mapping(address => bool) public votesOverrideSet;
|
|
107
|
+
|
|
108
|
+
constructor(H26MockStore _store, address _hook) {
|
|
109
|
+
store = _store;
|
|
110
|
+
hookAddr = _hook;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function setTotalSupplyOverride(uint256 value) external {
|
|
114
|
+
totalSupplyOverride = value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function setVotesOverride(address account, uint256 value) external {
|
|
118
|
+
votesOverride[account] = value;
|
|
119
|
+
votesOverrideSet[account] = true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getPastTotalSupply(uint256) external view returns (uint256 total) {
|
|
123
|
+
if (totalSupplyOverride != 0) return totalSupplyOverride;
|
|
124
|
+
// Dynamically compute from store: sum over tiers of (minted - burned) * votingUnits.
|
|
125
|
+
uint256 maxTierCount = store.maxTier();
|
|
126
|
+
for (uint256 i = 1; i <= maxTierCount; i++) {
|
|
127
|
+
JB721Tier memory tier = store.tierOf(hookAddr, i, false);
|
|
128
|
+
if (tier.id == 0 || tier.initialSupply == 0) continue;
|
|
129
|
+
uint256 burnedCount = store.burned(i);
|
|
130
|
+
uint256 held = tier.initialSupply - tier.remainingSupply - burnedCount;
|
|
131
|
+
total += held * tier.votingUnits;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getPastVotes(address account, uint256) external view returns (uint256) {
|
|
136
|
+
if (votesOverrideSet[account]) return votesOverride[account];
|
|
137
|
+
// Default: return max so min(votingUnits, pastVotes) = votingUnits for any holder.
|
|
138
|
+
return type(uint256).max;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/// @notice Mock 721 hook for H-26 tests.
|
|
143
|
+
contract H26MockHook {
|
|
144
|
+
H26MockStore public immutable _store;
|
|
145
|
+
H26MockCheckpoints public _checkpoints;
|
|
146
|
+
|
|
147
|
+
mapping(uint256 tokenId => address owner) public owners;
|
|
148
|
+
|
|
149
|
+
constructor(H26MockStore store_) {
|
|
150
|
+
_store = store_;
|
|
151
|
+
_checkpoints = new H26MockCheckpoints(store_, address(this));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function STORE() external view returns (H26MockStore) {
|
|
155
|
+
return _store;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function CHECKPOINTS() external view returns (H26MockCheckpoints) {
|
|
159
|
+
return _checkpoints;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function ownerOf(uint256 tokenId) external view returns (address) {
|
|
163
|
+
address owner = owners[tokenId];
|
|
164
|
+
require(owner != address(0), "ERC721: invalid token ID");
|
|
165
|
+
return owner;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
169
|
+
owners[tokenId] = owner;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function burn(uint256 tokenId) external {
|
|
173
|
+
delete owners[tokenId];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// @notice Tests for H-26: per-owner voting power cap in JB721Distributor.
|
|
178
|
+
/// @dev Verifies that an owner holding multiple NFTs cannot claim more rewards than their
|
|
179
|
+
/// historical voting power allows. The `_vestTokenIds` override in JB721Distributor
|
|
180
|
+
/// tracks consumed voting power per owner and caps each NFT's effective stake.
|
|
181
|
+
contract H26VotingPowerCapTest is Test {
|
|
182
|
+
JB721Distributor distributor;
|
|
183
|
+
H26MockRewardToken rewardToken;
|
|
184
|
+
H26MockHook hook;
|
|
185
|
+
H26MockStore store;
|
|
186
|
+
H26MockDirectory directory;
|
|
187
|
+
|
|
188
|
+
address alice = makeAddr("alice");
|
|
189
|
+
address bob = makeAddr("bob");
|
|
190
|
+
|
|
191
|
+
uint256 constant PROJECT_ID = 1;
|
|
192
|
+
uint256 constant ROUND_DURATION = 100;
|
|
193
|
+
uint256 constant VESTING_ROUNDS = 4;
|
|
194
|
+
|
|
195
|
+
function setUp() public {
|
|
196
|
+
store = new H26MockStore();
|
|
197
|
+
hook = new H26MockHook(store);
|
|
198
|
+
directory = new H26MockDirectory();
|
|
199
|
+
|
|
200
|
+
distributor = new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
201
|
+
|
|
202
|
+
// Register this test contract as a terminal for PROJECT_ID so processSplitWith works.
|
|
203
|
+
directory.setTerminal(PROJECT_ID, address(this), true);
|
|
204
|
+
|
|
205
|
+
rewardToken = new H26MockRewardToken();
|
|
206
|
+
|
|
207
|
+
JB721TierFlags memory flags;
|
|
208
|
+
|
|
209
|
+
// Tier 1: votingUnits = 50 each.
|
|
210
|
+
store.setMaxTierIdOf(1);
|
|
211
|
+
store.setTier(
|
|
212
|
+
1,
|
|
213
|
+
JB721Tier({
|
|
214
|
+
id: 1,
|
|
215
|
+
price: 1 ether,
|
|
216
|
+
remainingSupply: 7,
|
|
217
|
+
initialSupply: 10,
|
|
218
|
+
votingUnits: 50,
|
|
219
|
+
reserveFrequency: 0,
|
|
220
|
+
reserveBeneficiary: address(0),
|
|
221
|
+
encodedIPFSUri: bytes32(0),
|
|
222
|
+
category: 0,
|
|
223
|
+
discountPercent: 0,
|
|
224
|
+
flags: flags,
|
|
225
|
+
splitPercent: 0,
|
|
226
|
+
resolvedUri: ""
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// Alice owns 3 NFTs: tokens 1, 2, 3 — all tier 1 (50 voting units each).
|
|
231
|
+
store.setTokenTier(1, 1);
|
|
232
|
+
store.setTokenTier(2, 1);
|
|
233
|
+
store.setTokenTier(3, 1);
|
|
234
|
+
hook.setOwner(1, alice);
|
|
235
|
+
hook.setOwner(2, alice);
|
|
236
|
+
hook.setOwner(3, alice);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// =====================================================================
|
|
240
|
+
// Helpers
|
|
241
|
+
// =====================================================================
|
|
242
|
+
|
|
243
|
+
function _advanceToRound(uint256 round) internal {
|
|
244
|
+
uint256 targetTimestamp = distributor.roundStartTimestamp(round) + 1;
|
|
245
|
+
if (block.timestamp < targetTimestamp) {
|
|
246
|
+
vm.warp(targetTimestamp);
|
|
247
|
+
}
|
|
248
|
+
vm.roll(block.number + 1);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function _fundHook(uint256 amount) internal {
|
|
252
|
+
rewardToken.mint(address(this), amount);
|
|
253
|
+
rewardToken.approve(address(distributor), amount);
|
|
254
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), amount);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// =====================================================================
|
|
258
|
+
// H-26 Tests
|
|
259
|
+
// =====================================================================
|
|
260
|
+
|
|
261
|
+
/// @notice Owner has 3 NFTs (50 voting units each = 150 total) but only 100 past votes.
|
|
262
|
+
/// Should get rewards for only 100 votes total, not 150.
|
|
263
|
+
function test_h26_multipleNFTsCappedAtVotingPower() public {
|
|
264
|
+
// Alice has 3 NFTs x 50 voting units = 150 votingUnits total.
|
|
265
|
+
// But her pastVotes is only 100 — so she should be capped at 100.
|
|
266
|
+
hook._checkpoints().setVotesOverride(alice, 100);
|
|
267
|
+
|
|
268
|
+
// Total supply = 3 minted * 50 voting units = 150.
|
|
269
|
+
// (store has initialSupply=10, remainingSupply=7, so 3 minted)
|
|
270
|
+
|
|
271
|
+
_fundHook(1500 ether);
|
|
272
|
+
|
|
273
|
+
uint256[] memory tokenIds = new uint256[](3);
|
|
274
|
+
tokenIds[0] = 1;
|
|
275
|
+
tokenIds[1] = 2;
|
|
276
|
+
tokenIds[2] = 3;
|
|
277
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
278
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
279
|
+
|
|
280
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
281
|
+
|
|
282
|
+
// Without the cap, Alice would get: mulDiv(1500, 50, 150) * 3 = 500 * 3 = 1500 ether.
|
|
283
|
+
// With the cap: Alice has 100 past votes across 3 NFTs (50 each).
|
|
284
|
+
// NFT 1: min(50, 100 remaining) = 50 -> consumed = 50
|
|
285
|
+
// NFT 2: min(50, 50 remaining) = 50 -> consumed = 100
|
|
286
|
+
// NFT 3: min(50, 0 remaining) = 0 -> skipped
|
|
287
|
+
// Total effective stake = 100 out of 150 total supply.
|
|
288
|
+
// Alice total = mulDiv(1500, 50, 150) + mulDiv(1500, 50, 150) = 500 + 500 = 1000 ether.
|
|
289
|
+
uint256 claimed1 = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
290
|
+
uint256 claimed2 = distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken)));
|
|
291
|
+
uint256 claimed3 = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
|
|
292
|
+
|
|
293
|
+
uint256 totalClaimed = claimed1 + claimed2 + claimed3;
|
|
294
|
+
|
|
295
|
+
// Each NFT gets mulDiv(1500, 50, 150) = 500 ether for 50 effective units,
|
|
296
|
+
// but NFT 3 gets 0 (remaining pastVotes exhausted).
|
|
297
|
+
assertEq(claimed1, 500 ether, "NFT 1: should get full 50-unit share");
|
|
298
|
+
assertEq(claimed2, 500 ether, "NFT 2: should get full 50-unit share");
|
|
299
|
+
assertEq(claimed3, 0, "NFT 3: should get 0 (voting power exhausted)");
|
|
300
|
+
assertEq(totalClaimed, 1000 ether, "Total claimed should be capped at 100/150 of distributable");
|
|
301
|
+
|
|
302
|
+
// Verify total vesting reflects the capped amount, not the full 150-unit amount.
|
|
303
|
+
assertEq(
|
|
304
|
+
distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))),
|
|
305
|
+
1000 ether,
|
|
306
|
+
"Total vesting should reflect capped amount"
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// @notice An owner with 1 NFT and sufficient past votes gets the full reward (backward compatibility).
|
|
311
|
+
function test_h26_singleNFTUnaffected() public {
|
|
312
|
+
// Alice has 3 NFTs but we only vest 1 — pastVotes = 100 which is >= 50 voting units.
|
|
313
|
+
hook._checkpoints().setVotesOverride(alice, 100);
|
|
314
|
+
|
|
315
|
+
_fundHook(1500 ether);
|
|
316
|
+
|
|
317
|
+
// Only vest token 1.
|
|
318
|
+
uint256[] memory tokenIds = new uint256[](1);
|
|
319
|
+
tokenIds[0] = 1;
|
|
320
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
321
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
322
|
+
|
|
323
|
+
distributor.beginVesting(address(hook), tokenIds, tokens);
|
|
324
|
+
|
|
325
|
+
// With 1 NFT at 50 voting units, pastVotes=100 is more than enough.
|
|
326
|
+
// Alice's NFT 1 stake = min(50, 100) = 50.
|
|
327
|
+
// Share = mulDiv(1500, 50, 150) = 500 ether.
|
|
328
|
+
uint256 claimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
329
|
+
assertEq(claimed, 500 ether, "Single NFT should get full reward when pastVotes >= votingUnits");
|
|
330
|
+
|
|
331
|
+
// Verify total vesting.
|
|
332
|
+
assertEq(
|
|
333
|
+
distributor.totalVestingAmountOf(address(hook), IERC20(address(rewardToken))),
|
|
334
|
+
500 ether,
|
|
335
|
+
"Total vesting should equal single NFT share"
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -6,7 +6,6 @@ import {StdInvariant} from "forge-std/StdInvariant.sol";
|
|
|
6
6
|
|
|
7
7
|
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
8
8
|
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
9
|
-
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
10
9
|
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
11
10
|
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
12
11
|
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
@@ -14,7 +13,6 @@ import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.
|
|
|
14
13
|
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
15
14
|
|
|
16
15
|
import {JB721Distributor} from "../../src/JB721Distributor.sol";
|
|
17
|
-
import {JBDistributor} from "../../src/JBDistributor.sol";
|
|
18
16
|
|
|
19
17
|
/// @notice Simple ERC20 token for invariant testing.
|
|
20
18
|
contract InvariantToken is ERC20 {
|
|
@@ -135,16 +133,16 @@ contract DistributorHandler is Test {
|
|
|
135
133
|
uint256 public ghost_collectCalls;
|
|
136
134
|
uint256 public ghost_forfeitCalls;
|
|
137
135
|
uint256 public ghost_fundCalls;
|
|
138
|
-
uint256 public
|
|
136
|
+
uint256 public ghost_warpCalls;
|
|
139
137
|
|
|
140
138
|
// Track whether tokens are burned.
|
|
141
139
|
bool public token1Burned;
|
|
142
140
|
bool public token2Burned;
|
|
143
141
|
|
|
144
|
-
// Track latest round we vested in per tokenId to avoid
|
|
142
|
+
// Track latest round we vested in per tokenId to avoid double-vest in same round.
|
|
145
143
|
mapping(uint256 tokenId => uint256 lastVestedRound) public lastVestedRoundOf;
|
|
146
144
|
|
|
147
|
-
uint256 constant ROUND_DURATION = 100;
|
|
145
|
+
uint256 constant ROUND_DURATION = 100; // 100 seconds per round.
|
|
148
146
|
|
|
149
147
|
constructor(
|
|
150
148
|
JB721Distributor _distributor,
|
|
@@ -172,20 +170,21 @@ contract DistributorHandler is Test {
|
|
|
172
170
|
ghost_fundCalls++;
|
|
173
171
|
}
|
|
174
172
|
|
|
175
|
-
/// @notice Advance
|
|
176
|
-
function
|
|
173
|
+
/// @notice Advance time by random amount (0-3 rounds) and advance block number.
|
|
174
|
+
function warpForward(uint8 rawRounds) external {
|
|
177
175
|
uint256 rounds = bound(rawRounds, 0, 3);
|
|
178
176
|
if (rounds > 0) {
|
|
179
|
-
vm.
|
|
177
|
+
vm.warp(block.timestamp + ROUND_DURATION * rounds);
|
|
178
|
+
vm.roll(block.number + 1); // Advance block for getPastVotes.
|
|
180
179
|
}
|
|
181
|
-
|
|
180
|
+
ghost_warpCalls++;
|
|
182
181
|
}
|
|
183
182
|
|
|
184
183
|
/// @notice Begin vesting for one or both tokens.
|
|
185
184
|
function beginVesting(uint8 tokenSelector) external {
|
|
186
185
|
uint256 currentRound = distributor.currentRound();
|
|
187
186
|
|
|
188
|
-
// Determine which tokens to vest (
|
|
187
|
+
// Determine which tokens to vest (skip already-vested — they'll be silently skipped anyway).
|
|
189
188
|
bool vest1 = !token1Burned && (tokenSelector % 3 != 1) && lastVestedRoundOf[1] != currentRound;
|
|
190
189
|
bool vest2 = !token2Burned && (tokenSelector % 3 != 0) && lastVestedRoundOf[2] != currentRound;
|
|
191
190
|
|
|
@@ -284,7 +283,7 @@ contract JB721DistributorInvariantTest is StdInvariant, Test {
|
|
|
284
283
|
address alice = makeAddr("alice");
|
|
285
284
|
address bob = makeAddr("bob");
|
|
286
285
|
|
|
287
|
-
uint256 constant ROUND_DURATION = 100;
|
|
286
|
+
uint256 constant ROUND_DURATION = 100; // 100 seconds per round.
|
|
288
287
|
uint256 constant VESTING_ROUNDS = 4;
|
|
289
288
|
|
|
290
289
|
function setUp() public {
|
|
@@ -405,6 +404,6 @@ contract JB721DistributorInvariantTest is StdInvariant, Test {
|
|
|
405
404
|
handler.ghost_vestingCalls();
|
|
406
405
|
handler.ghost_collectCalls();
|
|
407
406
|
handler.ghost_forfeitCalls();
|
|
408
|
-
handler.
|
|
407
|
+
handler.ghost_warpCalls();
|
|
409
408
|
}
|
|
410
409
|
}
|