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