@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.
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.7",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -6,6 +6,7 @@ import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721Tiers
6
6
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
7
  import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
8
8
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
9
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
9
10
  import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
10
11
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
11
12
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
@@ -32,6 +33,9 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
32
33
  // --------------------------- custom errors ------------------------- //
33
34
  //*********************************************************************//
34
35
 
36
+ /// @notice Thrown when native ETH is sent but context.token is not NATIVE_TOKEN.
37
+ error JB721Distributor_TokenMismatch();
38
+
35
39
  /// @notice Thrown when the caller is not a terminal or controller for the project.
36
40
  error JB721Distributor_Unauthorized();
37
41
 
@@ -55,6 +59,19 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
55
59
  /// @notice The JB directory used to verify terminal/controller callers.
56
60
  IJBDirectory public immutable DIRECTORY;
57
61
 
62
+ //*********************************************************************//
63
+ // -------------------- internal stored properties ------------------- //
64
+ //*********************************************************************//
65
+
66
+ /// @notice Tracks voting power consumed per hook/token/round/owner to prevent cap resets across calls.
67
+ /// @custom:param hook The hook address.
68
+ /// @custom:param token The reward token.
69
+ /// @custom:param releaseRound The vesting release round.
70
+ /// @custom:param owner The NFT owner.
71
+ mapping(
72
+ address hook => mapping(IERC20 token => mapping(uint256 releaseRound => mapping(address owner => uint256)))
73
+ ) internal _consumedVotesOf;
74
+
58
75
  //*********************************************************************//
59
76
  // -------------------------- constructor ---------------------------- //
60
77
  //*********************************************************************//
@@ -108,16 +125,23 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
108
125
  // Use balance delta to handle fee-on-transfer tokens correctly.
109
126
  uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
110
127
  IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
111
- _balanceOf[hook][IERC20(context.token)] += IERC20(context.token).balanceOf(address(this))
112
- - balanceBefore;
128
+ uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
129
+ _balanceOf[hook][IERC20(context.token)] += delta;
130
+ _accountedBalanceOf[IERC20(context.token)] += delta;
113
131
  } 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.
132
+ // Controller-prepaid path: verify actual unaccounted balance covers the declared amount.
133
+ uint256 actual = IERC20(context.token).balanceOf(address(this));
134
+ uint256 unaccounted = actual - _accountedBalanceOf[IERC20(context.token)];
135
+ if (unaccounted < context.amount) revert JBDistributor_UnfundedSplitCredit();
136
+ _accountedBalanceOf[IERC20(context.token)] += context.amount;
116
137
  _balanceOf[hook][IERC20(context.token)] += context.amount;
117
138
  }
118
139
  } else if (msg.value != 0) {
140
+ // Validate that context.token matches NATIVE_TOKEN to prevent cross-booking attacks.
141
+ if (context.token != JBConstants.NATIVE_TOKEN) revert JB721Distributor_TokenMismatch();
119
142
  // Native ETH: credit actual value received.
120
143
  _balanceOf[hook][IERC20(context.token)] += msg.value;
144
+ _accountedBalanceOf[IERC20(context.token)] += msg.value;
121
145
  }
122
146
  }
123
147
 
@@ -194,6 +218,14 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
194
218
  ++j;
195
219
  }
196
220
  }
221
+
222
+ // Persist consumed voting power to storage to prevent cap resets across calls.
223
+ for (uint256 k; k < uniqueCount;) {
224
+ _consumedVotesOf[hook][token][vestingReleaseRound][owners[k]] = consumed[k];
225
+ unchecked {
226
+ ++k;
227
+ }
228
+ }
197
229
  }
198
230
 
199
231
  //*********************************************************************//
@@ -233,16 +265,14 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
233
265
  uint256 votingUnits =
234
266
  IJB721TiersHook(hook)
235
267
  .STORE()
236
- .tierOfTokenId({hook: hook, tokenId: tokenId, includeResolvedUri: false})
237
- .votingUnits;
268
+ .tierOfTokenId({hook: hook, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
238
269
 
239
270
  // Use the checkpoints module to verify the token's owner had voting power at the round's snapshot block.
240
271
  // If they had no voting power at that time, this token was minted or acquired after the round started
241
272
  // and is not eligible for this round's rewards.
242
- IJB721Checkpoints checkpoints = IJB721TiersHook(hook).CHECKPOINTS();
243
273
  address owner = IERC721(hook).ownerOf(tokenId);
244
- uint256 pastVotes =
245
- IVotes(address(checkpoints)).getPastVotes({account: owner, timepoint: roundSnapshotBlock[currentRound()]});
274
+ uint256 pastVotes = IVotes(address(IJB721TiersHook(hook).CHECKPOINTS()))
275
+ .getPastVotes({account: owner, timepoint: roundSnapshotBlock[currentRound()]});
246
276
 
247
277
  // If the owner had no voting power at round start, the token is ineligible.
248
278
  // slither-disable-next-line incorrect-equality
@@ -318,8 +348,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
318
348
  uint256 votingUnits =
319
349
  IJB721TiersHook(ctx.hook)
320
350
  .STORE()
321
- .tierOfTokenId({hook: ctx.hook, tokenId: tokenId, includeResolvedUri: false})
322
- .votingUnits;
351
+ .tierOfTokenId({hook: ctx.hook, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
323
352
 
324
353
  // Look up the owner, verify snapshot eligibility, and find or create the owner's tracking slot.
325
354
  uint256 ownerIndex;
@@ -354,6 +383,8 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
354
383
  if (!found) {
355
384
  ownerIndex = newUniqueCount;
356
385
  owners[newUniqueCount] = owner;
386
+ // Initialize from persistent storage to prevent cap resets across calls.
387
+ consumed[newUniqueCount] = _consumedVotesOf[ctx.hook][ctx.token][ctx.vestingReleaseRound][owner];
357
388
  unchecked {
358
389
  ++newUniqueCount;
359
390
  }
@@ -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
@@ -4,6 +4,7 @@ pragma solidity 0.8.28;
4
4
  import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
5
5
  import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
6
6
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
7
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
7
8
  import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
8
9
  import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
9
10
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
@@ -30,6 +31,9 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
30
31
  /// @notice Thrown when a tokenId has non-zero upper bits (above 160), which would alias to the same staker address.
31
32
  error JBTokenDistributor_InvalidTokenId();
32
33
 
34
+ /// @notice Thrown when native ETH is sent but context.token is not NATIVE_TOKEN.
35
+ error JBTokenDistributor_TokenMismatch();
36
+
33
37
  /// @notice Thrown when the caller is not a terminal or controller for the project.
34
38
  error JBTokenDistributor_Unauthorized();
35
39
 
@@ -90,16 +94,23 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
90
94
  // Use balance delta to handle fee-on-transfer tokens correctly.
91
95
  uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
92
96
  IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
93
- _balanceOf[hook][IERC20(context.token)] += IERC20(context.token).balanceOf(address(this))
94
- - balanceBefore;
97
+ uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
98
+ _balanceOf[hook][IERC20(context.token)] += delta;
99
+ _accountedBalanceOf[IERC20(context.token)] += delta;
95
100
  } 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.
101
+ // Controller-prepaid path: verify actual unaccounted balance covers the declared amount.
102
+ uint256 actual = IERC20(context.token).balanceOf(address(this));
103
+ uint256 unaccounted = actual - _accountedBalanceOf[IERC20(context.token)];
104
+ if (unaccounted < context.amount) revert JBDistributor_UnfundedSplitCredit();
105
+ _accountedBalanceOf[IERC20(context.token)] += context.amount;
98
106
  _balanceOf[hook][IERC20(context.token)] += context.amount;
99
107
  }
100
108
  } else if (msg.value != 0) {
109
+ // Validate that context.token matches NATIVE_TOKEN to prevent cross-booking attacks.
110
+ if (context.token != JBConstants.NATIVE_TOKEN) revert JBTokenDistributor_TokenMismatch();
101
111
  // Native ETH: credit actual value received.
102
112
  _balanceOf[hook][IERC20(context.token)] += msg.value;
113
+ _accountedBalanceOf[IERC20(context.token)] += msg.value;
103
114
  }
104
115
  }
105
116
 
@@ -153,6 +153,7 @@ contract MockStore {
153
153
  mapping(uint256 tierId => JB721Tier) public tiers;
154
154
  mapping(uint256 tierId => uint256) public burned;
155
155
  mapping(uint256 tokenId => uint256 tierId) public tokenTiers;
156
+ mapping(address hook => mapping(uint256 tokenId => uint256)) public mintBlockOf;
156
157
 
157
158
  function setMaxTierIdOf(uint256 maxTierId) external {
158
159
  maxTier = maxTierId;
@@ -185,6 +186,10 @@ contract MockStore {
185
186
  function numberOfBurnedFor(address, uint256 tierId) external view returns (uint256) {
186
187
  return burned[tierId];
187
188
  }
189
+
190
+ function setMintBlock(address hook, uint256 tokenId, uint256 blockNum) external {
191
+ mintBlockOf[hook][tokenId] = blockNum;
192
+ }
188
193
  }
189
194
 
190
195
  contract JB721DistributorTest is Test {
@@ -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
 
@@ -82,6 +83,11 @@ contract CodexNemesisStore {
82
83
  function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
83
84
  return tiers[tokenTiers[tokenId]];
84
85
  }
86
+
87
+ /// @dev Returns 0 for all tokens (backward-compatible: allows vesting).
88
+ function mintBlockOf(address, uint256) external pure returns (uint256) {
89
+ return 0;
90
+ }
85
91
  }
86
92
 
87
93
  contract CodexNemesisCheckpoints {
@@ -180,31 +186,19 @@ contract CodexNemesisAccountingPoCTest is Test {
180
186
  split: split
181
187
  });
182
188
 
189
+ // C-6 FIX: The malicious controller did not actually transfer tokens, so the
190
+ // balance-delta check reverts with UnfundedSplitCredit.
183
191
  vm.prank(maliciousController);
192
+ vm.expectRevert(JBDistributor.JBDistributor_UnfundedSplitCredit.selector);
184
193
  distributor.processSplitWith(fakeContext);
185
194
 
186
- assertEq(rewardToken.balanceOf(address(distributor)), 1000 ether, "no additional reward tokens arrived");
195
+ // The real balance should remain intact — the attack was blocked.
196
+ assertEq(rewardToken.balanceOf(address(distributor)), 1000 ether, "balance unchanged after blocked attack");
187
197
  assertEq(
188
198
  distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
189
- 100_000 ether,
190
- "tracked balance was inflated by context.amount"
199
+ 1000 ether,
200
+ "tracked balance was not inflated"
191
201
  );
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
202
  }
209
203
 
210
204
  function test_721LateMintCanUseOwnersPastVotesAndDrainRound() public {
@@ -325,23 +319,26 @@ contract CodexNemesisAccountingPoCTest is Test {
325
319
  secondLateMint[0] = 3;
326
320
  distributor.beginVesting(address(hook), secondLateMint, tokens);
327
321
 
322
+ // H-24 FIX: With persistent consumed-votes tracking, the second beginVesting sees
323
+ // that all 100 votes are already consumed, so token 3 gets 0 reward.
328
324
  assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
329
- assertEq(distributor.claimedFor(address(hook), 3, tokens[0]), 1000 ether);
325
+ assertEq(distributor.claimedFor(address(hook), 3, tokens[0]), 0, "votes already consumed, no double-claim");
330
326
  assertEq(
331
327
  distributor.totalVestingAmountOf(address(hook), tokens[0]),
332
- 2000 ether,
333
- "same 100 snapshot votes were consumed twice in separate calls"
328
+ 1000 ether,
329
+ "total vesting capped at funded balance"
334
330
  );
335
- assertGt(
331
+ assertLe(
336
332
  distributor.totalVestingAmountOf(address(hook), tokens[0]),
337
333
  distributor.balanceOf(address(hook), tokens[0]),
338
- "vesting obligations exceed funded balance"
334
+ "vesting obligations do not exceed funded balance"
339
335
  );
340
336
 
337
+ // Collection should succeed without underflow.
341
338
  vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
342
339
  vm.roll(block.number + 1);
343
340
  vm.prank(attacker);
344
- vm.expectRevert(stdError.arithmeticError);
345
341
  distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
342
+ assertEq(rewardToken.balanceOf(attacker), 1000 ether, "attacker gets only their fair share");
346
343
  }
347
344
  }
@@ -0,0 +1,133 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
8
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
9
+ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
10
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
11
+ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
12
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
13
+ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
14
+ import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
15
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
16
+ import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
17
+
18
+ import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
19
+
20
+ contract NemesisRewardToken is ERC20 {
21
+ constructor() ERC20("Reward", "RWD") {}
22
+
23
+ function mint(address to, uint256 amount) external {
24
+ _mint(to, amount);
25
+ }
26
+ }
27
+
28
+ contract NemesisStakeToken is ERC20, ERC20Votes {
29
+ constructor() ERC20("Stake", "STK") EIP712("Stake", "1") {}
30
+
31
+ function mint(address to, uint256 amount) external {
32
+ _mint(to, amount);
33
+ }
34
+
35
+ function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
36
+ super._update(from, to, value);
37
+ }
38
+ }
39
+
40
+ contract NemesisDirectory is IJBDirectory {
41
+ mapping(uint256 projectId => IJBTerminal terminal) public terminalOf;
42
+ mapping(uint256 projectId => IERC165 controller) public override controllerOf;
43
+
44
+ function setTerminal(uint256 projectId, IJBTerminal terminal) external {
45
+ terminalOf[projectId] = terminal;
46
+ }
47
+
48
+ function PROJECTS() external pure returns (IJBProjects) {
49
+ return IJBProjects(address(0));
50
+ }
51
+
52
+ function isAllowedToSetFirstController(address) external pure returns (bool) {
53
+ return false;
54
+ }
55
+
56
+ function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
57
+ return terminalOf[projectId] == terminal;
58
+ }
59
+
60
+ function primaryTerminalOf(uint256, address) external pure returns (IJBTerminal) {
61
+ return IJBTerminal(address(0));
62
+ }
63
+
64
+ function terminalsOf(uint256 projectId) external view returns (IJBTerminal[] memory terminals) {
65
+ terminals = new IJBTerminal[](1);
66
+ terminals[0] = terminalOf[projectId];
67
+ }
68
+
69
+ function setControllerOf(uint256, IERC165) external {}
70
+ function setIsAllowedToSetFirstController(address, bool) external {}
71
+ function setPrimaryTerminalOf(uint256, address, IJBTerminal) external {}
72
+ function setTerminalsOf(uint256, IJBTerminal[] calldata) external {}
73
+ }
74
+
75
+ contract CodexNemesisFreshSplitTokenMismatchTest is Test {
76
+ JBTokenDistributor distributor;
77
+ NemesisDirectory directory;
78
+ NemesisRewardToken reward;
79
+ NemesisStakeToken stake;
80
+
81
+ address attacker = address(0xA11CE);
82
+ address attackerTerminal = address(0xBEEF);
83
+ address victimHook = address(0xCAFE);
84
+
85
+ function setUp() public {
86
+ directory = new NemesisDirectory();
87
+ distributor = new JBTokenDistributor(IJBDirectory(address(directory)), 1 days, 1);
88
+ reward = new NemesisRewardToken();
89
+ stake = new NemesisStakeToken();
90
+
91
+ directory.setTerminal(1, IJBTerminal(attackerTerminal));
92
+
93
+ reward.mint(address(this), 100 ether);
94
+ reward.approve(address(distributor), 100 ether);
95
+ distributor.fund(victimHook, reward, 100 ether);
96
+
97
+ stake.mint(attacker, 1 ether);
98
+ vm.prank(attacker);
99
+ stake.delegate(attacker);
100
+ vm.roll(block.number + 1);
101
+ }
102
+
103
+ /// @notice Previously this test proved the attack worked. Now it proves the fix: sending ETH
104
+ /// with context.token set to an ERC-20 address reverts with TokenMismatch.
105
+ function test_authorizedTerminalCanBackFakeErc20CreditWithEthAndDrainVictimInventory() public {
106
+ vm.deal(attackerTerminal, 50 ether);
107
+
108
+ JBSplit memory split = JBSplit({
109
+ percent: 0,
110
+ projectId: 0,
111
+ beneficiary: payable(address(stake)),
112
+ preferAddToBalance: false,
113
+ lockedUntil: 0,
114
+ hook: IJBSplitHook(address(distributor))
115
+ });
116
+ JBSplitHookContext memory context = JBSplitHookContext({
117
+ token: address(reward),
118
+ amount: 50 ether,
119
+ decimals: 18,
120
+ projectId: 1,
121
+ groupId: uint256(uint160(address(reward))),
122
+ split: split
123
+ });
124
+
125
+ // FIX VERIFIED: The attack now reverts because context.token != NATIVE_TOKEN when msg.value != 0.
126
+ vm.prank(attackerTerminal);
127
+ vm.expectRevert(JBTokenDistributor.JBTokenDistributor_TokenMismatch.selector);
128
+ distributor.processSplitWith{value: 50 ether}(context);
129
+
130
+ // Victim's balance remains intact.
131
+ assertEq(distributor.balanceOf(victimHook, reward), 100 ether);
132
+ }
133
+ }