@bananapus/distributor-v6 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
package/src/JB721Distributor.sol
CHANGED
|
@@ -55,6 +55,19 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
55
55
|
/// @notice The JB directory used to verify terminal/controller callers.
|
|
56
56
|
IJBDirectory public immutable DIRECTORY;
|
|
57
57
|
|
|
58
|
+
//*********************************************************************//
|
|
59
|
+
// -------------------- internal stored properties ------------------- //
|
|
60
|
+
//*********************************************************************//
|
|
61
|
+
|
|
62
|
+
/// @notice Tracks voting power consumed per hook/token/round/owner to prevent cap resets across calls.
|
|
63
|
+
/// @custom:param hook The hook address.
|
|
64
|
+
/// @custom:param token The reward token.
|
|
65
|
+
/// @custom:param releaseRound The vesting release round.
|
|
66
|
+
/// @custom:param owner The NFT owner.
|
|
67
|
+
mapping(
|
|
68
|
+
address hook => mapping(IERC20 token => mapping(uint256 releaseRound => mapping(address owner => uint256)))
|
|
69
|
+
) internal _consumedVotesOf;
|
|
70
|
+
|
|
58
71
|
//*********************************************************************//
|
|
59
72
|
// -------------------------- constructor ---------------------------- //
|
|
60
73
|
//*********************************************************************//
|
|
@@ -108,16 +121,21 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
108
121
|
// Use balance delta to handle fee-on-transfer tokens correctly.
|
|
109
122
|
uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
|
|
110
123
|
IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
|
|
111
|
-
|
|
112
|
-
|
|
124
|
+
uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
|
|
125
|
+
_balanceOf[hook][IERC20(context.token)] += delta;
|
|
126
|
+
_accountedBalanceOf[IERC20(context.token)] += delta;
|
|
113
127
|
} else {
|
|
114
|
-
// Controller-prepaid path:
|
|
115
|
-
|
|
128
|
+
// Controller-prepaid path: verify actual unaccounted balance covers the declared amount.
|
|
129
|
+
uint256 actual = IERC20(context.token).balanceOf(address(this));
|
|
130
|
+
uint256 unaccounted = actual - _accountedBalanceOf[IERC20(context.token)];
|
|
131
|
+
if (unaccounted < context.amount) revert JBDistributor_UnfundedSplitCredit();
|
|
132
|
+
_accountedBalanceOf[IERC20(context.token)] += context.amount;
|
|
116
133
|
_balanceOf[hook][IERC20(context.token)] += context.amount;
|
|
117
134
|
}
|
|
118
135
|
} else if (msg.value != 0) {
|
|
119
136
|
// Native ETH: credit actual value received.
|
|
120
137
|
_balanceOf[hook][IERC20(context.token)] += msg.value;
|
|
138
|
+
_accountedBalanceOf[IERC20(context.token)] += msg.value;
|
|
121
139
|
}
|
|
122
140
|
}
|
|
123
141
|
|
|
@@ -194,6 +212,14 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
194
212
|
++j;
|
|
195
213
|
}
|
|
196
214
|
}
|
|
215
|
+
|
|
216
|
+
// Persist consumed voting power to storage to prevent cap resets across calls.
|
|
217
|
+
for (uint256 k; k < uniqueCount;) {
|
|
218
|
+
_consumedVotesOf[hook][token][vestingReleaseRound][owners[k]] = consumed[k];
|
|
219
|
+
unchecked {
|
|
220
|
+
++k;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
197
223
|
}
|
|
198
224
|
|
|
199
225
|
//*********************************************************************//
|
|
@@ -354,6 +380,8 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
354
380
|
if (!found) {
|
|
355
381
|
ownerIndex = newUniqueCount;
|
|
356
382
|
owners[newUniqueCount] = owner;
|
|
383
|
+
// Initialize from persistent storage to prevent cap resets across calls.
|
|
384
|
+
consumed[newUniqueCount] = _consumedVotesOf[ctx.hook][ctx.token][ctx.vestingReleaseRound][owner];
|
|
357
385
|
unchecked {
|
|
358
386
|
++newUniqueCount;
|
|
359
387
|
}
|
package/src/JBDistributor.sol
CHANGED
|
@@ -27,9 +27,18 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
27
27
|
/// @notice Thrown when the caller does not have access to the token.
|
|
28
28
|
error JBDistributor_NoAccess();
|
|
29
29
|
|
|
30
|
+
/// @notice Thrown when the round duration is zero.
|
|
31
|
+
error JBDistributor_InvalidRoundDuration();
|
|
32
|
+
|
|
30
33
|
/// @notice Thrown when there is nothing to distribute for a token in the current round.
|
|
31
34
|
error JBDistributor_NothingToDistribute();
|
|
32
35
|
|
|
36
|
+
/// @notice Thrown when a controller-prepaid split credit is not backed by actual token balance.
|
|
37
|
+
error JBDistributor_UnfundedSplitCredit();
|
|
38
|
+
|
|
39
|
+
/// @notice Thrown when unexpected native ETH is sent with an ERC-20 operation.
|
|
40
|
+
error JBDistributor_UnexpectedNativeValue();
|
|
41
|
+
|
|
33
42
|
//*********************************************************************//
|
|
34
43
|
// ------------------------- public constants ------------------------ //
|
|
35
44
|
//*********************************************************************//
|
|
@@ -80,6 +89,10 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
80
89
|
// -------------------- internal stored properties ------------------- //
|
|
81
90
|
//*********************************************************************//
|
|
82
91
|
|
|
92
|
+
/// @notice The total accounted balance of each token across all hooks.
|
|
93
|
+
/// @custom:param token The token to check the accounted balance of.
|
|
94
|
+
mapping(IERC20 token => uint256) internal _accountedBalanceOf;
|
|
95
|
+
|
|
83
96
|
/// @notice The balance of a token held for a specific hook's stakers.
|
|
84
97
|
/// @custom:param hook The hook whose balance to check.
|
|
85
98
|
/// @custom:param token The token to check the balance of.
|
|
@@ -99,6 +112,7 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
99
112
|
/// @param roundDuration_ The duration of each round, specified in seconds.
|
|
100
113
|
/// @param vestingRounds_ The number of rounds until tokens are fully vested.
|
|
101
114
|
constructor(uint256 roundDuration_, uint256 vestingRounds_) {
|
|
115
|
+
if (roundDuration_ == 0) revert JBDistributor_InvalidRoundDuration();
|
|
102
116
|
startingTimestamp = block.timestamp;
|
|
103
117
|
roundDuration = roundDuration_;
|
|
104
118
|
vestingRounds = vestingRounds_;
|
|
@@ -162,12 +176,14 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
162
176
|
if (address(token) == JBConstants.NATIVE_TOKEN) {
|
|
163
177
|
amount = msg.value;
|
|
164
178
|
} else {
|
|
179
|
+
if (msg.value != 0) revert JBDistributor_UnexpectedNativeValue();
|
|
165
180
|
// Use balance delta to handle fee-on-transfer tokens correctly.
|
|
166
181
|
uint256 balanceBefore = token.balanceOf(address(this));
|
|
167
182
|
token.safeTransferFrom(msg.sender, address(this), amount);
|
|
168
183
|
amount = token.balanceOf(address(this)) - balanceBefore;
|
|
169
184
|
}
|
|
170
185
|
_balanceOf[hook][token] += amount;
|
|
186
|
+
_accountedBalanceOf[token] += amount;
|
|
171
187
|
}
|
|
172
188
|
|
|
173
189
|
/// @notice Record the snapshot block for the current round. Callable by anyone (keepers, frontends).
|
|
@@ -465,6 +481,7 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
465
481
|
if (ownerClaim) {
|
|
466
482
|
// Decrement the hook's balance and transfer tokens out.
|
|
467
483
|
_balanceOf[hook][token] -= totalTokenAmount;
|
|
484
|
+
_accountedBalanceOf[token] -= totalTokenAmount;
|
|
468
485
|
|
|
469
486
|
if (address(token) == JBConstants.NATIVE_TOKEN) {
|
|
470
487
|
// slither-disable-next-line arbitrary-send-eth,reentrancy-eth
|
|
@@ -90,16 +90,21 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
|
|
|
90
90
|
// Use balance delta to handle fee-on-transfer tokens correctly.
|
|
91
91
|
uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
|
|
92
92
|
IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
|
|
94
|
+
_balanceOf[hook][IERC20(context.token)] += delta;
|
|
95
|
+
_accountedBalanceOf[IERC20(context.token)] += delta;
|
|
95
96
|
} else {
|
|
96
|
-
// Controller-prepaid path:
|
|
97
|
-
|
|
97
|
+
// Controller-prepaid path: verify actual unaccounted balance covers the declared amount.
|
|
98
|
+
uint256 actual = IERC20(context.token).balanceOf(address(this));
|
|
99
|
+
uint256 unaccounted = actual - _accountedBalanceOf[IERC20(context.token)];
|
|
100
|
+
if (unaccounted < context.amount) revert JBDistributor_UnfundedSplitCredit();
|
|
101
|
+
_accountedBalanceOf[IERC20(context.token)] += context.amount;
|
|
98
102
|
_balanceOf[hook][IERC20(context.token)] += context.amount;
|
|
99
103
|
}
|
|
100
104
|
} else if (msg.value != 0) {
|
|
101
105
|
// Native ETH: credit actual value received.
|
|
102
106
|
_balanceOf[hook][IERC20(context.token)] += msg.value;
|
|
107
|
+
_accountedBalanceOf[IERC20(context.token)] += msg.value;
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
110
|
|
|
@@ -18,6 +18,7 @@ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookCont
|
|
|
18
18
|
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
19
19
|
import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
|
|
20
20
|
|
|
21
|
+
import {JBDistributor} from "../../src/JBDistributor.sol";
|
|
21
22
|
import {JB721Distributor} from "../../src/JB721Distributor.sol";
|
|
22
23
|
import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
|
|
23
24
|
|
|
@@ -180,31 +181,19 @@ contract CodexNemesisAccountingPoCTest is Test {
|
|
|
180
181
|
split: split
|
|
181
182
|
});
|
|
182
183
|
|
|
184
|
+
// C-6 FIX: The malicious controller did not actually transfer tokens, so the
|
|
185
|
+
// balance-delta check reverts with UnfundedSplitCredit.
|
|
183
186
|
vm.prank(maliciousController);
|
|
187
|
+
vm.expectRevert(JBDistributor.JBDistributor_UnfundedSplitCredit.selector);
|
|
184
188
|
distributor.processSplitWith(fakeContext);
|
|
185
189
|
|
|
186
|
-
|
|
190
|
+
// The real balance should remain intact — the attack was blocked.
|
|
191
|
+
assertEq(rewardToken.balanceOf(address(distributor)), 1000 ether, "balance unchanged after blocked attack");
|
|
187
192
|
assertEq(
|
|
188
193
|
distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
189
|
-
|
|
190
|
-
"tracked balance was inflated
|
|
194
|
+
1000 ether,
|
|
195
|
+
"tracked balance was not inflated"
|
|
191
196
|
);
|
|
192
|
-
|
|
193
|
-
uint256[] memory tokenIds = new uint256[](1);
|
|
194
|
-
tokenIds[0] = uint256(uint160(attacker));
|
|
195
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
196
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
197
|
-
|
|
198
|
-
distributor.beginVesting(address(votesToken), tokenIds, tokens);
|
|
199
|
-
assertEq(distributor.claimedFor(address(votesToken), tokenIds[0], tokens[0]), 1000 ether);
|
|
200
|
-
|
|
201
|
-
vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
|
|
202
|
-
vm.roll(block.number + 1);
|
|
203
|
-
vm.prank(attacker);
|
|
204
|
-
distributor.collectVestedRewards(address(votesToken), tokenIds, tokens, attacker);
|
|
205
|
-
|
|
206
|
-
assertEq(rewardToken.balanceOf(attacker), 1000 ether, "attacker drained the real inventory");
|
|
207
|
-
assertEq(rewardToken.balanceOf(address(distributor)), 0, "honest claimants are left unfunded");
|
|
208
197
|
}
|
|
209
198
|
|
|
210
199
|
function test_721LateMintCanUseOwnersPastVotesAndDrainRound() public {
|
|
@@ -325,23 +314,26 @@ contract CodexNemesisAccountingPoCTest is Test {
|
|
|
325
314
|
secondLateMint[0] = 3;
|
|
326
315
|
distributor.beginVesting(address(hook), secondLateMint, tokens);
|
|
327
316
|
|
|
317
|
+
// H-24 FIX: With persistent consumed-votes tracking, the second beginVesting sees
|
|
318
|
+
// that all 100 votes are already consumed, so token 3 gets 0 reward.
|
|
328
319
|
assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
|
|
329
|
-
assertEq(distributor.claimedFor(address(hook), 3, tokens[0]),
|
|
320
|
+
assertEq(distributor.claimedFor(address(hook), 3, tokens[0]), 0, "votes already consumed, no double-claim");
|
|
330
321
|
assertEq(
|
|
331
322
|
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
332
|
-
|
|
333
|
-
"
|
|
323
|
+
1000 ether,
|
|
324
|
+
"total vesting capped at funded balance"
|
|
334
325
|
);
|
|
335
|
-
|
|
326
|
+
assertLe(
|
|
336
327
|
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
337
328
|
distributor.balanceOf(address(hook), tokens[0]),
|
|
338
|
-
"vesting obligations exceed funded balance"
|
|
329
|
+
"vesting obligations do not exceed funded balance"
|
|
339
330
|
);
|
|
340
331
|
|
|
332
|
+
// Collection should succeed without underflow.
|
|
341
333
|
vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
|
|
342
334
|
vm.roll(block.number + 1);
|
|
343
335
|
vm.prank(attacker);
|
|
344
|
-
vm.expectRevert(stdError.arithmeticError);
|
|
345
336
|
distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
|
|
337
|
+
assertEq(rewardToken.balanceOf(attacker), 1000 ether, "attacker gets only their fair share");
|
|
346
338
|
}
|
|
347
339
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
7
|
+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
8
|
+
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
|
|
9
|
+
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
|
|
10
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
11
|
+
|
|
12
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
13
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
14
|
+
import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
15
|
+
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
16
|
+
import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
|
|
17
|
+
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
18
|
+
import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
|
|
19
|
+
|
|
20
|
+
import {JB721Distributor} from "../../src/JB721Distributor.sol";
|
|
21
|
+
import {JBDistributor} from "../../src/JBDistributor.sol";
|
|
22
|
+
import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
|
|
23
|
+
|
|
24
|
+
import {H26MockDirectory, H26MockHook, H26MockRewardToken, H26MockStore} from "./H26VotingPowerCap.t.sol";
|
|
25
|
+
|
|
26
|
+
contract CodexNemesisDirectory {
|
|
27
|
+
mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
|
|
28
|
+
mapping(uint256 projectId => address controller) public controllers;
|
|
29
|
+
|
|
30
|
+
function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
|
|
31
|
+
terminals[projectId][terminal] = isTerminal;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function setController(uint256 projectId, address controller) external {
|
|
35
|
+
controllers[projectId] = controller;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
|
|
39
|
+
return terminals[projectId][address(terminal)];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function controllerOf(uint256 projectId) external view returns (IERC165) {
|
|
43
|
+
return IERC165(controllers[projectId]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
contract CodexNemesisRewardToken is ERC20 {
|
|
48
|
+
constructor() ERC20("Reward", "RWD") {}
|
|
49
|
+
|
|
50
|
+
function mint(address to, uint256 amount) external {
|
|
51
|
+
_mint(to, amount);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
contract CodexNemesisVotesToken is ERC20, ERC20Votes {
|
|
56
|
+
constructor() ERC20("StakeToken", "STK") EIP712("StakeToken", "1") {}
|
|
57
|
+
|
|
58
|
+
function mint(address to, uint256 amount) external {
|
|
59
|
+
_mint(to, amount);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
|
|
63
|
+
super._update(from, to, value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
contract CodexNemesisPoCTest is Test {
|
|
68
|
+
uint256 constant ROUND_DURATION = 100;
|
|
69
|
+
uint256 constant VESTING_ROUNDS = 1;
|
|
70
|
+
|
|
71
|
+
function test_721OwnerVotingCapResetsAcrossBeginVestingCalls() public {
|
|
72
|
+
H26MockStore store = new H26MockStore();
|
|
73
|
+
H26MockHook hook = new H26MockHook(store);
|
|
74
|
+
H26MockDirectory directory = new H26MockDirectory();
|
|
75
|
+
JB721Distributor distributor =
|
|
76
|
+
new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
77
|
+
H26MockRewardToken rewardToken = new H26MockRewardToken();
|
|
78
|
+
|
|
79
|
+
address alice = makeAddr("alice");
|
|
80
|
+
|
|
81
|
+
store.setMaxTierIdOf(1);
|
|
82
|
+
store.setTier(
|
|
83
|
+
1,
|
|
84
|
+
JB721Tier({
|
|
85
|
+
id: 1,
|
|
86
|
+
price: 1 ether,
|
|
87
|
+
remainingSupply: 7,
|
|
88
|
+
initialSupply: 10,
|
|
89
|
+
votingUnits: 50,
|
|
90
|
+
reserveFrequency: 0,
|
|
91
|
+
reserveBeneficiary: address(0),
|
|
92
|
+
encodedIPFSUri: bytes32(0),
|
|
93
|
+
category: 0,
|
|
94
|
+
discountPercent: 0,
|
|
95
|
+
flags: JB721TierFlags({
|
|
96
|
+
allowOwnerMint: false,
|
|
97
|
+
transfersPausable: false,
|
|
98
|
+
cantBeRemoved: false,
|
|
99
|
+
cantIncreaseDiscountPercent: false,
|
|
100
|
+
cantBuyWithCredits: false
|
|
101
|
+
}),
|
|
102
|
+
splitPercent: 0,
|
|
103
|
+
resolvedUri: ""
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
store.setTokenTier(1, 1);
|
|
108
|
+
store.setTokenTier(2, 1);
|
|
109
|
+
store.setTokenTier(3, 1);
|
|
110
|
+
hook.setOwner(1, alice);
|
|
111
|
+
hook.setOwner(2, alice);
|
|
112
|
+
hook.setOwner(3, alice);
|
|
113
|
+
hook._checkpoints().setVotesOverride(alice, 100);
|
|
114
|
+
|
|
115
|
+
rewardToken.mint(address(this), 1500 ether);
|
|
116
|
+
rewardToken.approve(address(distributor), 1500 ether);
|
|
117
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), 1500 ether);
|
|
118
|
+
|
|
119
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
120
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
121
|
+
|
|
122
|
+
uint256[] memory oneTokenId = new uint256[](1);
|
|
123
|
+
oneTokenId[0] = 1;
|
|
124
|
+
distributor.beginVesting(address(hook), oneTokenId, tokens);
|
|
125
|
+
oneTokenId[0] = 2;
|
|
126
|
+
distributor.beginVesting(address(hook), oneTokenId, tokens);
|
|
127
|
+
oneTokenId[0] = 3;
|
|
128
|
+
distributor.beginVesting(address(hook), oneTokenId, tokens);
|
|
129
|
+
|
|
130
|
+
uint256 claimed1 = distributor.claimedFor(address(hook), 1, IERC20(address(rewardToken)));
|
|
131
|
+
uint256 claimed2 = distributor.claimedFor(address(hook), 2, IERC20(address(rewardToken)));
|
|
132
|
+
uint256 claimed3 = distributor.claimedFor(address(hook), 3, IERC20(address(rewardToken)));
|
|
133
|
+
|
|
134
|
+
// H-24 FIX: With persistent consumed-votes tracking, the total claimed is now correctly
|
|
135
|
+
// capped at the owner's voting power (100 votes / 150 total stake * 1500 = 1000 ether).
|
|
136
|
+
assertEq(claimed1 + claimed2 + claimed3, 1000 ether);
|
|
137
|
+
assertLe(claimed1 + claimed2 + claimed3, 1000 ether);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function test_processSplitWithControllerPathCanCreditUnfundedBalanceAndDrainOtherHook() public {
|
|
141
|
+
CodexNemesisDirectory directory = new CodexNemesisDirectory();
|
|
142
|
+
JBTokenDistributor distributor =
|
|
143
|
+
new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
144
|
+
CodexNemesisRewardToken rewardToken = new CodexNemesisRewardToken();
|
|
145
|
+
CodexNemesisVotesToken attackerVotes = new CodexNemesisVotesToken();
|
|
146
|
+
|
|
147
|
+
address attacker = makeAddr("attacker");
|
|
148
|
+
address maliciousController = makeAddr("maliciousController");
|
|
149
|
+
address victimHook = makeAddr("victimHook");
|
|
150
|
+
uint256 projectId = 1;
|
|
151
|
+
|
|
152
|
+
directory.setController(projectId, maliciousController);
|
|
153
|
+
|
|
154
|
+
rewardToken.mint(address(this), 1000 ether);
|
|
155
|
+
rewardToken.approve(address(distributor), 1000 ether);
|
|
156
|
+
distributor.fund(victimHook, IERC20(address(rewardToken)), 1000 ether);
|
|
157
|
+
|
|
158
|
+
attackerVotes.mint(attacker, 1000 ether);
|
|
159
|
+
vm.prank(attacker);
|
|
160
|
+
attackerVotes.delegate(attacker);
|
|
161
|
+
vm.roll(block.number + 1);
|
|
162
|
+
|
|
163
|
+
JBSplit memory split = JBSplit({
|
|
164
|
+
percent: 1_000_000_000,
|
|
165
|
+
projectId: 0,
|
|
166
|
+
beneficiary: payable(address(attackerVotes)),
|
|
167
|
+
preferAddToBalance: false,
|
|
168
|
+
lockedUntil: 0,
|
|
169
|
+
hook: IJBSplitHook(address(distributor))
|
|
170
|
+
});
|
|
171
|
+
JBSplitHookContext memory context = JBSplitHookContext({
|
|
172
|
+
token: address(rewardToken),
|
|
173
|
+
amount: 1000 ether,
|
|
174
|
+
decimals: 18,
|
|
175
|
+
projectId: projectId,
|
|
176
|
+
groupId: uint256(uint160(address(rewardToken))),
|
|
177
|
+
split: split
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// C-6 FIX: The malicious controller did not actually transfer tokens, so the
|
|
181
|
+
// balance-delta check should revert with UnfundedSplitCredit.
|
|
182
|
+
vm.prank(maliciousController);
|
|
183
|
+
vm.expectRevert(JBDistributor.JBDistributor_UnfundedSplitCredit.selector);
|
|
184
|
+
distributor.processSplitWith(context);
|
|
185
|
+
|
|
186
|
+
// The victim hook's balance should remain intact.
|
|
187
|
+
assertEq(distributor.balanceOf(victimHook, IERC20(address(rewardToken))), 1000 ether);
|
|
188
|
+
// No balance should be credited to the attacker's hook.
|
|
189
|
+
assertEq(distributor.balanceOf(address(attackerVotes), IERC20(address(rewardToken))), 0);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
7
|
+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
8
|
+
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
|
|
9
|
+
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
|
|
10
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
11
|
+
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
12
|
+
import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
|
|
13
|
+
|
|
14
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
15
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
16
|
+
import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
17
|
+
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
18
|
+
import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
|
|
19
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
20
|
+
|
|
21
|
+
import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
|
|
22
|
+
import {JB721Distributor} from "../../src/JB721Distributor.sol";
|
|
23
|
+
import {JBDistributor} from "../../src/JBDistributor.sol";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
H26MockDirectory,
|
|
27
|
+
H26MockHook,
|
|
28
|
+
H26MockRewardToken,
|
|
29
|
+
H26MockStore,
|
|
30
|
+
H26MockCheckpoints
|
|
31
|
+
} from "./H26VotingPowerCap.t.sol";
|
|
32
|
+
|
|
33
|
+
// =========================================================================
|
|
34
|
+
// Mock contracts for JBTokenDistributor tests (C-6, L-17)
|
|
35
|
+
// =========================================================================
|
|
36
|
+
|
|
37
|
+
/// @notice Mock JB directory for Pass12 tests.
|
|
38
|
+
contract P12MockDirectory {
|
|
39
|
+
mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
|
|
40
|
+
mapping(uint256 projectId => address controller) public controllers;
|
|
41
|
+
|
|
42
|
+
function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
|
|
43
|
+
terminals[projectId][terminal] = isTerminal;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function setController(uint256 projectId, address controller) external {
|
|
47
|
+
controllers[projectId] = controller;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
|
|
51
|
+
return terminals[projectId][address(terminal)];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function controllerOf(uint256 projectId) external view returns (IERC165) {
|
|
55
|
+
return IERC165(controllers[projectId]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// @notice Simple ERC20 reward token for Pass12 tests.
|
|
60
|
+
contract P12MockRewardToken is ERC20 {
|
|
61
|
+
constructor() ERC20("Reward", "RWD") {}
|
|
62
|
+
|
|
63
|
+
function mint(address to, uint256 amount) external {
|
|
64
|
+
_mint(to, amount);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// @notice ERC20Votes token for staking in Pass12 tests.
|
|
69
|
+
contract P12MockVotesToken is ERC20, ERC20Votes {
|
|
70
|
+
constructor() ERC20("StakeToken", "STK") EIP712("StakeToken", "1") {}
|
|
71
|
+
|
|
72
|
+
function mint(address to, uint256 amount) external {
|
|
73
|
+
_mint(to, amount);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
|
|
77
|
+
super._update(from, to, value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// =========================================================================
|
|
82
|
+
// Test contract
|
|
83
|
+
// =========================================================================
|
|
84
|
+
|
|
85
|
+
/// @notice Tests for Pass 12 audit fixes: C-6, H-24, and L-17.
|
|
86
|
+
contract Pass12FixesTest is Test {
|
|
87
|
+
// --- Token Distributor setup (C-6, L-17) ---
|
|
88
|
+
P12MockDirectory tokenDirectory;
|
|
89
|
+
P12MockRewardToken rewardToken;
|
|
90
|
+
P12MockVotesToken votesToken;
|
|
91
|
+
JBTokenDistributor tokenDistributor;
|
|
92
|
+
|
|
93
|
+
// --- 721 Distributor setup (H-24) ---
|
|
94
|
+
H26MockStore store;
|
|
95
|
+
H26MockHook hook;
|
|
96
|
+
H26MockDirectory nftDirectory;
|
|
97
|
+
H26MockRewardToken nftRewardToken;
|
|
98
|
+
JB721Distributor nftDistributor;
|
|
99
|
+
|
|
100
|
+
address alice = makeAddr("alice");
|
|
101
|
+
address bob = makeAddr("bob");
|
|
102
|
+
address controller = makeAddr("controller");
|
|
103
|
+
address terminal = makeAddr("terminal");
|
|
104
|
+
uint256 projectId = 1;
|
|
105
|
+
|
|
106
|
+
uint256 constant ROUND_DURATION = 100;
|
|
107
|
+
uint256 constant VESTING_ROUNDS = 4;
|
|
108
|
+
|
|
109
|
+
function setUp() public {
|
|
110
|
+
// --- Token Distributor ---
|
|
111
|
+
tokenDirectory = new P12MockDirectory();
|
|
112
|
+
rewardToken = new P12MockRewardToken();
|
|
113
|
+
votesToken = new P12MockVotesToken();
|
|
114
|
+
|
|
115
|
+
tokenDirectory.setTerminal(projectId, terminal, true);
|
|
116
|
+
tokenDirectory.setController(projectId, controller);
|
|
117
|
+
|
|
118
|
+
tokenDistributor = new JBTokenDistributor(IJBDirectory(address(tokenDirectory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
119
|
+
|
|
120
|
+
votesToken.mint(alice, 1000 ether);
|
|
121
|
+
vm.prank(alice);
|
|
122
|
+
votesToken.delegate(alice);
|
|
123
|
+
|
|
124
|
+
// --- 721 Distributor ---
|
|
125
|
+
store = new H26MockStore();
|
|
126
|
+
hook = new H26MockHook(store);
|
|
127
|
+
nftDirectory = new H26MockDirectory();
|
|
128
|
+
|
|
129
|
+
nftDistributor = new JB721Distributor(IJBDirectory(address(nftDirectory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
130
|
+
|
|
131
|
+
nftDirectory.setTerminal(projectId, address(this), true);
|
|
132
|
+
|
|
133
|
+
nftRewardToken = new H26MockRewardToken();
|
|
134
|
+
|
|
135
|
+
JB721TierFlags memory flags;
|
|
136
|
+
|
|
137
|
+
// Tier 1: votingUnits = 50 each, 3 minted out of 10.
|
|
138
|
+
store.setMaxTierIdOf(1);
|
|
139
|
+
store.setTier(
|
|
140
|
+
1,
|
|
141
|
+
JB721Tier({
|
|
142
|
+
id: 1,
|
|
143
|
+
price: 1 ether,
|
|
144
|
+
remainingSupply: 7,
|
|
145
|
+
initialSupply: 10,
|
|
146
|
+
votingUnits: 50,
|
|
147
|
+
reserveFrequency: 0,
|
|
148
|
+
reserveBeneficiary: address(0),
|
|
149
|
+
encodedIPFSUri: bytes32(0),
|
|
150
|
+
category: 0,
|
|
151
|
+
discountPercent: 0,
|
|
152
|
+
flags: flags,
|
|
153
|
+
splitPercent: 0,
|
|
154
|
+
resolvedUri: ""
|
|
155
|
+
})
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Alice owns 3 NFTs: tokens 1, 2, 3 — all tier 1 (50 voting units each).
|
|
159
|
+
store.setTokenTier(1, 1);
|
|
160
|
+
store.setTokenTier(2, 1);
|
|
161
|
+
store.setTokenTier(3, 1);
|
|
162
|
+
hook.setOwner(1, alice);
|
|
163
|
+
hook.setOwner(2, alice);
|
|
164
|
+
hook.setOwner(3, alice);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// =====================================================================
|
|
168
|
+
// Helpers
|
|
169
|
+
// =====================================================================
|
|
170
|
+
|
|
171
|
+
function _advanceToRound(uint256 round, JBDistributor dist) internal {
|
|
172
|
+
uint256 targetTimestamp = dist.roundStartTimestamp(round) + 1;
|
|
173
|
+
if (block.timestamp < targetTimestamp) {
|
|
174
|
+
vm.warp(targetTimestamp);
|
|
175
|
+
}
|
|
176
|
+
vm.roll(block.number + 1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _buildTokenContext(address token, uint256 amount) internal view returns (JBSplitHookContext memory) {
|
|
180
|
+
JBSplit memory split = JBSplit({
|
|
181
|
+
percent: 1_000_000_000,
|
|
182
|
+
projectId: 0,
|
|
183
|
+
beneficiary: payable(address(votesToken)),
|
|
184
|
+
preferAddToBalance: false,
|
|
185
|
+
lockedUntil: 0,
|
|
186
|
+
hook: IJBSplitHook(address(tokenDistributor))
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return JBSplitHookContext({
|
|
190
|
+
token: token, amount: amount, decimals: 18, projectId: projectId, groupId: 0, split: split
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// =====================================================================
|
|
195
|
+
// C-6: Unbacked split credits
|
|
196
|
+
// =====================================================================
|
|
197
|
+
|
|
198
|
+
/// @notice A controller calls processSplitWith without transferring tokens first.
|
|
199
|
+
/// Should revert with JBDistributor_UnfundedSplitCredit.
|
|
200
|
+
function test_C6_fix_reverts_unfunded_credit() public {
|
|
201
|
+
uint256 amount = 500 ether;
|
|
202
|
+
JBSplitHookContext memory context = _buildTokenContext(address(rewardToken), amount);
|
|
203
|
+
|
|
204
|
+
// Controller calls processSplitWith WITHOUT transferring any tokens to the distributor.
|
|
205
|
+
// The controller has no allowance either, so it falls into the "else" (controller-prepaid) branch.
|
|
206
|
+
vm.prank(controller);
|
|
207
|
+
vm.expectRevert(JBDistributor.JBDistributor_UnfundedSplitCredit.selector);
|
|
208
|
+
tokenDistributor.processSplitWith(context);
|
|
209
|
+
|
|
210
|
+
// Verify no balance was credited.
|
|
211
|
+
assertEq(
|
|
212
|
+
tokenDistributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
213
|
+
0,
|
|
214
|
+
"No balance should be credited without actual token transfer"
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// @notice A controller that actually transfers tokens before calling processSplitWith
|
|
219
|
+
/// should still work correctly.
|
|
220
|
+
function test_C6_legitimate_prepaid_still_works() public {
|
|
221
|
+
uint256 amount = 500 ether;
|
|
222
|
+
JBSplitHookContext memory context = _buildTokenContext(address(rewardToken), amount);
|
|
223
|
+
|
|
224
|
+
// Controller transfers tokens to the distributor first.
|
|
225
|
+
rewardToken.mint(controller, amount);
|
|
226
|
+
vm.prank(controller);
|
|
227
|
+
rewardToken.transfer(address(tokenDistributor), amount);
|
|
228
|
+
|
|
229
|
+
// Now the controller calls processSplitWith — should succeed because the unaccounted
|
|
230
|
+
// balance covers the declared amount.
|
|
231
|
+
vm.prank(controller);
|
|
232
|
+
tokenDistributor.processSplitWith(context);
|
|
233
|
+
|
|
234
|
+
// Verify balance was credited.
|
|
235
|
+
assertEq(
|
|
236
|
+
tokenDistributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
237
|
+
amount,
|
|
238
|
+
"Balance should be credited when tokens were actually transferred"
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
// Verify the tokens are held by the distributor.
|
|
242
|
+
assertEq(rewardToken.balanceOf(address(tokenDistributor)), amount, "Tokens should be in the distributor");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// =====================================================================
|
|
246
|
+
// H-24: Voting cap reset across calls
|
|
247
|
+
// =====================================================================
|
|
248
|
+
|
|
249
|
+
/// @notice Calling beginVesting multiple times in the same round for the same owner's
|
|
250
|
+
/// different tokens should not reset the voting power cap.
|
|
251
|
+
function test_H24_fix_caps_across_calls() public {
|
|
252
|
+
// Alice has 3 NFTs x 50 voting units = 150 total.
|
|
253
|
+
// Her pastVotes is only 100 — so she should be capped at 100 total.
|
|
254
|
+
hook._checkpoints().setVotesOverride(alice, 100);
|
|
255
|
+
|
|
256
|
+
// Fund with 1500 ether. Total stake = 150 (3 minted * 50 voting units).
|
|
257
|
+
nftRewardToken.mint(address(this), 1500 ether);
|
|
258
|
+
nftRewardToken.approve(address(nftDistributor), 1500 ether);
|
|
259
|
+
nftDistributor.fund(address(hook), IERC20(address(nftRewardToken)), 1500 ether);
|
|
260
|
+
|
|
261
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
262
|
+
tokens[0] = IERC20(address(nftRewardToken));
|
|
263
|
+
|
|
264
|
+
// Call beginVesting THREE TIMES, each with a single token ID.
|
|
265
|
+
// Without the H-24 fix, each call resets the consumed voting power to 0,
|
|
266
|
+
// allowing Alice to claim 50 voting units per call = 150 total (bypassing the 100 cap).
|
|
267
|
+
// With the fix, consumed votes persist in storage across calls.
|
|
268
|
+
uint256[] memory singleId = new uint256[](1);
|
|
269
|
+
|
|
270
|
+
singleId[0] = 1;
|
|
271
|
+
nftDistributor.beginVesting(address(hook), singleId, tokens);
|
|
272
|
+
|
|
273
|
+
singleId[0] = 2;
|
|
274
|
+
nftDistributor.beginVesting(address(hook), singleId, tokens);
|
|
275
|
+
|
|
276
|
+
singleId[0] = 3;
|
|
277
|
+
nftDistributor.beginVesting(address(hook), singleId, tokens);
|
|
278
|
+
|
|
279
|
+
// Check claimed amounts.
|
|
280
|
+
uint256 claimed1 = nftDistributor.claimedFor(address(hook), 1, IERC20(address(nftRewardToken)));
|
|
281
|
+
uint256 claimed2 = nftDistributor.claimedFor(address(hook), 2, IERC20(address(nftRewardToken)));
|
|
282
|
+
uint256 claimed3 = nftDistributor.claimedFor(address(hook), 3, IERC20(address(nftRewardToken)));
|
|
283
|
+
|
|
284
|
+
uint256 totalClaimed = claimed1 + claimed2 + claimed3;
|
|
285
|
+
|
|
286
|
+
// With cap enforced: Alice has 100 pastVotes out of 150 total stake.
|
|
287
|
+
// NFT 1: effective stake = min(50, 100 remaining) = 50, reward = 1500 * 50/150 = 500
|
|
288
|
+
// NFT 2: effective stake = min(50, 50 remaining) = 50, reward = 1500 * 50/150 = 500
|
|
289
|
+
// NFT 3: effective stake = min(50, 0 remaining) = 0, reward = 0
|
|
290
|
+
// Total = 1000 ether (not 1500).
|
|
291
|
+
assertEq(claimed1, 500 ether, "NFT 1 should get full 50-unit share");
|
|
292
|
+
assertEq(claimed2, 500 ether, "NFT 2 should get full 50-unit share");
|
|
293
|
+
assertEq(claimed3, 0, "NFT 3 should get 0 (voting power exhausted across calls)");
|
|
294
|
+
assertEq(totalClaimed, 1000 ether, "Total should be capped at 100/150 of distributable");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// =====================================================================
|
|
298
|
+
// L-17: fund() ETH trap
|
|
299
|
+
// =====================================================================
|
|
300
|
+
|
|
301
|
+
/// @notice Sending ETH with an ERC-20 token in fund() should revert.
|
|
302
|
+
function test_L17_fix_reverts_unexpected_eth() public {
|
|
303
|
+
uint256 erc20Amount = 100 ether;
|
|
304
|
+
rewardToken.mint(address(this), erc20Amount);
|
|
305
|
+
rewardToken.approve(address(tokenDistributor), erc20Amount);
|
|
306
|
+
|
|
307
|
+
// Call fund() with an ERC-20 token but also send msg.value.
|
|
308
|
+
// This should revert with JBDistributor_UnexpectedNativeValue.
|
|
309
|
+
vm.expectRevert(JBDistributor.JBDistributor_UnexpectedNativeValue.selector);
|
|
310
|
+
tokenDistributor.fund{value: 1 ether}(address(votesToken), IERC20(address(rewardToken)), erc20Amount);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/// @notice fund() with native token and msg.value should still work normally.
|
|
314
|
+
function test_L17_fund_native_token_still_works() public {
|
|
315
|
+
vm.deal(address(this), 5 ether);
|
|
316
|
+
|
|
317
|
+
tokenDistributor.fund{value: 5 ether}(
|
|
318
|
+
address(votesToken),
|
|
319
|
+
IERC20(JBConstants.NATIVE_TOKEN),
|
|
320
|
+
0 // amount param is ignored for native token
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
assertEq(
|
|
324
|
+
tokenDistributor.balanceOf(address(votesToken), IERC20(JBConstants.NATIVE_TOKEN)),
|
|
325
|
+
5 ether,
|
|
326
|
+
"Native ETH fund should work normally"
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/// @notice fund() with ERC-20 and no ETH should still work normally.
|
|
331
|
+
function test_L17_fund_erc20_no_eth_still_works() public {
|
|
332
|
+
uint256 amount = 200 ether;
|
|
333
|
+
rewardToken.mint(address(this), amount);
|
|
334
|
+
rewardToken.approve(address(tokenDistributor), amount);
|
|
335
|
+
|
|
336
|
+
tokenDistributor.fund(address(votesToken), IERC20(address(rewardToken)), amount);
|
|
337
|
+
|
|
338
|
+
assertEq(
|
|
339
|
+
tokenDistributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
340
|
+
amount,
|
|
341
|
+
"ERC-20 fund without ETH should work normally"
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
}
|