@bananapus/distributor-v6 0.0.6 → 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.
@@ -1,338 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.28;
3
-
4
- import {Test} from "forge-std/Test.sol";
5
-
6
- import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7
- import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
8
- import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
9
- import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
10
-
11
- import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
12
- import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
13
- import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
14
- import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
15
- import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
16
- import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
17
-
18
- import {JB721Distributor} from "../../src/JB721Distributor.sol";
19
- import {JBDistributor} from "../../src/JBDistributor.sol";
20
- import {IJBDistributor} from "../../src/interfaces/IJBDistributor.sol";
21
-
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
- }
@@ -1,344 +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
- import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
11
- import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
12
- import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
13
-
14
- import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
15
- import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
16
- import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
17
- import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
18
- import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
19
- import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
20
-
21
- import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
22
- import {JB721Distributor} from "../../src/JB721Distributor.sol";
23
- import {JBDistributor} from "../../src/JBDistributor.sol";
24
-
25
- import {
26
- H26MockDirectory,
27
- H26MockHook,
28
- H26MockRewardToken,
29
- H26MockStore,
30
- H26MockCheckpoints
31
- } from "./H26VotingPowerCap.t.sol";
32
-
33
- // =========================================================================
34
- // Mock contracts for JBTokenDistributor tests (C-6, L-17)
35
- // =========================================================================
36
-
37
- /// @notice Mock JB directory for Pass12 tests.
38
- contract P12MockDirectory {
39
- mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
40
- mapping(uint256 projectId => address controller) public controllers;
41
-
42
- function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
43
- terminals[projectId][terminal] = isTerminal;
44
- }
45
-
46
- function setController(uint256 projectId, address controller) external {
47
- controllers[projectId] = controller;
48
- }
49
-
50
- function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
51
- return terminals[projectId][address(terminal)];
52
- }
53
-
54
- function controllerOf(uint256 projectId) external view returns (IERC165) {
55
- return IERC165(controllers[projectId]);
56
- }
57
- }
58
-
59
- /// @notice Simple ERC20 reward token for Pass12 tests.
60
- contract P12MockRewardToken is ERC20 {
61
- constructor() ERC20("Reward", "RWD") {}
62
-
63
- function mint(address to, uint256 amount) external {
64
- _mint(to, amount);
65
- }
66
- }
67
-
68
- /// @notice ERC20Votes token for staking in Pass12 tests.
69
- contract P12MockVotesToken is ERC20, ERC20Votes {
70
- constructor() ERC20("StakeToken", "STK") EIP712("StakeToken", "1") {}
71
-
72
- function mint(address to, uint256 amount) external {
73
- _mint(to, amount);
74
- }
75
-
76
- function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
77
- super._update(from, to, value);
78
- }
79
- }
80
-
81
- // =========================================================================
82
- // Test contract
83
- // =========================================================================
84
-
85
- /// @notice Tests for Pass 12 audit fixes: C-6, H-24, and L-17.
86
- contract Pass12FixesTest is Test {
87
- // --- Token Distributor setup (C-6, L-17) ---
88
- P12MockDirectory tokenDirectory;
89
- P12MockRewardToken rewardToken;
90
- P12MockVotesToken votesToken;
91
- JBTokenDistributor tokenDistributor;
92
-
93
- // --- 721 Distributor setup (H-24) ---
94
- H26MockStore store;
95
- H26MockHook hook;
96
- H26MockDirectory nftDirectory;
97
- H26MockRewardToken nftRewardToken;
98
- JB721Distributor nftDistributor;
99
-
100
- address alice = makeAddr("alice");
101
- address bob = makeAddr("bob");
102
- address controller = makeAddr("controller");
103
- address terminal = makeAddr("terminal");
104
- uint256 projectId = 1;
105
-
106
- uint256 constant ROUND_DURATION = 100;
107
- uint256 constant VESTING_ROUNDS = 4;
108
-
109
- function setUp() public {
110
- // --- Token Distributor ---
111
- tokenDirectory = new P12MockDirectory();
112
- rewardToken = new P12MockRewardToken();
113
- votesToken = new P12MockVotesToken();
114
-
115
- tokenDirectory.setTerminal(projectId, terminal, true);
116
- tokenDirectory.setController(projectId, controller);
117
-
118
- tokenDistributor = new JBTokenDistributor(IJBDirectory(address(tokenDirectory)), ROUND_DURATION, VESTING_ROUNDS);
119
-
120
- votesToken.mint(alice, 1000 ether);
121
- vm.prank(alice);
122
- votesToken.delegate(alice);
123
-
124
- // --- 721 Distributor ---
125
- store = new H26MockStore();
126
- hook = new H26MockHook(store);
127
- nftDirectory = new H26MockDirectory();
128
-
129
- nftDistributor = new JB721Distributor(IJBDirectory(address(nftDirectory)), ROUND_DURATION, VESTING_ROUNDS);
130
-
131
- nftDirectory.setTerminal(projectId, address(this), true);
132
-
133
- nftRewardToken = new H26MockRewardToken();
134
-
135
- JB721TierFlags memory flags;
136
-
137
- // Tier 1: votingUnits = 50 each, 3 minted out of 10.
138
- store.setMaxTierIdOf(1);
139
- store.setTier(
140
- 1,
141
- JB721Tier({
142
- id: 1,
143
- price: 1 ether,
144
- remainingSupply: 7,
145
- initialSupply: 10,
146
- votingUnits: 50,
147
- reserveFrequency: 0,
148
- reserveBeneficiary: address(0),
149
- encodedIPFSUri: bytes32(0),
150
- category: 0,
151
- discountPercent: 0,
152
- flags: flags,
153
- splitPercent: 0,
154
- resolvedUri: ""
155
- })
156
- );
157
-
158
- // Alice owns 3 NFTs: tokens 1, 2, 3 — all tier 1 (50 voting units each).
159
- store.setTokenTier(1, 1);
160
- store.setTokenTier(2, 1);
161
- store.setTokenTier(3, 1);
162
- hook.setOwner(1, alice);
163
- hook.setOwner(2, alice);
164
- hook.setOwner(3, alice);
165
- }
166
-
167
- // =====================================================================
168
- // Helpers
169
- // =====================================================================
170
-
171
- function _advanceToRound(uint256 round, JBDistributor dist) internal {
172
- uint256 targetTimestamp = dist.roundStartTimestamp(round) + 1;
173
- if (block.timestamp < targetTimestamp) {
174
- vm.warp(targetTimestamp);
175
- }
176
- vm.roll(block.number + 1);
177
- }
178
-
179
- function _buildTokenContext(address token, uint256 amount) internal view returns (JBSplitHookContext memory) {
180
- JBSplit memory split = JBSplit({
181
- percent: 1_000_000_000,
182
- projectId: 0,
183
- beneficiary: payable(address(votesToken)),
184
- preferAddToBalance: false,
185
- lockedUntil: 0,
186
- hook: IJBSplitHook(address(tokenDistributor))
187
- });
188
-
189
- return JBSplitHookContext({
190
- token: token, amount: amount, decimals: 18, projectId: projectId, groupId: 0, split: split
191
- });
192
- }
193
-
194
- // =====================================================================
195
- // C-6: Unbacked split credits
196
- // =====================================================================
197
-
198
- /// @notice A controller calls processSplitWith without transferring tokens first.
199
- /// Should revert with JBDistributor_UnfundedSplitCredit.
200
- function test_C6_fix_reverts_unfunded_credit() public {
201
- uint256 amount = 500 ether;
202
- JBSplitHookContext memory context = _buildTokenContext(address(rewardToken), amount);
203
-
204
- // Controller calls processSplitWith WITHOUT transferring any tokens to the distributor.
205
- // The controller has no allowance either, so it falls into the "else" (controller-prepaid) branch.
206
- vm.prank(controller);
207
- vm.expectRevert(JBDistributor.JBDistributor_UnfundedSplitCredit.selector);
208
- tokenDistributor.processSplitWith(context);
209
-
210
- // Verify no balance was credited.
211
- assertEq(
212
- tokenDistributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
213
- 0,
214
- "No balance should be credited without actual token transfer"
215
- );
216
- }
217
-
218
- /// @notice A controller that actually transfers tokens before calling processSplitWith
219
- /// should still work correctly.
220
- function test_C6_legitimate_prepaid_still_works() public {
221
- uint256 amount = 500 ether;
222
- JBSplitHookContext memory context = _buildTokenContext(address(rewardToken), amount);
223
-
224
- // Controller transfers tokens to the distributor first.
225
- rewardToken.mint(controller, amount);
226
- vm.prank(controller);
227
- rewardToken.transfer(address(tokenDistributor), amount);
228
-
229
- // Now the controller calls processSplitWith — should succeed because the unaccounted
230
- // balance covers the declared amount.
231
- vm.prank(controller);
232
- tokenDistributor.processSplitWith(context);
233
-
234
- // Verify balance was credited.
235
- assertEq(
236
- tokenDistributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
237
- amount,
238
- "Balance should be credited when tokens were actually transferred"
239
- );
240
-
241
- // Verify the tokens are held by the distributor.
242
- assertEq(rewardToken.balanceOf(address(tokenDistributor)), amount, "Tokens should be in the distributor");
243
- }
244
-
245
- // =====================================================================
246
- // H-24: Voting cap reset across calls
247
- // =====================================================================
248
-
249
- /// @notice Calling beginVesting multiple times in the same round for the same owner's
250
- /// different tokens should not reset the voting power cap.
251
- function test_H24_fix_caps_across_calls() public {
252
- // Alice has 3 NFTs x 50 voting units = 150 total.
253
- // Her pastVotes is only 100 — so she should be capped at 100 total.
254
- hook._checkpoints().setVotesOverride(alice, 100);
255
-
256
- // Fund with 1500 ether. Total stake = 150 (3 minted * 50 voting units).
257
- nftRewardToken.mint(address(this), 1500 ether);
258
- nftRewardToken.approve(address(nftDistributor), 1500 ether);
259
- nftDistributor.fund(address(hook), IERC20(address(nftRewardToken)), 1500 ether);
260
-
261
- IERC20[] memory tokens = new IERC20[](1);
262
- tokens[0] = IERC20(address(nftRewardToken));
263
-
264
- // Call beginVesting THREE TIMES, each with a single token ID.
265
- // Without the H-24 fix, each call resets the consumed voting power to 0,
266
- // allowing Alice to claim 50 voting units per call = 150 total (bypassing the 100 cap).
267
- // With the fix, consumed votes persist in storage across calls.
268
- uint256[] memory singleId = new uint256[](1);
269
-
270
- singleId[0] = 1;
271
- nftDistributor.beginVesting(address(hook), singleId, tokens);
272
-
273
- singleId[0] = 2;
274
- nftDistributor.beginVesting(address(hook), singleId, tokens);
275
-
276
- singleId[0] = 3;
277
- nftDistributor.beginVesting(address(hook), singleId, tokens);
278
-
279
- // Check claimed amounts.
280
- uint256 claimed1 = nftDistributor.claimedFor(address(hook), 1, IERC20(address(nftRewardToken)));
281
- uint256 claimed2 = nftDistributor.claimedFor(address(hook), 2, IERC20(address(nftRewardToken)));
282
- uint256 claimed3 = nftDistributor.claimedFor(address(hook), 3, IERC20(address(nftRewardToken)));
283
-
284
- uint256 totalClaimed = claimed1 + claimed2 + claimed3;
285
-
286
- // With cap enforced: Alice has 100 pastVotes out of 150 total stake.
287
- // NFT 1: effective stake = min(50, 100 remaining) = 50, reward = 1500 * 50/150 = 500
288
- // NFT 2: effective stake = min(50, 50 remaining) = 50, reward = 1500 * 50/150 = 500
289
- // NFT 3: effective stake = min(50, 0 remaining) = 0, reward = 0
290
- // Total = 1000 ether (not 1500).
291
- assertEq(claimed1, 500 ether, "NFT 1 should get full 50-unit share");
292
- assertEq(claimed2, 500 ether, "NFT 2 should get full 50-unit share");
293
- assertEq(claimed3, 0, "NFT 3 should get 0 (voting power exhausted across calls)");
294
- assertEq(totalClaimed, 1000 ether, "Total should be capped at 100/150 of distributable");
295
- }
296
-
297
- // =====================================================================
298
- // L-17: fund() ETH trap
299
- // =====================================================================
300
-
301
- /// @notice Sending ETH with an ERC-20 token in fund() should revert.
302
- function test_L17_fix_reverts_unexpected_eth() public {
303
- uint256 erc20Amount = 100 ether;
304
- rewardToken.mint(address(this), erc20Amount);
305
- rewardToken.approve(address(tokenDistributor), erc20Amount);
306
-
307
- // Call fund() with an ERC-20 token but also send msg.value.
308
- // This should revert with JBDistributor_UnexpectedNativeValue.
309
- vm.expectRevert(JBDistributor.JBDistributor_UnexpectedNativeValue.selector);
310
- tokenDistributor.fund{value: 1 ether}(address(votesToken), IERC20(address(rewardToken)), erc20Amount);
311
- }
312
-
313
- /// @notice fund() with native token and msg.value should still work normally.
314
- function test_L17_fund_native_token_still_works() public {
315
- vm.deal(address(this), 5 ether);
316
-
317
- tokenDistributor.fund{value: 5 ether}(
318
- address(votesToken),
319
- IERC20(JBConstants.NATIVE_TOKEN),
320
- 0 // amount param is ignored for native token
321
- );
322
-
323
- assertEq(
324
- tokenDistributor.balanceOf(address(votesToken), IERC20(JBConstants.NATIVE_TOKEN)),
325
- 5 ether,
326
- "Native ETH fund should work normally"
327
- );
328
- }
329
-
330
- /// @notice fund() with ERC-20 and no ETH should still work normally.
331
- function test_L17_fund_erc20_no_eth_still_works() public {
332
- uint256 amount = 200 ether;
333
- rewardToken.mint(address(this), amount);
334
- rewardToken.approve(address(tokenDistributor), amount);
335
-
336
- tokenDistributor.fund(address(votesToken), IERC20(address(rewardToken)), amount);
337
-
338
- assertEq(
339
- tokenDistributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
340
- amount,
341
- "ERC-20 fund without ETH should work normally"
342
- );
343
- }
344
- }