@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 +1 -1
- package/src/JB721Distributor.sol +42 -11
- package/src/JBDistributor.sol +17 -0
- package/src/JBTokenDistributor.sol +15 -4
- package/test/JB721Distributor.t.sol +5 -0
- package/test/audit/CodexNemesisAccountingPoC.t.sol +22 -25
- package/test/audit/CodexNemesisFreshSplitTokenMismatch.t.sol +133 -0
- package/test/audit/CodexNemesisFreshVerification.t.sol +218 -0
- package/test/audit/CodexNemesisPoC.t.sol +191 -0
- package/test/audit/H26VotingPowerCap.t.sol +5 -0
- package/test/audit/Pass12Fixes.t.sol +344 -0
- package/test/audit/PostSnapshotMintTheft.t.sol +413 -0
- package/test/audit/TokenMismatchFix.t.sol +295 -0
- package/test/fork/TokenDistributorFork.t.sol +1 -1
- package/test/invariant/JB721DistributorInvariant.t.sol +5 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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.
|