@bananapus/distributor-v6 0.0.6 → 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.6",
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
 
@@ -133,6 +137,8 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
133
137
  _balanceOf[hook][IERC20(context.token)] += context.amount;
134
138
  }
135
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();
136
142
  // Native ETH: credit actual value received.
137
143
  _balanceOf[hook][IERC20(context.token)] += msg.value;
138
144
  _accountedBalanceOf[IERC20(context.token)] += msg.value;
@@ -259,16 +265,14 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
259
265
  uint256 votingUnits =
260
266
  IJB721TiersHook(hook)
261
267
  .STORE()
262
- .tierOfTokenId({hook: hook, tokenId: tokenId, includeResolvedUri: false})
263
- .votingUnits;
268
+ .tierOfTokenId({hook: hook, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
264
269
 
265
270
  // Use the checkpoints module to verify the token's owner had voting power at the round's snapshot block.
266
271
  // If they had no voting power at that time, this token was minted or acquired after the round started
267
272
  // and is not eligible for this round's rewards.
268
- IJB721Checkpoints checkpoints = IJB721TiersHook(hook).CHECKPOINTS();
269
273
  address owner = IERC721(hook).ownerOf(tokenId);
270
- uint256 pastVotes =
271
- IVotes(address(checkpoints)).getPastVotes({account: owner, timepoint: roundSnapshotBlock[currentRound()]});
274
+ uint256 pastVotes = IVotes(address(IJB721TiersHook(hook).CHECKPOINTS()))
275
+ .getPastVotes({account: owner, timepoint: roundSnapshotBlock[currentRound()]});
272
276
 
273
277
  // If the owner had no voting power at round start, the token is ineligible.
274
278
  // slither-disable-next-line incorrect-equality
@@ -344,8 +348,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
344
348
  uint256 votingUnits =
345
349
  IJB721TiersHook(ctx.hook)
346
350
  .STORE()
347
- .tierOfTokenId({hook: ctx.hook, tokenId: tokenId, includeResolvedUri: false})
348
- .votingUnits;
351
+ .tierOfTokenId({hook: ctx.hook, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
349
352
 
350
353
  // Look up the owner, verify snapshot eligibility, and find or create the owner's tracking slot.
351
354
  uint256 ownerIndex;
@@ -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
 
@@ -102,6 +106,8 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
102
106
  _balanceOf[hook][IERC20(context.token)] += context.amount;
103
107
  }
104
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();
105
111
  // Native ETH: credit actual value received.
106
112
  _balanceOf[hook][IERC20(context.token)] += msg.value;
107
113
  _accountedBalanceOf[IERC20(context.token)] += msg.value;
@@ -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 {
@@ -83,6 +83,11 @@ contract CodexNemesisStore {
83
83
  function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
84
84
  return tiers[tokenTiers[tokenId]];
85
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
+ }
86
91
  }
87
92
 
88
93
  contract CodexNemesisCheckpoints {
@@ -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
+ }
@@ -0,0 +1,218 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
7
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
8
+
9
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
10
+ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
11
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
12
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
13
+ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
14
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
15
+
16
+ import {JB721Distributor} from "../../src/JB721Distributor.sol";
17
+ import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
18
+
19
+ contract CodexNemesisToken is ERC20 {
20
+ constructor() ERC20("Reward", "RWD") {}
21
+
22
+ function decimals() public pure override returns (uint8) {
23
+ return 6;
24
+ }
25
+
26
+ function mint(address to, uint256 amount) external {
27
+ _mint(to, amount);
28
+ }
29
+ }
30
+
31
+ contract CodexNemesisDirectory {
32
+ address public terminal;
33
+ IERC165 public controller;
34
+
35
+ function setTerminal(address terminal_) external {
36
+ terminal = terminal_;
37
+ }
38
+
39
+ function setController(IERC165 controller_) external {
40
+ controller = controller_;
41
+ }
42
+
43
+ function isTerminalOf(uint256, IJBTerminal terminal_) external view returns (bool) {
44
+ return address(terminal_) == terminal;
45
+ }
46
+
47
+ function controllerOf(uint256) external view returns (IERC165) {
48
+ return controller;
49
+ }
50
+ }
51
+
52
+ contract CodexNemesisVotes {
53
+ mapping(address account => uint256 votes) public votesOf;
54
+ uint256 public totalSupply;
55
+
56
+ function setVotes(address account, uint256 votes) external {
57
+ votesOf[account] = votes;
58
+ }
59
+
60
+ function setTotalSupply(uint256 totalSupply_) external {
61
+ totalSupply = totalSupply_;
62
+ }
63
+
64
+ function getPastVotes(address account, uint256) external view returns (uint256) {
65
+ return votesOf[account];
66
+ }
67
+
68
+ function getPastTotalSupply(uint256) external view returns (uint256) {
69
+ return totalSupply;
70
+ }
71
+ }
72
+
73
+ contract CodexNemesis721Store {
74
+ uint104 public votingUnits = 100;
75
+
76
+ function tierOfTokenId(address, uint256, bool) external view returns (JB721Tier memory tier) {
77
+ tier.votingUnits = votingUnits;
78
+ tier.initialSupply = 100;
79
+ }
80
+
81
+ /// @dev Returns 0 for all tokens (backward-compatible: allows vesting).
82
+ function mintBlockOf(address, uint256) external pure returns (uint256) {
83
+ return 0;
84
+ }
85
+ }
86
+
87
+ contract CodexNemesis721Checkpoints {
88
+ mapping(address account => uint256 votes) public votesOf;
89
+ uint256 public totalSupply;
90
+
91
+ function setVotes(address account, uint256 votes) external {
92
+ votesOf[account] = votes;
93
+ }
94
+
95
+ function setTotalSupply(uint256 totalSupply_) external {
96
+ totalSupply = totalSupply_;
97
+ }
98
+
99
+ function getPastVotes(address account, uint256) external view returns (uint256) {
100
+ return votesOf[account];
101
+ }
102
+
103
+ function getPastTotalSupply(uint256) external view returns (uint256) {
104
+ return totalSupply;
105
+ }
106
+ }
107
+
108
+ contract CodexNemesis721Hook {
109
+ CodexNemesis721Store public immutable STORE;
110
+ CodexNemesis721Checkpoints public immutable CHECKPOINTS;
111
+ mapping(uint256 tokenId => address owner) public ownerOf;
112
+
113
+ constructor(CodexNemesis721Store store, CodexNemesis721Checkpoints checkpoints) {
114
+ STORE = store;
115
+ CHECKPOINTS = checkpoints;
116
+ }
117
+
118
+ function mint(address owner, uint256 tokenId) external {
119
+ ownerOf[tokenId] = owner;
120
+ }
121
+
122
+ function burn(uint256 tokenId) external {
123
+ delete ownerOf[tokenId];
124
+ }
125
+ }
126
+
127
+ contract CodexNemesisFreshVerificationTest is Test {
128
+ uint256 internal constant PROJECT_ID = 1;
129
+ uint256 internal constant ROUND_DURATION = 1 days;
130
+ uint256 internal constant VESTING_ROUNDS = 1;
131
+
132
+ /// @notice Previously this test proved the attack worked. Now it proves the fix: sending ETH
133
+ /// with context.token set to an ERC-20 address reverts with TokenMismatch.
134
+ function test_nativeValueCanCreateUnbackedErc20CreditAndDrainOtherHookInventory() public {
135
+ address attacker = makeAddr("attacker");
136
+ address victimHook = makeAddr("victimHook");
137
+ uint256 rewardAmount = 100_000_000; // 100 units of a 6-decimal token.
138
+
139
+ CodexNemesisDirectory directory = new CodexNemesisDirectory();
140
+ directory.setTerminal(address(this));
141
+
142
+ JBTokenDistributor distributor =
143
+ new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
144
+ CodexNemesisToken reward = new CodexNemesisToken();
145
+ CodexNemesisVotes stake = new CodexNemesisVotes();
146
+ stake.setVotes(attacker, 1 ether);
147
+ stake.setTotalSupply(1 ether);
148
+
149
+ reward.mint(address(this), rewardAmount);
150
+ reward.approve(address(distributor), rewardAmount);
151
+ distributor.fund(victimHook, IERC20(address(reward)), rewardAmount);
152
+
153
+ JBSplit memory split = JBSplit({
154
+ percent: 0,
155
+ projectId: 0,
156
+ beneficiary: payable(address(stake)),
157
+ preferAddToBalance: false,
158
+ lockedUntil: 0,
159
+ hook: IJBSplitHook(address(0))
160
+ });
161
+ JBSplitHookContext memory context = JBSplitHookContext({
162
+ token: address(reward),
163
+ amount: rewardAmount,
164
+ decimals: 6,
165
+ projectId: PROJECT_ID,
166
+ groupId: uint256(uint160(address(reward))),
167
+ split: split
168
+ });
169
+
170
+ // FIX VERIFIED: The attack now reverts because context.token != NATIVE_TOKEN when msg.value != 0.
171
+ vm.deal(address(this), rewardAmount);
172
+ vm.expectRevert(JBTokenDistributor.JBTokenDistributor_TokenMismatch.selector);
173
+ distributor.processSplitWith{value: rewardAmount}(context);
174
+
175
+ // Victim's balance remains intact — attack blocked.
176
+ assertEq(distributor.balanceOf(victimHook, IERC20(address(reward))), rewardAmount);
177
+ assertEq(reward.balanceOf(address(distributor)), rewardAmount);
178
+ }
179
+
180
+ function test_721LateMintedTokenCanClaimRoundSnapshotRewardsFromOwnersPastVotes() public {
181
+ address alice = makeAddr("alice");
182
+
183
+ CodexNemesisDirectory directory = new CodexNemesisDirectory();
184
+ JB721Distributor distributor =
185
+ new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
186
+ CodexNemesisToken reward = new CodexNemesisToken();
187
+ CodexNemesis721Store store = new CodexNemesis721Store();
188
+ CodexNemesis721Checkpoints checkpoints = new CodexNemesis721Checkpoints();
189
+ CodexNemesis721Hook hook = new CodexNemesis721Hook(store, checkpoints);
190
+
191
+ reward.mint(address(this), 100 ether);
192
+ reward.approve(address(distributor), 100 ether);
193
+ distributor.fund(address(hook), IERC20(address(reward)), 100 ether);
194
+
195
+ hook.mint(alice, 1);
196
+ checkpoints.setVotes(alice, 100);
197
+ checkpoints.setTotalSupply(100);
198
+ distributor.poke();
199
+
200
+ hook.burn(1);
201
+ hook.mint(alice, 2);
202
+
203
+ uint256[] memory tokenIds = new uint256[](1);
204
+ tokenIds[0] = 2;
205
+ IERC20[] memory tokens = new IERC20[](1);
206
+ tokens[0] = IERC20(address(reward));
207
+
208
+ distributor.beginVesting(address(hook), tokenIds, tokens);
209
+
210
+ assertEq(distributor.claimedFor(address(hook), 2, IERC20(address(reward))), 100 ether);
211
+
212
+ vm.warp(block.timestamp + ROUND_DURATION);
213
+ vm.prank(alice);
214
+ distributor.collectVestedRewards(address(hook), tokenIds, tokens, alice);
215
+
216
+ assertEq(reward.balanceOf(alice), 100 ether);
217
+ }
218
+ }
@@ -88,6 +88,11 @@ contract H26MockStore {
88
88
  function numberOfBurnedFor(address, uint256 tierId) external view returns (uint256) {
89
89
  return burned[tierId];
90
90
  }
91
+
92
+ /// @dev Returns 0 for all tokens (backward-compatible: allows vesting).
93
+ function mintBlockOf(address, uint256) external pure returns (uint256) {
94
+ return 0;
95
+ }
91
96
  }
92
97
 
93
98
  /// @notice Mock checkpoints with explicit per-address vote overrides for H-26 testing.
@@ -0,0 +1,413 @@
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 {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
13
+ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
14
+
15
+ import {JB721Distributor} from "../../src/JB721Distributor.sol";
16
+ import {JBDistributor} from "../../src/JBDistributor.sol";
17
+
18
+ // --- Mocks ---------------------------------------------------------------
19
+
20
+ contract VPCapMockDirectory {
21
+ mapping(uint256 => mapping(address => bool)) public terminals;
22
+
23
+ function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
24
+ terminals[projectId][terminal] = isTerminal;
25
+ }
26
+
27
+ function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
28
+ return terminals[projectId][address(terminal)];
29
+ }
30
+
31
+ function controllerOf(uint256) external pure returns (IERC165) {
32
+ return IERC165(address(0));
33
+ }
34
+ }
35
+
36
+ contract VPCapMockToken is ERC20 {
37
+ constructor() ERC20("Reward", "RWD") {}
38
+
39
+ function mint(address to, uint256 amount) external {
40
+ _mint(to, amount);
41
+ }
42
+ }
43
+
44
+ contract VPCapMockStore {
45
+ uint256 public maxTier;
46
+ mapping(uint256 => JB721Tier) public tiers;
47
+ mapping(uint256 => uint256) public burned;
48
+ mapping(uint256 => uint256) public tokenTiers;
49
+
50
+ function setMaxTierIdOf(uint256 v) external {
51
+ maxTier = v;
52
+ }
53
+
54
+ function maxTierIdOf(address) external view returns (uint256) {
55
+ return maxTier;
56
+ }
57
+
58
+ function setTier(uint256 tierId, JB721Tier memory tier) external {
59
+ tiers[tierId] = tier;
60
+ }
61
+
62
+ function tierOf(address, uint256 id, bool) external view returns (JB721Tier memory) {
63
+ return tiers[id];
64
+ }
65
+
66
+ function setTokenTier(uint256 tokenId, uint256 tierId) external {
67
+ tokenTiers[tokenId] = tierId;
68
+ }
69
+
70
+ function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
71
+ return tiers[tokenTiers[tokenId]];
72
+ }
73
+
74
+ function setBurnedFor(uint256 tierId, uint256 count) external {
75
+ burned[tierId] = count;
76
+ }
77
+
78
+ function numberOfBurnedFor(address, uint256 tierId) external view returns (uint256) {
79
+ return burned[tierId];
80
+ }
81
+ }
82
+
83
+ contract VPCapMockCheckpoints {
84
+ VPCapMockStore public store;
85
+ address public hookAddr;
86
+
87
+ uint256 public totalSupplyOverride;
88
+
89
+ mapping(address => uint256) public votesOverride;
90
+ mapping(address => bool) public votesOverrideSet;
91
+
92
+ constructor(VPCapMockStore _store, address _hook) {
93
+ store = _store;
94
+ hookAddr = _hook;
95
+ }
96
+
97
+ function setTotalSupplyOverride(uint256 value) external {
98
+ totalSupplyOverride = value;
99
+ }
100
+
101
+ function setVotesOverride(address account, uint256 value) external {
102
+ votesOverride[account] = value;
103
+ votesOverrideSet[account] = true;
104
+ }
105
+
106
+ function getPastTotalSupply(uint256) external view returns (uint256 total) {
107
+ if (totalSupplyOverride != 0) return totalSupplyOverride;
108
+ uint256 max = store.maxTier();
109
+ for (uint256 i = 1; i <= max; i++) {
110
+ JB721Tier memory tier = store.tierOf(hookAddr, i, false);
111
+ if (tier.id == 0 || tier.initialSupply == 0) continue;
112
+ uint256 b = store.burned(i);
113
+ uint256 held = tier.initialSupply - tier.remainingSupply - b;
114
+ total += held * tier.votingUnits;
115
+ }
116
+ }
117
+
118
+ function getPastVotes(address account, uint256) external view returns (uint256) {
119
+ if (votesOverrideSet[account]) return votesOverride[account];
120
+ return 0; // Default: no historical votes (realistic behavior).
121
+ }
122
+ }
123
+
124
+ contract VPCapMockHook {
125
+ VPCapMockStore public immutable _store;
126
+ VPCapMockCheckpoints public _checkpoints;
127
+ mapping(uint256 => address) public owners;
128
+
129
+ constructor(VPCapMockStore s) {
130
+ _store = s;
131
+ _checkpoints = new VPCapMockCheckpoints(s, address(this));
132
+ }
133
+
134
+ // solhint-disable-next-line func-name-mixedcase
135
+ function STORE() external view returns (VPCapMockStore) {
136
+ return _store;
137
+ }
138
+
139
+ // solhint-disable-next-line func-name-mixedcase
140
+ function CHECKPOINTS() external view returns (VPCapMockCheckpoints) {
141
+ return _checkpoints;
142
+ }
143
+
144
+ function ownerOf(uint256 tokenId) external view returns (address) {
145
+ address o = owners[tokenId];
146
+ require(o != address(0), "ERC721: invalid token ID");
147
+ return o;
148
+ }
149
+
150
+ function setOwner(uint256 tokenId, address owner) external {
151
+ owners[tokenId] = owner;
152
+ }
153
+ }
154
+
155
+ // --- Tests ---------------------------------------------------------------
156
+
157
+ /// @title VotingPowerCapSufficiencyTest
158
+ /// @notice Proves that the `_consumedVotesOf` tracking against `getPastVotes` is sufficient
159
+ /// to prevent post-snapshot minted NFTs from extracting excess rewards — no `mintBlockOf`
160
+ /// storage on the 721 hook is needed.
161
+ ///
162
+ /// Key invariant: an owner's total vested rewards are bounded by their historical voting
163
+ /// power at the snapshot block, regardless of which specific tokens they vest.
164
+ contract VotingPowerCapSufficiencyTest is Test {
165
+ JB721Distributor distributor;
166
+ VPCapMockToken rewardToken;
167
+ VPCapMockHook hook;
168
+ VPCapMockStore store;
169
+ VPCapMockDirectory directory;
170
+
171
+ address alice = makeAddr("alice");
172
+ address bob = makeAddr("bob");
173
+ address charlie = makeAddr("charlie");
174
+
175
+ uint256 constant ROUND_DURATION = 100;
176
+ uint256 constant VESTING_ROUNDS = 4;
177
+
178
+ function setUp() public {
179
+ store = new VPCapMockStore();
180
+ hook = new VPCapMockHook(store);
181
+ directory = new VPCapMockDirectory();
182
+ distributor = new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
183
+
184
+ directory.setTerminal(1, address(this), true);
185
+ rewardToken = new VPCapMockToken();
186
+
187
+ JB721TierFlags memory flags;
188
+ store.setMaxTierIdOf(1);
189
+
190
+ // Tier 1: votingUnits=100, 2 minted (initialSupply=10, remainingSupply=8).
191
+ store.setTier(
192
+ 1,
193
+ JB721Tier({
194
+ id: 1,
195
+ price: 1 ether,
196
+ remainingSupply: 8,
197
+ initialSupply: 10,
198
+ votingUnits: 100,
199
+ reserveFrequency: 0,
200
+ reserveBeneficiary: address(0),
201
+ encodedIPFSUri: bytes32(0),
202
+ category: 0,
203
+ discountPercent: 0,
204
+ flags: flags,
205
+ splitPercent: 0,
206
+ resolvedUri: ""
207
+ })
208
+ );
209
+
210
+ // Token 1 -> alice, Token 2 -> bob (both pre-snapshot).
211
+ store.setTokenTier(1, 1);
212
+ hook.setOwner(1, alice);
213
+ store.setTokenTier(2, 1);
214
+ hook.setOwner(2, bob);
215
+
216
+ // Set realistic historical voting power: each holder had 100 at snapshot.
217
+ hook._checkpoints().setVotesOverride(alice, 100);
218
+ hook._checkpoints().setVotesOverride(bob, 100);
219
+ // Charlie has 0 voting power at snapshot (default).
220
+
221
+ // Fix total supply at 200 so post-snapshot mints don't inflate denominator.
222
+ hook._checkpoints().setTotalSupplyOverride(200);
223
+ }
224
+
225
+ function _advanceToRound(uint256 round) internal {
226
+ uint256 target = distributor.roundStartTimestamp(round) + 1;
227
+ if (block.timestamp < target) vm.warp(target);
228
+ vm.roll(block.number + 1);
229
+ }
230
+
231
+ function _fundHook(uint256 amount) internal {
232
+ rewardToken.mint(address(this), amount);
233
+ rewardToken.approve(address(distributor), amount);
234
+ distributor.fund(address(hook), IERC20(address(rewardToken)), amount);
235
+ }
236
+
237
+ /// @notice Post-snapshot mint cannot extract more than the owner's historical voting power.
238
+ /// Alice has 100 votes at snapshot. She mints token 3 after snapshot and vests both.
239
+ /// Total extraction: 500 ether (capped at 100/200 of pool), NOT 1000 ether.
240
+ function test_votingPowerCap_preventsOverExtraction() public {
241
+ _fundHook(1000 ether);
242
+ _advanceToRound(1);
243
+ distributor.poke();
244
+
245
+ // AFTER snapshot: Alice mints token 3.
246
+ vm.roll(block.number + 5);
247
+ store.setTokenTier(3, 1);
248
+ hook.setOwner(3, alice);
249
+
250
+ // Alice vests both tokens 1 (pre-snapshot) and 3 (post-snapshot).
251
+ uint256[] memory tokenIds = new uint256[](2);
252
+ tokenIds[0] = 1;
253
+ tokenIds[1] = 3;
254
+ IERC20[] memory tokens = new IERC20[](1);
255
+ tokens[0] = IERC20(address(rewardToken));
256
+
257
+ distributor.beginVesting(address(hook), tokenIds, tokens);
258
+
259
+ // Token 1 consumed all 100 votes. Token 3 gets 0 (budget exhausted).
260
+ uint256 token1Claimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
261
+ uint256 token3Claimed = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
262
+
263
+ assertEq(token1Claimed, 500 ether, "Token 1 gets full share (100/200)");
264
+ assertEq(token3Claimed, 0, "Token 3 gets 0 (voting power budget exhausted)");
265
+ }
266
+
267
+ /// @notice Vesting only a post-snapshot token still capped by historical votes.
268
+ /// Alice skips token 1, vests only token 3 (post-snapshot). Gets 500 ether through it.
269
+ /// Then token 1 gets 0 because the budget is spent. Total: still 500.
270
+ function test_votingPowerCap_postSnapshotOnlyToken_sameTotal() public {
271
+ _fundHook(1000 ether);
272
+ _advanceToRound(1);
273
+ distributor.poke();
274
+
275
+ // AFTER snapshot: Alice mints token 3.
276
+ vm.roll(block.number + 5);
277
+ store.setTokenTier(3, 1);
278
+ hook.setOwner(3, alice);
279
+
280
+ // Alice vests ONLY token 3 (post-snapshot).
281
+ uint256[] memory tokenIds = new uint256[](1);
282
+ tokenIds[0] = 3;
283
+ IERC20[] memory tokens = new IERC20[](1);
284
+ tokens[0] = IERC20(address(rewardToken));
285
+
286
+ distributor.beginVesting(address(hook), tokenIds, tokens);
287
+
288
+ uint256 token3Claimed = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
289
+ assertEq(token3Claimed, 500 ether, "Token 3 vests using Alice's historical 100 votes");
290
+
291
+ // Now vest token 1. Alice's budget is already consumed.
292
+ tokenIds[0] = 1;
293
+ distributor.beginVesting(address(hook), tokenIds, tokens);
294
+
295
+ uint256 token1Claimed = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
296
+ assertEq(token1Claimed, 0, "Token 1 gets 0 (budget spent on token 3)");
297
+
298
+ // Total: 500 ether — exactly what Alice is entitled to.
299
+ assertEq(token3Claimed + token1Claimed, 500 ether, "Total extraction bounded by historical votes");
300
+ }
301
+
302
+ /// @notice No historical voting power → zero rewards, even with a valid NFT.
303
+ function test_votingPowerCap_noHistoricalVotes_zeroRewards() public {
304
+ _fundHook(1000 ether);
305
+ _advanceToRound(1);
306
+ distributor.poke();
307
+
308
+ // AFTER snapshot: Charlie (0 votes at snapshot) mints token 3.
309
+ vm.roll(block.number + 5);
310
+ store.setTokenTier(3, 1);
311
+ hook.setOwner(3, charlie);
312
+
313
+ uint256[] memory tokenIds = new uint256[](1);
314
+ tokenIds[0] = 3;
315
+ IERC20[] memory tokens = new IERC20[](1);
316
+ tokens[0] = IERC20(address(rewardToken));
317
+
318
+ distributor.beginVesting(address(hook), tokenIds, tokens);
319
+
320
+ uint256 claimed = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
321
+ assertEq(claimed, 0, "No historical votes = no rewards");
322
+ }
323
+
324
+ /// @notice Multiple post-snapshot tokens still bounded by historical voting power.
325
+ /// Alice mints 3 new tokens after snapshot. Total extraction: still 500 ether.
326
+ function test_votingPowerCap_multiplePostSnapshotTokens_bounded() public {
327
+ _fundHook(1000 ether);
328
+ _advanceToRound(1);
329
+ distributor.poke();
330
+
331
+ // AFTER snapshot: Alice mints tokens 3, 4, 5.
332
+ vm.roll(block.number + 5);
333
+ for (uint256 i = 3; i <= 5; i++) {
334
+ store.setTokenTier(i, 1);
335
+ hook.setOwner(i, alice);
336
+ }
337
+
338
+ // Alice vests all her tokens (1 pre-snapshot + 3 post-snapshot).
339
+ uint256[] memory tokenIds = new uint256[](4);
340
+ tokenIds[0] = 1;
341
+ tokenIds[1] = 3;
342
+ tokenIds[2] = 4;
343
+ tokenIds[3] = 5;
344
+ IERC20[] memory tokens = new IERC20[](1);
345
+ tokens[0] = IERC20(address(rewardToken));
346
+
347
+ distributor.beginVesting(address(hook), tokenIds, tokens);
348
+
349
+ uint256 total;
350
+ for (uint256 i; i < tokenIds.length; i++) {
351
+ total += distributor.claimedFor(address(hook), tokenIds[i], IERC20(address(rewardToken)));
352
+ }
353
+
354
+ assertEq(total, 500 ether, "4 tokens but still capped at 100/200 of pool");
355
+ }
356
+
357
+ /// @notice Burn-and-remint: Alice burns pre-snapshot token, mints replacement after.
358
+ /// Total extraction: still 500 ether (same as if she kept the original).
359
+ function test_votingPowerCap_burnAndRemint_bounded() public {
360
+ _fundHook(1000 ether);
361
+ _advanceToRound(1);
362
+ distributor.poke();
363
+
364
+ // Simulate burn of token 1 (ownerOf reverts for burned tokens).
365
+ hook.setOwner(1, address(0));
366
+
367
+ // AFTER snapshot: Alice mints token 3 as replacement.
368
+ vm.roll(block.number + 5);
369
+ store.setTokenTier(3, 1);
370
+ hook.setOwner(3, alice);
371
+
372
+ // Vest token 3 only (token 1 is burned).
373
+ uint256[] memory tokenIds = new uint256[](1);
374
+ tokenIds[0] = 3;
375
+ IERC20[] memory tokens = new IERC20[](1);
376
+ tokens[0] = IERC20(address(rewardToken));
377
+
378
+ distributor.beginVesting(address(hook), tokenIds, tokens);
379
+
380
+ uint256 claimed = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
381
+ assertEq(claimed, 500 ether, "Replacement token capped at Alice's historical 100 votes");
382
+ }
383
+
384
+ /// @notice Cross-owner isolation: Alice's post-snapshot mint doesn't affect Bob's rewards.
385
+ function test_votingPowerCap_crossOwnerIsolation() public {
386
+ _fundHook(1000 ether);
387
+ _advanceToRound(1);
388
+ distributor.poke();
389
+
390
+ // AFTER snapshot: Alice mints token 3.
391
+ vm.roll(block.number + 5);
392
+ store.setTokenTier(3, 1);
393
+ hook.setOwner(3, alice);
394
+
395
+ // Vest all three tokens.
396
+ uint256[] memory tokenIds = new uint256[](3);
397
+ tokenIds[0] = 1; // alice
398
+ tokenIds[1] = 2; // bob
399
+ tokenIds[2] = 3; // alice (post-snapshot)
400
+ IERC20[] memory tokens = new IERC20[](1);
401
+ tokens[0] = IERC20(address(rewardToken));
402
+
403
+ distributor.beginVesting(address(hook), tokenIds, tokens);
404
+
405
+ uint256 aliceTotal = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)))
406
+ + distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
407
+ uint256 bobTotal = distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken)));
408
+
409
+ assertEq(aliceTotal, 500 ether, "Alice gets exactly her 100/200 share");
410
+ assertEq(bobTotal, 500 ether, "Bob gets exactly his 100/200 share");
411
+ assertEq(aliceTotal + bobTotal, 1000 ether, "Full pool distributed, no over-extraction");
412
+ }
413
+ }
@@ -0,0 +1,295 @@
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 {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
18
+
19
+ import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
20
+ import {JB721Distributor} from "../../src/JB721Distributor.sol";
21
+ import {JBDistributor} from "../../src/JBDistributor.sol";
22
+
23
+ // =========================================================================
24
+ // Mock contracts
25
+ // =========================================================================
26
+
27
+ /// @notice Mock JB directory for token mismatch tests.
28
+ contract TMMockDirectory {
29
+ mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
30
+ mapping(uint256 projectId => address controller) public controllers;
31
+
32
+ function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
33
+ terminals[projectId][terminal] = isTerminal;
34
+ }
35
+
36
+ function setController(uint256 projectId, address controller) external {
37
+ controllers[projectId] = controller;
38
+ }
39
+
40
+ function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
41
+ return terminals[projectId][address(terminal)];
42
+ }
43
+
44
+ function controllerOf(uint256 projectId) external view returns (IERC165) {
45
+ return IERC165(controllers[projectId]);
46
+ }
47
+ }
48
+
49
+ /// @notice Simple ERC20 token representing a victim's deposited ERC-20.
50
+ contract TMVictimToken is ERC20 {
51
+ constructor() ERC20("VictimToken", "VICTIM") {}
52
+
53
+ function mint(address to, uint256 amount) external {
54
+ _mint(to, amount);
55
+ }
56
+ }
57
+
58
+ /// @notice ERC20Votes token for staking.
59
+ contract TMVotesToken is ERC20, ERC20Votes {
60
+ constructor() ERC20("StakeToken", "STK") EIP712("StakeToken", "1") {}
61
+
62
+ function mint(address to, uint256 amount) external {
63
+ _mint(to, amount);
64
+ }
65
+
66
+ function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
67
+ super._update(from, to, value);
68
+ }
69
+ }
70
+
71
+ // =========================================================================
72
+ // Test: JBTokenDistributor token mismatch vulnerability
73
+ // =========================================================================
74
+
75
+ /// @notice Proves the ETH-to-ERC20 cross-booking vulnerability is fixed in JBTokenDistributor.
76
+ /// @dev The attack: a malicious terminal sends ETH (msg.value != 0) but sets context.token to an
77
+ /// arbitrary ERC-20 address. Without the fix, the ETH amount would be credited under that ERC-20's
78
+ /// balance mapping, effectively stealing another hook's ERC-20 balance.
79
+ contract TokenMismatchTokenDistributorTest is Test {
80
+ TMMockDirectory directory;
81
+ TMVictimToken victimToken;
82
+ TMVotesToken votesToken;
83
+ JBTokenDistributor distributor;
84
+
85
+ address attacker = makeAddr("attacker");
86
+ address victim = makeAddr("victim");
87
+ address terminal = makeAddr("terminal");
88
+ address hook;
89
+ uint256 projectId = 1;
90
+
91
+ uint256 constant ROUND_DURATION = 100;
92
+ uint256 constant VESTING_ROUNDS = 4;
93
+
94
+ function setUp() public {
95
+ directory = new TMMockDirectory();
96
+ victimToken = new TMVictimToken();
97
+ votesToken = new TMVotesToken();
98
+
99
+ directory.setTerminal(projectId, terminal, true);
100
+
101
+ distributor = new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
102
+
103
+ hook = address(votesToken);
104
+ }
105
+
106
+ /// @notice Helper to build a JBSplitHookContext.
107
+ function _buildContext(address token, uint256 amount) internal view returns (JBSplitHookContext memory) {
108
+ JBSplit memory split = JBSplit({
109
+ percent: 1_000_000_000,
110
+ projectId: 0,
111
+ beneficiary: payable(hook),
112
+ preferAddToBalance: false,
113
+ lockedUntil: 0,
114
+ hook: IJBSplitHook(address(distributor))
115
+ });
116
+
117
+ return JBSplitHookContext({
118
+ token: token, amount: amount, decimals: 18, projectId: projectId, groupId: 0, split: split
119
+ });
120
+ }
121
+
122
+ /// @notice Proves the fix: sending ETH with context.token set to an ERC-20 address reverts.
123
+ function test_tokenMismatch_ethWithErc20Token_reverts() public {
124
+ // Attacker constructs a context claiming the token is victimToken (an ERC-20),
125
+ // but sends ETH as msg.value.
126
+ JBSplitHookContext memory context = _buildContext(address(victimToken), 1 ether);
127
+
128
+ // Fund the terminal with ETH for the attack.
129
+ vm.deal(terminal, 10 ether);
130
+
131
+ // The attack should now revert with TokenMismatch.
132
+ vm.prank(terminal);
133
+ vm.expectRevert(JBTokenDistributor.JBTokenDistributor_TokenMismatch.selector);
134
+ distributor.processSplitWith{value: 1 ether}(context);
135
+ }
136
+
137
+ /// @notice Proves that legitimate native ETH splits (context.token == NATIVE_TOKEN) still work.
138
+ function test_tokenMismatch_ethWithNativeToken_succeeds() public {
139
+ JBSplitHookContext memory context = _buildContext(JBConstants.NATIVE_TOKEN, 1 ether);
140
+
141
+ vm.deal(terminal, 10 ether);
142
+
143
+ vm.prank(terminal);
144
+ distributor.processSplitWith{value: 1 ether}(context);
145
+
146
+ // Balance should be credited under NATIVE_TOKEN.
147
+ assertEq(
148
+ distributor.balanceOf(hook, IERC20(JBConstants.NATIVE_TOKEN)),
149
+ 1 ether,
150
+ "Native ETH split should credit balance under NATIVE_TOKEN"
151
+ );
152
+ }
153
+
154
+ /// @notice Demonstrates the attack scenario that the fix prevents: without the fix,
155
+ /// an attacker could steal the victim's ERC-20 balance by sending ETH with a fake token.
156
+ function test_tokenMismatch_attackScenario_cannotStealErc20Balance() public {
157
+ // Step 1: Victim legitimately deposits ERC-20 tokens via the terminal.
158
+ uint256 victimAmount = 100 ether;
159
+ victimToken.mint(terminal, victimAmount);
160
+
161
+ JBSplitHookContext memory legitimateContext = _buildContext(address(victimToken), victimAmount);
162
+
163
+ vm.startPrank(terminal);
164
+ victimToken.approve(address(distributor), victimAmount);
165
+ distributor.processSplitWith(legitimateContext);
166
+ vm.stopPrank();
167
+
168
+ // Verify victim's ERC-20 balance is properly recorded.
169
+ assertEq(
170
+ distributor.balanceOf(hook, IERC20(address(victimToken))),
171
+ victimAmount,
172
+ "Victim's ERC-20 balance should be credited"
173
+ );
174
+
175
+ // Step 2: Attacker tries to send 1 ETH but have it credited as victimToken.
176
+ // This would allow the attacker to inflate the victimToken balance and steal from others.
177
+ JBSplitHookContext memory attackContext = _buildContext(address(victimToken), 1 ether);
178
+
179
+ vm.deal(terminal, 10 ether);
180
+ vm.prank(terminal);
181
+ vm.expectRevert(JBTokenDistributor.JBTokenDistributor_TokenMismatch.selector);
182
+ distributor.processSplitWith{value: 1 ether}(attackContext);
183
+
184
+ // Balance remains unchanged — attack blocked.
185
+ assertEq(
186
+ distributor.balanceOf(hook, IERC20(address(victimToken))),
187
+ victimAmount,
188
+ "Victim's ERC-20 balance must not change after blocked attack"
189
+ );
190
+ }
191
+ }
192
+
193
+ // =========================================================================
194
+ // Test: JB721Distributor token mismatch vulnerability
195
+ // =========================================================================
196
+
197
+ /// @notice Proves the ETH-to-ERC20 cross-booking vulnerability is fixed in JB721Distributor.
198
+ contract TokenMismatch721DistributorTest is Test {
199
+ TMMockDirectory directory;
200
+ TMVictimToken victimToken;
201
+ JB721Distributor distributor;
202
+
203
+ address terminal = makeAddr("terminal");
204
+ address hook = makeAddr("nft-hook");
205
+ uint256 projectId = 1;
206
+
207
+ uint256 constant ROUND_DURATION = 100;
208
+ uint256 constant VESTING_ROUNDS = 4;
209
+
210
+ function setUp() public {
211
+ directory = new TMMockDirectory();
212
+ victimToken = new TMVictimToken();
213
+
214
+ directory.setTerminal(projectId, terminal, true);
215
+
216
+ distributor = new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
217
+ }
218
+
219
+ /// @notice Helper to build a JBSplitHookContext.
220
+ function _buildContext(address token, uint256 amount) internal view returns (JBSplitHookContext memory) {
221
+ JBSplit memory split = JBSplit({
222
+ percent: 1_000_000_000,
223
+ projectId: 0,
224
+ beneficiary: payable(hook),
225
+ preferAddToBalance: false,
226
+ lockedUntil: 0,
227
+ hook: IJBSplitHook(address(distributor))
228
+ });
229
+
230
+ return JBSplitHookContext({
231
+ token: token, amount: amount, decimals: 18, projectId: projectId, groupId: 0, split: split
232
+ });
233
+ }
234
+
235
+ /// @notice Proves the fix: sending ETH with context.token set to an ERC-20 address reverts.
236
+ function test_tokenMismatch_721_ethWithErc20Token_reverts() public {
237
+ JBSplitHookContext memory context = _buildContext(address(victimToken), 1 ether);
238
+
239
+ vm.deal(terminal, 10 ether);
240
+
241
+ vm.prank(terminal);
242
+ vm.expectRevert(JB721Distributor.JB721Distributor_TokenMismatch.selector);
243
+ distributor.processSplitWith{value: 1 ether}(context);
244
+ }
245
+
246
+ /// @notice Proves that legitimate native ETH splits still work for JB721Distributor.
247
+ function test_tokenMismatch_721_ethWithNativeToken_succeeds() public {
248
+ JBSplitHookContext memory context = _buildContext(JBConstants.NATIVE_TOKEN, 1 ether);
249
+
250
+ vm.deal(terminal, 10 ether);
251
+
252
+ vm.prank(terminal);
253
+ distributor.processSplitWith{value: 1 ether}(context);
254
+
255
+ // Balance should be credited under NATIVE_TOKEN.
256
+ assertEq(
257
+ distributor.balanceOf(hook, IERC20(JBConstants.NATIVE_TOKEN)),
258
+ 1 ether,
259
+ "Native ETH split should credit balance under NATIVE_TOKEN"
260
+ );
261
+ }
262
+
263
+ /// @notice Full attack scenario blocked on JB721Distributor.
264
+ function test_tokenMismatch_721_attackScenario_cannotStealErc20Balance() public {
265
+ // Step 1: Legitimate ERC-20 deposit.
266
+ uint256 victimAmount = 50 ether;
267
+ victimToken.mint(terminal, victimAmount);
268
+
269
+ JBSplitHookContext memory legitimateContext = _buildContext(address(victimToken), victimAmount);
270
+
271
+ vm.startPrank(terminal);
272
+ victimToken.approve(address(distributor), victimAmount);
273
+ distributor.processSplitWith(legitimateContext);
274
+ vm.stopPrank();
275
+
276
+ assertEq(
277
+ distributor.balanceOf(hook, IERC20(address(victimToken))), victimAmount, "Victim balance should be credited"
278
+ );
279
+
280
+ // Step 2: Attack — send ETH but claim it as victimToken.
281
+ JBSplitHookContext memory attackContext = _buildContext(address(victimToken), 1 ether);
282
+
283
+ vm.deal(terminal, 10 ether);
284
+ vm.prank(terminal);
285
+ vm.expectRevert(JB721Distributor.JB721Distributor_TokenMismatch.selector);
286
+ distributor.processSplitWith{value: 1 ether}(attackContext);
287
+
288
+ // Balance unaffected.
289
+ assertEq(
290
+ distributor.balanceOf(hook, IERC20(address(victimToken))),
291
+ victimAmount,
292
+ "Balance must remain unchanged after blocked attack"
293
+ );
294
+ }
295
+ }
@@ -87,7 +87,7 @@ contract TokenDistributorForkTest is Test {
87
87
  IPermit2 constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
88
88
 
89
89
  function setUp() public {
90
- vm.createSelectFork("ethereum");
90
+ vm.createSelectFork("ethereum", 24_981_600);
91
91
 
92
92
  // Create labeled addresses and ensure they're clean EOAs (no mainnet code).
93
93
  multisig = makeAddr("test_distributor_multisig");
@@ -90,6 +90,11 @@ contract InvariantMockStore {
90
90
  function numberOfBurnedFor(address, uint256 tierId) external view returns (uint256) {
91
91
  return burned[tierId];
92
92
  }
93
+
94
+ /// @dev Returns 0 for all tokens (backward-compatible: allows vesting).
95
+ function mintBlockOf(address, uint256) external pure returns (uint256) {
96
+ return 0;
97
+ }
93
98
  }
94
99
 
95
100
  /// @notice Mock JB directory for invariant testing.