@bananapus/distributor-v6 0.0.7 → 0.0.8
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/JBTokenDistributor.sol +4 -0
- 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
package/USER_JOURNEYS.md
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
# User Journeys
|
|
2
|
-
|
|
3
|
-
## Repo Purpose
|
|
4
|
-
|
|
5
|
-
This repo distributes already-owned assets over time. It snapshots stake, starts vesting rounds, and lets eligible recipients collect what has unlocked.
|
|
6
|
-
|
|
7
|
-
## Primary Actors
|
|
8
|
-
|
|
9
|
-
- teams funding a distributor from a split or post-mint allocation
|
|
10
|
-
- token holders or NFT holders collecting vested rewards
|
|
11
|
-
- operators configuring round timing and deployment shape
|
|
12
|
-
- auditors reviewing snapshot timing and stake-accounting correctness
|
|
13
|
-
|
|
14
|
-
## Key Surfaces
|
|
15
|
-
|
|
16
|
-
- `JBDistributor`: shared round and vesting engine
|
|
17
|
-
- `JBTokenDistributor`: ERC-20 distributor using `IVotes`
|
|
18
|
-
- `JB721Distributor`: NFT distributor using tier voting units
|
|
19
|
-
|
|
20
|
-
## Journey 1: Fund A Distributor
|
|
21
|
-
|
|
22
|
-
**Actor:** project or payout flow.
|
|
23
|
-
|
|
24
|
-
**Intent:** move owned assets into a distributor that will vest them over time.
|
|
25
|
-
|
|
26
|
-
**Preconditions**
|
|
27
|
-
- the correct asset and distributor type are chosen
|
|
28
|
-
- the distributor actually receives the inventory it is expected to vest
|
|
29
|
-
|
|
30
|
-
**Main Flow**
|
|
31
|
-
1. Fund the distributor directly or through a payout split.
|
|
32
|
-
2. Confirm the tracked balance matches what the distributor received.
|
|
33
|
-
3. Use the distributor as the vesting surface, not as the source of entitlement logic.
|
|
34
|
-
|
|
35
|
-
**Failure Modes**
|
|
36
|
-
- wrong asset funded
|
|
37
|
-
- underfunded distributor
|
|
38
|
-
- caller assumes funding alone starts vesting
|
|
39
|
-
|
|
40
|
-
**Postconditions**
|
|
41
|
-
- the distributor holds the asset inventory for future rounds
|
|
42
|
-
|
|
43
|
-
## Journey 2: Start A Vesting Round
|
|
44
|
-
|
|
45
|
-
**Actor:** any caller.
|
|
46
|
-
|
|
47
|
-
**Intent:** snapshot the current round and begin vesting.
|
|
48
|
-
|
|
49
|
-
**Preconditions**
|
|
50
|
-
- the round timing and parameters are valid
|
|
51
|
-
- the stake source is usable and non-zero
|
|
52
|
-
|
|
53
|
-
**Main Flow**
|
|
54
|
-
1. Call `beginVesting`.
|
|
55
|
-
2. The distributor snapshots the relevant balance and stake source.
|
|
56
|
-
3. Vesting entries become claimable over the configured schedule.
|
|
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
|
-
|
|
60
|
-
**Failure Modes**
|
|
61
|
-
- zero total stake
|
|
62
|
-
- bad deployment parameters such as zero round duration or zero vesting rounds
|
|
63
|
-
- stake snapshot is stale or surprising to operators
|
|
64
|
-
|
|
65
|
-
**Postconditions**
|
|
66
|
-
- a new vesting round exists with fixed snapshot assumptions
|
|
67
|
-
|
|
68
|
-
## Journey 3: Collect Vested Rewards
|
|
69
|
-
|
|
70
|
-
**Actor:** eligible recipient.
|
|
71
|
-
|
|
72
|
-
**Intent:** collect the share that has unlocked for a round.
|
|
73
|
-
|
|
74
|
-
**Preconditions**
|
|
75
|
-
- the recipient is authorized under the distributor type
|
|
76
|
-
- some share has already vested
|
|
77
|
-
|
|
78
|
-
**Main Flow**
|
|
79
|
-
1. Call the relevant claim function.
|
|
80
|
-
2. The distributor checks authority and unlocked amount.
|
|
81
|
-
3. The vested share transfers to the claimant.
|
|
82
|
-
|
|
83
|
-
**Failure Modes**
|
|
84
|
-
- invalid claimant
|
|
85
|
-
- claim batch includes invalid 721 token IDs
|
|
86
|
-
- reward token transfer fails
|
|
87
|
-
|
|
88
|
-
**Postconditions**
|
|
89
|
-
- vested rewards move to the claimant
|
|
90
|
-
|
|
91
|
-
## Journey 4: Recycle Forfeited 721 Rewards
|
|
92
|
-
|
|
93
|
-
**Actor:** caller using the 721 distributor path.
|
|
94
|
-
|
|
95
|
-
**Intent:** release rewards tied to burned NFTs back into the future distribution pool.
|
|
96
|
-
|
|
97
|
-
**Preconditions**
|
|
98
|
-
- the distributor type is 721-based
|
|
99
|
-
- the relevant NFTs are burned or otherwise forfeited under the configured rules
|
|
100
|
-
|
|
101
|
-
**Main Flow**
|
|
102
|
-
1. Call the forfeiture-release path.
|
|
103
|
-
2. The distributor reduces current vesting obligations for those forfeited claims.
|
|
104
|
-
3. The value remains in the distributor for future rounds instead of being destroyed.
|
|
105
|
-
|
|
106
|
-
**Failure Modes**
|
|
107
|
-
- caller expects the same behavior from the token distributor
|
|
108
|
-
- off-chain systems treat forfeited value as burned instead of recycled
|
|
109
|
-
|
|
110
|
-
**Postconditions**
|
|
111
|
-
- forfeited 721 rewards return to the future distributable pool
|
|
112
|
-
|
|
113
|
-
## Trust Boundaries
|
|
114
|
-
|
|
115
|
-
- this repo trusts `JBDirectory` for authenticated split-hook caller checks
|
|
116
|
-
- `JBTokenDistributor` trusts `IVotes` checkpoints
|
|
117
|
-
- `JB721Distributor` trusts the 721 hook's `CHECKPOINTS()` module for historical voting power and the store for tier metadata
|
|
118
|
-
|
|
119
|
-
## Hand-Offs
|
|
120
|
-
|
|
121
|
-
- Use the upstream repo that funded the distributor when the question is about why an allocation exists.
|
|
122
|
-
- Use [nana-721-hook-v6](../nana-721-hook-v6/USER_JOURNEYS.md) when the stake source is a tiered 721 hook.
|
package/slither-ci.config.json
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"detectors_to_exclude": "timestamp,uninitialized-local,naming-convention,solc-version,shadowing-local",
|
|
3
|
-
"exclude_informational": true,
|
|
4
|
-
"exclude_low": false,
|
|
5
|
-
"exclude_medium": false,
|
|
6
|
-
"exclude_high": false,
|
|
7
|
-
"disable_color": false,
|
|
8
|
-
"filter_paths": "(mocks/|test/|node_modules/|lib/)",
|
|
9
|
-
"legacy_ast": false
|
|
10
|
-
}
|
package/test/AuditFixes.t.sol
DELETED
|
@@ -1,429 +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 {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
|
|
9
|
-
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.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 {JBTokenDistributor} from "../src/JBTokenDistributor.sol";
|
|
19
|
-
import {JBDistributor} from "../src/JBDistributor.sol";
|
|
20
|
-
import {IJBTokenDistributor} from "../src/interfaces/IJBTokenDistributor.sol";
|
|
21
|
-
|
|
22
|
-
/// @notice Mock JB directory for audit fix tests.
|
|
23
|
-
contract AuditFixMockDirectory {
|
|
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 token for reward payouts.
|
|
45
|
-
contract AuditFixMockRewardToken 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 ERC20Votes token for staking.
|
|
54
|
-
contract AuditFixMockVotesToken is ERC20, ERC20Votes {
|
|
55
|
-
constructor() ERC20("StakeToken", "STK") EIP712("StakeToken", "1") {}
|
|
56
|
-
|
|
57
|
-
function mint(address to, uint256 amount) external {
|
|
58
|
-
_mint(to, amount);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
|
|
62
|
-
super._update(from, to, value);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/// @notice Tests for controller-prepaid split funds, zero-stake vesting, and empty claim array handling in
|
|
67
|
-
/// JBTokenDistributor / JBDistributor.
|
|
68
|
-
contract AuditFixesTest is Test {
|
|
69
|
-
AuditFixMockDirectory directory;
|
|
70
|
-
AuditFixMockRewardToken rewardToken;
|
|
71
|
-
AuditFixMockVotesToken votesToken;
|
|
72
|
-
JBTokenDistributor distributor;
|
|
73
|
-
|
|
74
|
-
address alice = makeAddr("alice");
|
|
75
|
-
address bob = makeAddr("bob");
|
|
76
|
-
address terminal = makeAddr("terminal");
|
|
77
|
-
address controller = makeAddr("controller");
|
|
78
|
-
uint256 projectId = 1;
|
|
79
|
-
|
|
80
|
-
uint256 constant ROUND_DURATION = 100;
|
|
81
|
-
uint256 constant VESTING_ROUNDS = 4;
|
|
82
|
-
|
|
83
|
-
function setUp() public {
|
|
84
|
-
directory = new AuditFixMockDirectory();
|
|
85
|
-
rewardToken = new AuditFixMockRewardToken();
|
|
86
|
-
votesToken = new AuditFixMockVotesToken();
|
|
87
|
-
|
|
88
|
-
directory.setTerminal(projectId, terminal, true);
|
|
89
|
-
directory.setController(projectId, controller);
|
|
90
|
-
|
|
91
|
-
distributor = new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
92
|
-
|
|
93
|
-
// Mint staking tokens and delegate.
|
|
94
|
-
votesToken.mint(alice, 700 ether);
|
|
95
|
-
votesToken.mint(bob, 300 ether);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
//*********************************************************************//
|
|
99
|
-
// ----------------------------- helpers ----------------------------- //
|
|
100
|
-
//*********************************************************************//
|
|
101
|
-
|
|
102
|
-
/// @notice Encode a staker address as a tokenId.
|
|
103
|
-
function _tokenId(address staker) internal pure returns (uint256) {
|
|
104
|
-
return uint256(uint160(staker));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/// @notice Advance to 1 second after the start of the given round.
|
|
108
|
-
function _advanceToRound(uint256 round) internal {
|
|
109
|
-
uint256 targetTimestamp = distributor.roundStartTimestamp(round) + 1;
|
|
110
|
-
if (block.timestamp < targetTimestamp) {
|
|
111
|
-
vm.warp(targetTimestamp);
|
|
112
|
-
}
|
|
113
|
-
vm.roll(block.number + 1);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/// @notice Build a JBSplitHookContext for the given token and amount.
|
|
117
|
-
function _buildContext(address token, uint256 amount) internal view returns (JBSplitHookContext memory) {
|
|
118
|
-
JBSplit memory split = JBSplit({
|
|
119
|
-
percent: 1_000_000_000,
|
|
120
|
-
projectId: 0,
|
|
121
|
-
beneficiary: payable(address(votesToken)),
|
|
122
|
-
preferAddToBalance: false,
|
|
123
|
-
lockedUntil: 0,
|
|
124
|
-
hook: IJBSplitHook(address(distributor))
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
return JBSplitHookContext({
|
|
128
|
-
token: token, amount: amount, decimals: 18, projectId: projectId, groupId: 0, split: split
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
//*********************************************************************//
|
|
133
|
-
// ------- Controller-Prepaid ERC20 Split Funds ---------------------- //
|
|
134
|
-
//*********************************************************************//
|
|
135
|
-
|
|
136
|
-
/// @notice Terminal path: ERC20 credited via allowance + transferFrom.
|
|
137
|
-
function test_controllerPrepaidSplits_processSplitWith_terminalPath_creditsViaAllowance() public {
|
|
138
|
-
uint256 amount = 500 ether;
|
|
139
|
-
JBSplitHookContext memory context = _buildContext(address(rewardToken), amount);
|
|
140
|
-
|
|
141
|
-
// Terminal mints tokens and approves the distributor before calling.
|
|
142
|
-
rewardToken.mint(terminal, amount);
|
|
143
|
-
vm.startPrank(terminal);
|
|
144
|
-
rewardToken.approve(address(distributor), amount);
|
|
145
|
-
distributor.processSplitWith(context);
|
|
146
|
-
vm.stopPrank();
|
|
147
|
-
|
|
148
|
-
// Balance should be credited.
|
|
149
|
-
assertEq(
|
|
150
|
-
distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
151
|
-
amount,
|
|
152
|
-
"Terminal path: balance should be credited via transferFrom"
|
|
153
|
-
);
|
|
154
|
-
// Tokens should be held by the distributor.
|
|
155
|
-
assertEq(rewardToken.balanceOf(address(distributor)), amount, "Tokens should be in the distributor");
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/// @notice Controller-prepaid path: ERC20 credited when tokens are sent before processSplitWith.
|
|
159
|
-
function test_controllerPrepaidSplits_processSplitWith_controllerPrepaidPath_creditsDirectly() public {
|
|
160
|
-
uint256 amount = 500 ether;
|
|
161
|
-
JBSplitHookContext memory context = _buildContext(address(rewardToken), amount);
|
|
162
|
-
|
|
163
|
-
// Controller transfers tokens directly to the distributor (no approval).
|
|
164
|
-
rewardToken.mint(controller, amount);
|
|
165
|
-
vm.prank(controller);
|
|
166
|
-
rewardToken.transfer(address(distributor), amount);
|
|
167
|
-
|
|
168
|
-
// Controller calls processSplitWith WITHOUT granting an allowance.
|
|
169
|
-
vm.prank(controller);
|
|
170
|
-
distributor.processSplitWith(context);
|
|
171
|
-
|
|
172
|
-
// Balance should be credited via the controller-prepaid path.
|
|
173
|
-
assertEq(
|
|
174
|
-
distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
175
|
-
amount,
|
|
176
|
-
"Controller-prepaid path: balance should be credited directly"
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/// @notice Verifies that the controller-prepaid path allows end-to-end vesting and collection.
|
|
181
|
-
function test_controllerPrepaidSplits_controllerPrepaidPath_endToEndVestAndCollect() public {
|
|
182
|
-
uint256 amount = 1000 ether;
|
|
183
|
-
|
|
184
|
-
// Alice delegates to self so she has voting power.
|
|
185
|
-
vm.prank(alice);
|
|
186
|
-
votesToken.delegate(alice);
|
|
187
|
-
vm.prank(bob);
|
|
188
|
-
votesToken.delegate(bob);
|
|
189
|
-
|
|
190
|
-
// Controller sends tokens directly and calls processSplitWith.
|
|
191
|
-
JBSplitHookContext memory context = _buildContext(address(rewardToken), amount);
|
|
192
|
-
rewardToken.mint(controller, amount);
|
|
193
|
-
vm.prank(controller);
|
|
194
|
-
rewardToken.transfer(address(distributor), amount);
|
|
195
|
-
vm.prank(controller);
|
|
196
|
-
distributor.processSplitWith(context);
|
|
197
|
-
|
|
198
|
-
// Advance to round 1 and begin vesting.
|
|
199
|
-
_advanceToRound(1);
|
|
200
|
-
|
|
201
|
-
uint256[] memory tokenIds = new uint256[](2);
|
|
202
|
-
tokenIds[0] = _tokenId(alice);
|
|
203
|
-
tokenIds[1] = _tokenId(bob);
|
|
204
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
205
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
206
|
-
|
|
207
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
208
|
-
|
|
209
|
-
// Advance past full vesting.
|
|
210
|
-
_advanceToRound(1 + VESTING_ROUNDS);
|
|
211
|
-
|
|
212
|
-
// Alice collects her 70%.
|
|
213
|
-
uint256[] memory aliceIds = new uint256[](1);
|
|
214
|
-
aliceIds[0] = _tokenId(alice);
|
|
215
|
-
vm.prank(alice);
|
|
216
|
-
distributor.collectVestedRewards(address(votesToken), aliceIds, tokens, alice);
|
|
217
|
-
assertEq(rewardToken.balanceOf(alice), 700 ether, "Alice should collect 70% of controller-prepaid funds");
|
|
218
|
-
|
|
219
|
-
// Bob collects his 30%.
|
|
220
|
-
uint256[] memory bobIds = new uint256[](1);
|
|
221
|
-
bobIds[0] = _tokenId(bob);
|
|
222
|
-
vm.prank(bob);
|
|
223
|
-
distributor.collectVestedRewards(address(votesToken), bobIds, tokens, bob);
|
|
224
|
-
assertEq(rewardToken.balanceOf(bob), 300 ether, "Bob should collect 30% of controller-prepaid funds");
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
//*********************************************************************//
|
|
228
|
-
// ------- Zero totalStake Causes beginVesting Revert ---------------- //
|
|
229
|
-
//*********************************************************************//
|
|
230
|
-
|
|
231
|
-
/// @notice beginVesting with zero totalStake should silently return (no revert).
|
|
232
|
-
function test_zeroTotalStake_beginVesting_zeroTotalStake_doesNotRevert() public {
|
|
233
|
-
// Nobody delegates, so getPastTotalSupply will return 0. But we use the mock votes token
|
|
234
|
-
// which returns totalSupply via getPastTotalSupply. We need to ensure totalSupply is 0.
|
|
235
|
-
// Since votesToken was minted in setUp but nobody delegated, getPastTotalSupply returns
|
|
236
|
-
// the total supply of delegated votes. With no delegation, this is 0 for ERC20Votes.
|
|
237
|
-
|
|
238
|
-
// Fund the distributor.
|
|
239
|
-
rewardToken.mint(address(this), 1000 ether);
|
|
240
|
-
rewardToken.approve(address(distributor), 1000 ether);
|
|
241
|
-
distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
|
|
242
|
-
|
|
243
|
-
// Advance to round 1.
|
|
244
|
-
_advanceToRound(1);
|
|
245
|
-
|
|
246
|
-
uint256[] memory tokenIds = new uint256[](1);
|
|
247
|
-
tokenIds[0] = _tokenId(alice);
|
|
248
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
249
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
250
|
-
|
|
251
|
-
// Should NOT revert even though totalStake == 0. Funds carry over.
|
|
252
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
253
|
-
|
|
254
|
-
// No vesting should have occurred.
|
|
255
|
-
assertEq(
|
|
256
|
-
distributor.totalVestingAmountOf(address(votesToken), IERC20(address(rewardToken))),
|
|
257
|
-
0,
|
|
258
|
-
"Nothing should be vesting when totalStake is zero"
|
|
259
|
-
);
|
|
260
|
-
|
|
261
|
-
// Balance should still be intact for future rounds.
|
|
262
|
-
assertEq(
|
|
263
|
-
distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
264
|
-
1000 ether,
|
|
265
|
-
"Funds should carry over when totalStake is zero"
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/// @notice After zero-stake round passes, a round with stakers should distribute normally.
|
|
270
|
-
function test_zeroTotalStake_zeroTotalStake_fundsCarryOverToNextRound() public {
|
|
271
|
-
// Fund the distributor.
|
|
272
|
-
rewardToken.mint(address(this), 1000 ether);
|
|
273
|
-
rewardToken.approve(address(distributor), 1000 ether);
|
|
274
|
-
distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
|
|
275
|
-
|
|
276
|
-
// Round 1: no one has delegated — zero total stake.
|
|
277
|
-
_advanceToRound(1);
|
|
278
|
-
|
|
279
|
-
uint256[] memory tokenIds = new uint256[](1);
|
|
280
|
-
tokenIds[0] = _tokenId(alice);
|
|
281
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
282
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
283
|
-
|
|
284
|
-
// beginVesting with zero stake — silently returns. H-25: eagerly locks round 2 snapshot.
|
|
285
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
286
|
-
|
|
287
|
-
// Alice delegates (after round 2 snapshot is already locked by H-25 eager fix).
|
|
288
|
-
vm.prank(alice);
|
|
289
|
-
votesToken.delegate(alice);
|
|
290
|
-
|
|
291
|
-
// Round 2: Alice's delegation not captured (round 2 snapshot precedes her delegation).
|
|
292
|
-
// Zero stake again — silently returns. H-25: eagerly locks round 3 snapshot (AFTER delegation).
|
|
293
|
-
_advanceToRound(2);
|
|
294
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
295
|
-
|
|
296
|
-
// Round 3: Alice IS eligible (round 3 snapshot was set after her delegation).
|
|
297
|
-
// Funds from rounds 1 and 2 carry over since no vesting was recorded.
|
|
298
|
-
_advanceToRound(3);
|
|
299
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
300
|
-
|
|
301
|
-
// Alice should have claimed her share of the full 1000 ether (700/1000 total supply).
|
|
302
|
-
uint256 aliceClaimed =
|
|
303
|
-
distributor.claimedFor(address(votesToken), _tokenId(alice), IERC20(address(rewardToken)));
|
|
304
|
-
assertEq(aliceClaimed, 700 ether, "Alice should claim 70% of carried-over funds");
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
//*********************************************************************//
|
|
308
|
-
// ------- Empty Claim Arrays Freeze Round Snapshot ------------------ //
|
|
309
|
-
//*********************************************************************//
|
|
310
|
-
|
|
311
|
-
/// @notice beginVesting with empty tokenIds should revert.
|
|
312
|
-
function test_emptyClaimArrays_beginVesting_emptyTokenIds_reverts() public {
|
|
313
|
-
uint256[] memory tokenIds = new uint256[](0);
|
|
314
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
315
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
316
|
-
|
|
317
|
-
vm.expectRevert(JBDistributor.JBDistributor_EmptyTokenIds.selector);
|
|
318
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
/// @notice collectVestedRewards with empty tokenIds should revert.
|
|
322
|
-
function test_emptyClaimArrays_collectVestedRewards_emptyTokenIds_reverts() public {
|
|
323
|
-
uint256[] memory tokenIds = new uint256[](0);
|
|
324
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
325
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
326
|
-
|
|
327
|
-
vm.expectRevert(JBDistributor.JBDistributor_EmptyTokenIds.selector);
|
|
328
|
-
distributor.collectVestedRewards(address(votesToken), tokenIds, tokens, alice);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/// @notice Empty tokenIds should not cause a snapshot to be recorded.
|
|
332
|
-
function test_emptyClaimArrays_emptyTokenIds_doesNotFreezeSnapshot() public {
|
|
333
|
-
_advanceToRound(1);
|
|
334
|
-
|
|
335
|
-
uint256[] memory tokenIds = new uint256[](0);
|
|
336
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
337
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
338
|
-
|
|
339
|
-
// beginVesting reverts before _ensureSnapshotBlock is called.
|
|
340
|
-
vm.expectRevert(JBDistributor.JBDistributor_EmptyTokenIds.selector);
|
|
341
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
342
|
-
|
|
343
|
-
// No snapshot should have been recorded.
|
|
344
|
-
assertEq(distributor.roundSnapshotBlock(1), 0, "Snapshot should not be recorded after empty-array revert");
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
//*********************************************************************//
|
|
348
|
-
// ------- H-25: Eager Snapshot --------------------------------------- //
|
|
349
|
-
//*********************************************************************//
|
|
350
|
-
|
|
351
|
-
/// @notice Calling poke() in round N should eagerly set the snapshot for round N+1.
|
|
352
|
-
function test_h25_pokeEagerlySetsFutureSnapshot() public {
|
|
353
|
-
// Advance to round 1.
|
|
354
|
-
_advanceToRound(1);
|
|
355
|
-
|
|
356
|
-
// poke() in round 1 should set snapshot for round 1 AND eagerly set round 2.
|
|
357
|
-
distributor.poke();
|
|
358
|
-
|
|
359
|
-
assertGt(distributor.roundSnapshotBlock(1), 0, "Round 1 snapshot should be set");
|
|
360
|
-
assertGt(distributor.roundSnapshotBlock(2), 0, "Round 2 snapshot should be eagerly set by poke()");
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/// @notice beginVesting locks the next round's snapshot. A later call in round N+1
|
|
364
|
-
/// should use that same snapshot, not overwrite it with a fresher block.
|
|
365
|
-
function test_h25_lateJoinerCannotManipulateSnapshot() public {
|
|
366
|
-
// Alice delegates to self so she has voting power.
|
|
367
|
-
vm.prank(alice);
|
|
368
|
-
votesToken.delegate(alice);
|
|
369
|
-
|
|
370
|
-
// Fund the distributor.
|
|
371
|
-
rewardToken.mint(address(this), 1000 ether);
|
|
372
|
-
rewardToken.approve(address(distributor), 1000 ether);
|
|
373
|
-
distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
|
|
374
|
-
|
|
375
|
-
// Advance to round 1 and call beginVesting — this locks round 1 AND eagerly locks round 2 snapshot.
|
|
376
|
-
_advanceToRound(1);
|
|
377
|
-
|
|
378
|
-
uint256[] memory tokenIds = new uint256[](1);
|
|
379
|
-
tokenIds[0] = _tokenId(alice);
|
|
380
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
381
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
382
|
-
|
|
383
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
384
|
-
|
|
385
|
-
// Record the eagerly-set round 2 snapshot.
|
|
386
|
-
uint256 eagerSnapshot = distributor.roundSnapshotBlock(2);
|
|
387
|
-
assertGt(eagerSnapshot, 0, "Round 2 snapshot should be eagerly set");
|
|
388
|
-
|
|
389
|
-
// Advance many blocks (simulating a late joiner trying to push the snapshot forward).
|
|
390
|
-
vm.roll(block.number + 100);
|
|
391
|
-
|
|
392
|
-
// Advance to round 2.
|
|
393
|
-
_advanceToRound(2);
|
|
394
|
-
|
|
395
|
-
// Fund more so beginVesting has something to distribute.
|
|
396
|
-
rewardToken.mint(address(this), 500 ether);
|
|
397
|
-
rewardToken.approve(address(distributor), 500 ether);
|
|
398
|
-
distributor.fund(address(votesToken), IERC20(address(rewardToken)), 500 ether);
|
|
399
|
-
|
|
400
|
-
// beginVesting in round 2 should NOT overwrite the eagerly-set snapshot.
|
|
401
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
402
|
-
|
|
403
|
-
assertEq(
|
|
404
|
-
distributor.roundSnapshotBlock(2),
|
|
405
|
-
eagerSnapshot,
|
|
406
|
-
"Late joiner should not overwrite eagerly-set round 2 snapshot"
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/// @notice Calling poke() twice in the same round should not change the next round's snapshot.
|
|
411
|
-
function test_h25_eagerSnapshotIdempotent() public {
|
|
412
|
-
// Advance to round 1.
|
|
413
|
-
_advanceToRound(1);
|
|
414
|
-
|
|
415
|
-
// First poke sets round 1 and eagerly sets round 2 snapshot.
|
|
416
|
-
distributor.poke();
|
|
417
|
-
uint256 firstEagerSnapshot = distributor.roundSnapshotBlock(2);
|
|
418
|
-
assertGt(firstEagerSnapshot, 0, "Round 2 snapshot should be set after first poke");
|
|
419
|
-
|
|
420
|
-
// Advance some blocks within round 1.
|
|
421
|
-
vm.roll(block.number + 50);
|
|
422
|
-
|
|
423
|
-
// Second poke in the same round should NOT change the round 2 snapshot.
|
|
424
|
-
distributor.poke();
|
|
425
|
-
uint256 secondEagerSnapshot = distributor.roundSnapshotBlock(2);
|
|
426
|
-
|
|
427
|
-
assertEq(firstEagerSnapshot, secondEagerSnapshot, "Eager snapshot should be idempotent across multiple pokes");
|
|
428
|
-
}
|
|
429
|
-
}
|