@bananapus/distributor-v6 0.0.5 → 0.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/distributor-v6",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,6 +55,19 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
55
55
  /// @notice The JB directory used to verify terminal/controller callers.
56
56
  IJBDirectory public immutable DIRECTORY;
57
57
 
58
+ //*********************************************************************//
59
+ // -------------------- internal stored properties ------------------- //
60
+ //*********************************************************************//
61
+
62
+ /// @notice Tracks voting power consumed per hook/token/round/owner to prevent cap resets across calls.
63
+ /// @custom:param hook The hook address.
64
+ /// @custom:param token The reward token.
65
+ /// @custom:param releaseRound The vesting release round.
66
+ /// @custom:param owner The NFT owner.
67
+ mapping(
68
+ address hook => mapping(IERC20 token => mapping(uint256 releaseRound => mapping(address owner => uint256)))
69
+ ) internal _consumedVotesOf;
70
+
58
71
  //*********************************************************************//
59
72
  // -------------------------- constructor ---------------------------- //
60
73
  //*********************************************************************//
@@ -108,16 +121,21 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
108
121
  // Use balance delta to handle fee-on-transfer tokens correctly.
109
122
  uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
110
123
  IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
111
- _balanceOf[hook][IERC20(context.token)] += IERC20(context.token).balanceOf(address(this))
112
- - balanceBefore;
124
+ uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
125
+ _balanceOf[hook][IERC20(context.token)] += delta;
126
+ _accountedBalanceOf[IERC20(context.token)] += delta;
113
127
  } else {
114
- // Controller-prepaid path: the controller sends tokens directly before calling processSplitWith.
115
- // Credit the declared amount since the tokens are already held by this contract.
128
+ // Controller-prepaid path: verify actual unaccounted balance covers the declared amount.
129
+ uint256 actual = IERC20(context.token).balanceOf(address(this));
130
+ uint256 unaccounted = actual - _accountedBalanceOf[IERC20(context.token)];
131
+ if (unaccounted < context.amount) revert JBDistributor_UnfundedSplitCredit();
132
+ _accountedBalanceOf[IERC20(context.token)] += context.amount;
116
133
  _balanceOf[hook][IERC20(context.token)] += context.amount;
117
134
  }
118
135
  } else if (msg.value != 0) {
119
136
  // Native ETH: credit actual value received.
120
137
  _balanceOf[hook][IERC20(context.token)] += msg.value;
138
+ _accountedBalanceOf[IERC20(context.token)] += msg.value;
121
139
  }
122
140
  }
123
141
 
@@ -194,6 +212,14 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
194
212
  ++j;
195
213
  }
196
214
  }
215
+
216
+ // Persist consumed voting power to storage to prevent cap resets across calls.
217
+ for (uint256 k; k < uniqueCount;) {
218
+ _consumedVotesOf[hook][token][vestingReleaseRound][owners[k]] = consumed[k];
219
+ unchecked {
220
+ ++k;
221
+ }
222
+ }
197
223
  }
198
224
 
199
225
  //*********************************************************************//
@@ -354,6 +380,8 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
354
380
  if (!found) {
355
381
  ownerIndex = newUniqueCount;
356
382
  owners[newUniqueCount] = owner;
383
+ // Initialize from persistent storage to prevent cap resets across calls.
384
+ consumed[newUniqueCount] = _consumedVotesOf[ctx.hook][ctx.token][ctx.vestingReleaseRound][owner];
357
385
  unchecked {
358
386
  ++newUniqueCount;
359
387
  }
@@ -27,9 +27,18 @@ abstract contract JBDistributor is IJBDistributor {
27
27
  /// @notice Thrown when the caller does not have access to the token.
28
28
  error JBDistributor_NoAccess();
29
29
 
30
+ /// @notice Thrown when the round duration is zero.
31
+ error JBDistributor_InvalidRoundDuration();
32
+
30
33
  /// @notice Thrown when there is nothing to distribute for a token in the current round.
31
34
  error JBDistributor_NothingToDistribute();
32
35
 
36
+ /// @notice Thrown when a controller-prepaid split credit is not backed by actual token balance.
37
+ error JBDistributor_UnfundedSplitCredit();
38
+
39
+ /// @notice Thrown when unexpected native ETH is sent with an ERC-20 operation.
40
+ error JBDistributor_UnexpectedNativeValue();
41
+
33
42
  //*********************************************************************//
34
43
  // ------------------------- public constants ------------------------ //
35
44
  //*********************************************************************//
@@ -80,6 +89,10 @@ abstract contract JBDistributor is IJBDistributor {
80
89
  // -------------------- internal stored properties ------------------- //
81
90
  //*********************************************************************//
82
91
 
92
+ /// @notice The total accounted balance of each token across all hooks.
93
+ /// @custom:param token The token to check the accounted balance of.
94
+ mapping(IERC20 token => uint256) internal _accountedBalanceOf;
95
+
83
96
  /// @notice The balance of a token held for a specific hook's stakers.
84
97
  /// @custom:param hook The hook whose balance to check.
85
98
  /// @custom:param token The token to check the balance of.
@@ -99,6 +112,7 @@ abstract contract JBDistributor is IJBDistributor {
99
112
  /// @param roundDuration_ The duration of each round, specified in seconds.
100
113
  /// @param vestingRounds_ The number of rounds until tokens are fully vested.
101
114
  constructor(uint256 roundDuration_, uint256 vestingRounds_) {
115
+ if (roundDuration_ == 0) revert JBDistributor_InvalidRoundDuration();
102
116
  startingTimestamp = block.timestamp;
103
117
  roundDuration = roundDuration_;
104
118
  vestingRounds = vestingRounds_;
@@ -162,12 +176,14 @@ abstract contract JBDistributor is IJBDistributor {
162
176
  if (address(token) == JBConstants.NATIVE_TOKEN) {
163
177
  amount = msg.value;
164
178
  } else {
179
+ if (msg.value != 0) revert JBDistributor_UnexpectedNativeValue();
165
180
  // Use balance delta to handle fee-on-transfer tokens correctly.
166
181
  uint256 balanceBefore = token.balanceOf(address(this));
167
182
  token.safeTransferFrom(msg.sender, address(this), amount);
168
183
  amount = token.balanceOf(address(this)) - balanceBefore;
169
184
  }
170
185
  _balanceOf[hook][token] += amount;
186
+ _accountedBalanceOf[token] += amount;
171
187
  }
172
188
 
173
189
  /// @notice Record the snapshot block for the current round. Callable by anyone (keepers, frontends).
@@ -465,6 +481,7 @@ abstract contract JBDistributor is IJBDistributor {
465
481
  if (ownerClaim) {
466
482
  // Decrement the hook's balance and transfer tokens out.
467
483
  _balanceOf[hook][token] -= totalTokenAmount;
484
+ _accountedBalanceOf[token] -= totalTokenAmount;
468
485
 
469
486
  if (address(token) == JBConstants.NATIVE_TOKEN) {
470
487
  // slither-disable-next-line arbitrary-send-eth,reentrancy-eth
@@ -90,16 +90,21 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
90
90
  // Use balance delta to handle fee-on-transfer tokens correctly.
91
91
  uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
92
92
  IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
93
- _balanceOf[hook][IERC20(context.token)] += IERC20(context.token).balanceOf(address(this))
94
- - balanceBefore;
93
+ uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
94
+ _balanceOf[hook][IERC20(context.token)] += delta;
95
+ _accountedBalanceOf[IERC20(context.token)] += delta;
95
96
  } else {
96
- // Controller-prepaid path: the controller sends tokens directly before calling processSplitWith.
97
- // Credit the declared amount since the tokens are already held by this contract.
97
+ // Controller-prepaid path: verify actual unaccounted balance covers the declared amount.
98
+ uint256 actual = IERC20(context.token).balanceOf(address(this));
99
+ uint256 unaccounted = actual - _accountedBalanceOf[IERC20(context.token)];
100
+ if (unaccounted < context.amount) revert JBDistributor_UnfundedSplitCredit();
101
+ _accountedBalanceOf[IERC20(context.token)] += context.amount;
98
102
  _balanceOf[hook][IERC20(context.token)] += context.amount;
99
103
  }
100
104
  } else if (msg.value != 0) {
101
105
  // Native ETH: credit actual value received.
102
106
  _balanceOf[hook][IERC20(context.token)] += msg.value;
107
+ _accountedBalanceOf[IERC20(context.token)] += msg.value;
103
108
  }
104
109
  }
105
110
 
@@ -18,6 +18,7 @@ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookCont
18
18
  import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
19
19
  import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
20
20
 
21
+ import {JBDistributor} from "../../src/JBDistributor.sol";
21
22
  import {JB721Distributor} from "../../src/JB721Distributor.sol";
22
23
  import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
23
24
 
@@ -180,31 +181,19 @@ contract CodexNemesisAccountingPoCTest is Test {
180
181
  split: split
181
182
  });
182
183
 
184
+ // C-6 FIX: The malicious controller did not actually transfer tokens, so the
185
+ // balance-delta check reverts with UnfundedSplitCredit.
183
186
  vm.prank(maliciousController);
187
+ vm.expectRevert(JBDistributor.JBDistributor_UnfundedSplitCredit.selector);
184
188
  distributor.processSplitWith(fakeContext);
185
189
 
186
- assertEq(rewardToken.balanceOf(address(distributor)), 1000 ether, "no additional reward tokens arrived");
190
+ // The real balance should remain intact — the attack was blocked.
191
+ assertEq(rewardToken.balanceOf(address(distributor)), 1000 ether, "balance unchanged after blocked attack");
187
192
  assertEq(
188
193
  distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
189
- 100_000 ether,
190
- "tracked balance was inflated by context.amount"
194
+ 1000 ether,
195
+ "tracked balance was not inflated"
191
196
  );
192
-
193
- uint256[] memory tokenIds = new uint256[](1);
194
- tokenIds[0] = uint256(uint160(attacker));
195
- IERC20[] memory tokens = new IERC20[](1);
196
- tokens[0] = IERC20(address(rewardToken));
197
-
198
- distributor.beginVesting(address(votesToken), tokenIds, tokens);
199
- assertEq(distributor.claimedFor(address(votesToken), tokenIds[0], tokens[0]), 1000 ether);
200
-
201
- vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
202
- vm.roll(block.number + 1);
203
- vm.prank(attacker);
204
- distributor.collectVestedRewards(address(votesToken), tokenIds, tokens, attacker);
205
-
206
- assertEq(rewardToken.balanceOf(attacker), 1000 ether, "attacker drained the real inventory");
207
- assertEq(rewardToken.balanceOf(address(distributor)), 0, "honest claimants are left unfunded");
208
197
  }
209
198
 
210
199
  function test_721LateMintCanUseOwnersPastVotesAndDrainRound() public {
@@ -325,23 +314,26 @@ contract CodexNemesisAccountingPoCTest is Test {
325
314
  secondLateMint[0] = 3;
326
315
  distributor.beginVesting(address(hook), secondLateMint, tokens);
327
316
 
317
+ // H-24 FIX: With persistent consumed-votes tracking, the second beginVesting sees
318
+ // that all 100 votes are already consumed, so token 3 gets 0 reward.
328
319
  assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
329
- assertEq(distributor.claimedFor(address(hook), 3, tokens[0]), 1000 ether);
320
+ assertEq(distributor.claimedFor(address(hook), 3, tokens[0]), 0, "votes already consumed, no double-claim");
330
321
  assertEq(
331
322
  distributor.totalVestingAmountOf(address(hook), tokens[0]),
332
- 2000 ether,
333
- "same 100 snapshot votes were consumed twice in separate calls"
323
+ 1000 ether,
324
+ "total vesting capped at funded balance"
334
325
  );
335
- assertGt(
326
+ assertLe(
336
327
  distributor.totalVestingAmountOf(address(hook), tokens[0]),
337
328
  distributor.balanceOf(address(hook), tokens[0]),
338
- "vesting obligations exceed funded balance"
329
+ "vesting obligations do not exceed funded balance"
339
330
  );
340
331
 
332
+ // Collection should succeed without underflow.
341
333
  vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
342
334
  vm.roll(block.number + 1);
343
335
  vm.prank(attacker);
344
- vm.expectRevert(stdError.arithmeticError);
345
336
  distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
337
+ assertEq(rewardToken.balanceOf(attacker), 1000 ether, "attacker gets only their fair share");
346
338
  }
347
339
  }
@@ -0,0 +1,191 @@
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
+
12
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
13
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
14
+ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
15
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
16
+ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
17
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
18
+ import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
19
+
20
+ import {JB721Distributor} from "../../src/JB721Distributor.sol";
21
+ import {JBDistributor} from "../../src/JBDistributor.sol";
22
+ import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
23
+
24
+ import {H26MockDirectory, H26MockHook, H26MockRewardToken, H26MockStore} from "./H26VotingPowerCap.t.sol";
25
+
26
+ contract CodexNemesisDirectory {
27
+ mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
28
+ mapping(uint256 projectId => address controller) public controllers;
29
+
30
+ function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
31
+ terminals[projectId][terminal] = isTerminal;
32
+ }
33
+
34
+ function setController(uint256 projectId, address controller) external {
35
+ controllers[projectId] = controller;
36
+ }
37
+
38
+ function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
39
+ return terminals[projectId][address(terminal)];
40
+ }
41
+
42
+ function controllerOf(uint256 projectId) external view returns (IERC165) {
43
+ return IERC165(controllers[projectId]);
44
+ }
45
+ }
46
+
47
+ contract CodexNemesisRewardToken is ERC20 {
48
+ constructor() ERC20("Reward", "RWD") {}
49
+
50
+ function mint(address to, uint256 amount) external {
51
+ _mint(to, amount);
52
+ }
53
+ }
54
+
55
+ contract CodexNemesisVotesToken is ERC20, ERC20Votes {
56
+ constructor() ERC20("StakeToken", "STK") EIP712("StakeToken", "1") {}
57
+
58
+ function mint(address to, uint256 amount) external {
59
+ _mint(to, amount);
60
+ }
61
+
62
+ function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
63
+ super._update(from, to, value);
64
+ }
65
+ }
66
+
67
+ contract CodexNemesisPoCTest is Test {
68
+ uint256 constant ROUND_DURATION = 100;
69
+ uint256 constant VESTING_ROUNDS = 1;
70
+
71
+ function test_721OwnerVotingCapResetsAcrossBeginVestingCalls() public {
72
+ H26MockStore store = new H26MockStore();
73
+ H26MockHook hook = new H26MockHook(store);
74
+ H26MockDirectory directory = new H26MockDirectory();
75
+ JB721Distributor distributor =
76
+ new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
77
+ H26MockRewardToken rewardToken = new H26MockRewardToken();
78
+
79
+ address alice = makeAddr("alice");
80
+
81
+ store.setMaxTierIdOf(1);
82
+ store.setTier(
83
+ 1,
84
+ JB721Tier({
85
+ id: 1,
86
+ price: 1 ether,
87
+ remainingSupply: 7,
88
+ initialSupply: 10,
89
+ votingUnits: 50,
90
+ reserveFrequency: 0,
91
+ reserveBeneficiary: address(0),
92
+ encodedIPFSUri: bytes32(0),
93
+ category: 0,
94
+ discountPercent: 0,
95
+ flags: JB721TierFlags({
96
+ allowOwnerMint: false,
97
+ transfersPausable: false,
98
+ cantBeRemoved: false,
99
+ cantIncreaseDiscountPercent: false,
100
+ cantBuyWithCredits: false
101
+ }),
102
+ splitPercent: 0,
103
+ resolvedUri: ""
104
+ })
105
+ );
106
+
107
+ store.setTokenTier(1, 1);
108
+ store.setTokenTier(2, 1);
109
+ store.setTokenTier(3, 1);
110
+ hook.setOwner(1, alice);
111
+ hook.setOwner(2, alice);
112
+ hook.setOwner(3, alice);
113
+ hook._checkpoints().setVotesOverride(alice, 100);
114
+
115
+ rewardToken.mint(address(this), 1500 ether);
116
+ rewardToken.approve(address(distributor), 1500 ether);
117
+ distributor.fund(address(hook), IERC20(address(rewardToken)), 1500 ether);
118
+
119
+ IERC20[] memory tokens = new IERC20[](1);
120
+ tokens[0] = IERC20(address(rewardToken));
121
+
122
+ uint256[] memory oneTokenId = new uint256[](1);
123
+ oneTokenId[0] = 1;
124
+ distributor.beginVesting(address(hook), oneTokenId, tokens);
125
+ oneTokenId[0] = 2;
126
+ distributor.beginVesting(address(hook), oneTokenId, tokens);
127
+ oneTokenId[0] = 3;
128
+ distributor.beginVesting(address(hook), oneTokenId, tokens);
129
+
130
+ uint256 claimed1 = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
131
+ uint256 claimed2 = distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken)));
132
+ uint256 claimed3 = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
133
+
134
+ // H-24 FIX: With persistent consumed-votes tracking, the total claimed is now correctly
135
+ // capped at the owner's voting power (100 votes / 150 total stake * 1500 = 1000 ether).
136
+ assertEq(claimed1 + claimed2 + claimed3, 1000 ether);
137
+ assertLe(claimed1 + claimed2 + claimed3, 1000 ether);
138
+ }
139
+
140
+ function test_processSplitWithControllerPathCanCreditUnfundedBalanceAndDrainOtherHook() public {
141
+ CodexNemesisDirectory directory = new CodexNemesisDirectory();
142
+ JBTokenDistributor distributor =
143
+ new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
144
+ CodexNemesisRewardToken rewardToken = new CodexNemesisRewardToken();
145
+ CodexNemesisVotesToken attackerVotes = new CodexNemesisVotesToken();
146
+
147
+ address attacker = makeAddr("attacker");
148
+ address maliciousController = makeAddr("maliciousController");
149
+ address victimHook = makeAddr("victimHook");
150
+ uint256 projectId = 1;
151
+
152
+ directory.setController(projectId, maliciousController);
153
+
154
+ rewardToken.mint(address(this), 1000 ether);
155
+ rewardToken.approve(address(distributor), 1000 ether);
156
+ distributor.fund(victimHook, IERC20(address(rewardToken)), 1000 ether);
157
+
158
+ attackerVotes.mint(attacker, 1000 ether);
159
+ vm.prank(attacker);
160
+ attackerVotes.delegate(attacker);
161
+ vm.roll(block.number + 1);
162
+
163
+ JBSplit memory split = JBSplit({
164
+ percent: 1_000_000_000,
165
+ projectId: 0,
166
+ beneficiary: payable(address(attackerVotes)),
167
+ preferAddToBalance: false,
168
+ lockedUntil: 0,
169
+ hook: IJBSplitHook(address(distributor))
170
+ });
171
+ JBSplitHookContext memory context = JBSplitHookContext({
172
+ token: address(rewardToken),
173
+ amount: 1000 ether,
174
+ decimals: 18,
175
+ projectId: projectId,
176
+ groupId: uint256(uint160(address(rewardToken))),
177
+ split: split
178
+ });
179
+
180
+ // C-6 FIX: The malicious controller did not actually transfer tokens, so the
181
+ // balance-delta check should revert with UnfundedSplitCredit.
182
+ vm.prank(maliciousController);
183
+ vm.expectRevert(JBDistributor.JBDistributor_UnfundedSplitCredit.selector);
184
+ distributor.processSplitWith(context);
185
+
186
+ // The victim hook's balance should remain intact.
187
+ assertEq(distributor.balanceOf(victimHook, IERC20(address(rewardToken))), 1000 ether);
188
+ // No balance should be credited to the attacker's hook.
189
+ assertEq(distributor.balanceOf(address(attackerVotes), IERC20(address(rewardToken))), 0);
190
+ }
191
+ }
@@ -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
+ }