@bananapus/distributor-v6 0.0.5 → 0.0.7

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.
@@ -0,0 +1,344 @@
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
+ }