@bananapus/distributor-v6 0.0.7 → 0.0.8
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/README.md +2 -2
- package/package.json +16 -7
- package/src/JB721Distributor.sol +46 -12
- package/src/JBTokenDistributor.sol +4 -0
- package/.github/pull_request_template.md +0 -33
- package/.github/workflows/lint.yml +0 -19
- package/.github/workflows/publish.yml +0 -19
- package/.github/workflows/slither.yml +0 -23
- package/.github/workflows/test.yml +0 -28
- package/.gitmodules +0 -3
- package/ADMINISTRATION.md +0 -65
- package/ARCHITECTURE.md +0 -89
- package/AUDIT_INSTRUCTIONS.md +0 -52
- package/RISKS.md +0 -78
- package/SKILLS.md +0 -36
- package/USER_JOURNEYS.md +0 -122
- package/slither-ci.config.json +0 -10
- package/test/AuditFixes.t.sol +0 -429
- package/test/JB721Distributor.t.sol +0 -2059
- package/test/JBTokenDistributor.t.sol +0 -503
- package/test/audit/CodexNemesisAccountingPoC.t.sol +0 -344
- package/test/audit/CodexNemesisFreshSplitTokenMismatch.t.sol +0 -133
- package/test/audit/CodexNemesisFreshVerification.t.sol +0 -218
- package/test/audit/CodexNemesisPoC.t.sol +0 -191
- package/test/audit/H26VotingPowerCap.t.sol +0 -343
- package/test/audit/Pass12Fixes.t.sol +0 -344
- package/test/audit/PostSnapshotMintTheft.t.sol +0 -413
- package/test/audit/TokenMismatchFix.t.sol +0 -295
- package/test/fork/TokenDistributorFork.t.sol +0 -603
- package/test/invariant/JB721DistributorInvariant.t.sol +0 -414
|
@@ -1,344 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity 0.8.28;
|
|
3
|
-
|
|
4
|
-
import {Test} from "forge-std/Test.sol";
|
|
5
|
-
import {stdError} from "forge-std/StdError.sol";
|
|
6
|
-
|
|
7
|
-
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
8
|
-
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
9
|
-
import {ERC20Votes} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol";
|
|
10
|
-
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
|
|
11
|
-
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
12
|
-
|
|
13
|
-
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
14
|
-
import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
15
|
-
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
16
|
-
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
17
|
-
import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
|
|
18
|
-
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
19
|
-
import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
|
|
20
|
-
|
|
21
|
-
import {JBDistributor} from "../../src/JBDistributor.sol";
|
|
22
|
-
import {JB721Distributor} from "../../src/JB721Distributor.sol";
|
|
23
|
-
import {JBTokenDistributor} from "../../src/JBTokenDistributor.sol";
|
|
24
|
-
|
|
25
|
-
contract CodexNemesisDirectory {
|
|
26
|
-
mapping(uint256 projectId => mapping(address terminal => bool)) public terminals;
|
|
27
|
-
mapping(uint256 projectId => address controller) public controllers;
|
|
28
|
-
|
|
29
|
-
function setTerminal(uint256 projectId, address terminal, bool isTerminal) external {
|
|
30
|
-
terminals[projectId][terminal] = isTerminal;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function setController(uint256 projectId, address controller) external {
|
|
34
|
-
controllers[projectId] = controller;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function isTerminalOf(uint256 projectId, IJBTerminal terminal) external view returns (bool) {
|
|
38
|
-
return terminals[projectId][address(terminal)];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function controllerOf(uint256 projectId) external view returns (IERC165) {
|
|
42
|
-
return IERC165(controllers[projectId]);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
contract CodexNemesisRewardToken is ERC20 {
|
|
47
|
-
constructor() ERC20("Reward", "RWD") {}
|
|
48
|
-
|
|
49
|
-
function mint(address to, uint256 amount) external {
|
|
50
|
-
_mint(to, amount);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
contract CodexNemesisVotesToken is ERC20, ERC20Votes {
|
|
55
|
-
constructor() ERC20("Votes", "VOTE") EIP712("Votes", "1") {}
|
|
56
|
-
|
|
57
|
-
function mint(address to, uint256 amount) external {
|
|
58
|
-
_mint(to, amount);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Votes) {
|
|
62
|
-
super._update(from, to, value);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
contract CodexNemesisStore {
|
|
67
|
-
uint256 public maxTier;
|
|
68
|
-
mapping(uint256 tierId => JB721Tier) public tiers;
|
|
69
|
-
mapping(uint256 tokenId => uint256 tierId) public tokenTiers;
|
|
70
|
-
|
|
71
|
-
function setMaxTierIdOf(uint256 maxTierId) external {
|
|
72
|
-
maxTier = maxTierId;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function setTier(uint256 tierId, JB721Tier memory tier) external {
|
|
76
|
-
tiers[tierId] = tier;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function setTokenTier(uint256 tokenId, uint256 tierId) external {
|
|
80
|
-
tokenTiers[tokenId] = tierId;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function tierOfTokenId(address, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
84
|
-
return tiers[tokenTiers[tokenId]];
|
|
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
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
contract CodexNemesisCheckpoints {
|
|
94
|
-
uint256 public totalSupplyAtSnapshot;
|
|
95
|
-
mapping(address account => uint256 votes) public votesAtSnapshot;
|
|
96
|
-
|
|
97
|
-
function setTotalSupply(uint256 totalSupply) external {
|
|
98
|
-
totalSupplyAtSnapshot = totalSupply;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function setVotes(address account, uint256 votes) external {
|
|
102
|
-
votesAtSnapshot[account] = votes;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function getPastTotalSupply(uint256) external view returns (uint256) {
|
|
106
|
-
return totalSupplyAtSnapshot;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function getPastVotes(address account, uint256) external view returns (uint256) {
|
|
110
|
-
return votesAtSnapshot[account];
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
contract CodexNemesisHook {
|
|
115
|
-
CodexNemesisStore public immutable STORE;
|
|
116
|
-
CodexNemesisCheckpoints public immutable CHECKPOINTS;
|
|
117
|
-
|
|
118
|
-
mapping(uint256 tokenId => address owner) public owners;
|
|
119
|
-
|
|
120
|
-
constructor(CodexNemesisStore store, CodexNemesisCheckpoints checkpoints) {
|
|
121
|
-
STORE = store;
|
|
122
|
-
CHECKPOINTS = checkpoints;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function ownerOf(uint256 tokenId) external view returns (address) {
|
|
126
|
-
address owner = owners[tokenId];
|
|
127
|
-
require(owner != address(0), "NO_OWNER");
|
|
128
|
-
return owner;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function setOwner(uint256 tokenId, address owner) external {
|
|
132
|
-
owners[tokenId] = owner;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
contract CodexNemesisAccountingPoCTest is Test {
|
|
137
|
-
uint256 internal constant PROJECT_ID = 1;
|
|
138
|
-
uint256 internal constant ROUND_DURATION = 100;
|
|
139
|
-
uint256 internal constant VESTING_ROUNDS = 4;
|
|
140
|
-
|
|
141
|
-
address internal attacker = makeAddr("attacker");
|
|
142
|
-
address internal honest = makeAddr("honest");
|
|
143
|
-
address internal maliciousController = makeAddr("maliciousController");
|
|
144
|
-
|
|
145
|
-
CodexNemesisDirectory internal directory;
|
|
146
|
-
CodexNemesisRewardToken internal rewardToken;
|
|
147
|
-
|
|
148
|
-
function setUp() public {
|
|
149
|
-
directory = new CodexNemesisDirectory();
|
|
150
|
-
rewardToken = new CodexNemesisRewardToken();
|
|
151
|
-
directory.setController(PROJECT_ID, maliciousController);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function test_controllerCanCreditUndeliveredTokensAndDrainRealInventory() public {
|
|
155
|
-
JBTokenDistributor distributor =
|
|
156
|
-
new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
157
|
-
CodexNemesisVotesToken votesToken = new CodexNemesisVotesToken();
|
|
158
|
-
|
|
159
|
-
votesToken.mint(attacker, 10 ether);
|
|
160
|
-
votesToken.mint(honest, 990 ether);
|
|
161
|
-
|
|
162
|
-
vm.prank(attacker);
|
|
163
|
-
votesToken.delegate(attacker);
|
|
164
|
-
vm.prank(honest);
|
|
165
|
-
votesToken.delegate(honest);
|
|
166
|
-
vm.roll(block.number + 1);
|
|
167
|
-
|
|
168
|
-
rewardToken.mint(address(this), 1000 ether);
|
|
169
|
-
rewardToken.approve(address(distributor), 1000 ether);
|
|
170
|
-
distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
|
|
171
|
-
|
|
172
|
-
JBSplit memory split = JBSplit({
|
|
173
|
-
percent: 1_000_000_000,
|
|
174
|
-
projectId: 0,
|
|
175
|
-
beneficiary: payable(address(votesToken)),
|
|
176
|
-
preferAddToBalance: false,
|
|
177
|
-
lockedUntil: 0,
|
|
178
|
-
hook: IJBSplitHook(address(distributor))
|
|
179
|
-
});
|
|
180
|
-
JBSplitHookContext memory fakeContext = JBSplitHookContext({
|
|
181
|
-
token: address(rewardToken),
|
|
182
|
-
amount: 99_000 ether,
|
|
183
|
-
decimals: 18,
|
|
184
|
-
projectId: PROJECT_ID,
|
|
185
|
-
groupId: 0,
|
|
186
|
-
split: split
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
// C-6 FIX: The malicious controller did not actually transfer tokens, so the
|
|
190
|
-
// balance-delta check reverts with UnfundedSplitCredit.
|
|
191
|
-
vm.prank(maliciousController);
|
|
192
|
-
vm.expectRevert(JBDistributor.JBDistributor_UnfundedSplitCredit.selector);
|
|
193
|
-
distributor.processSplitWith(fakeContext);
|
|
194
|
-
|
|
195
|
-
// The real balance should remain intact — the attack was blocked.
|
|
196
|
-
assertEq(rewardToken.balanceOf(address(distributor)), 1000 ether, "balance unchanged after blocked attack");
|
|
197
|
-
assertEq(
|
|
198
|
-
distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
199
|
-
1000 ether,
|
|
200
|
-
"tracked balance was not inflated"
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function test_721LateMintCanUseOwnersPastVotesAndDrainRound() public {
|
|
205
|
-
JB721Distributor distributor =
|
|
206
|
-
new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
207
|
-
CodexNemesisStore store = new CodexNemesisStore();
|
|
208
|
-
CodexNemesisCheckpoints checkpoints = new CodexNemesisCheckpoints();
|
|
209
|
-
CodexNemesisHook hook = new CodexNemesisHook(store, checkpoints);
|
|
210
|
-
|
|
211
|
-
JB721TierFlags memory flags;
|
|
212
|
-
store.setMaxTierIdOf(1);
|
|
213
|
-
store.setTier({
|
|
214
|
-
tierId: 1,
|
|
215
|
-
tier: JB721Tier({
|
|
216
|
-
id: 1,
|
|
217
|
-
price: 1 ether,
|
|
218
|
-
remainingSupply: 97,
|
|
219
|
-
initialSupply: 100,
|
|
220
|
-
votingUnits: 100,
|
|
221
|
-
reserveFrequency: 0,
|
|
222
|
-
reserveBeneficiary: address(0),
|
|
223
|
-
encodedIPFSUri: bytes32(0),
|
|
224
|
-
category: 0,
|
|
225
|
-
discountPercent: 0,
|
|
226
|
-
flags: flags,
|
|
227
|
-
splitPercent: 0,
|
|
228
|
-
resolvedUri: ""
|
|
229
|
-
})
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
store.setTokenTier(1, 1);
|
|
233
|
-
store.setTokenTier(2, 1);
|
|
234
|
-
store.setTokenTier(3, 1);
|
|
235
|
-
hook.setOwner(1, attacker);
|
|
236
|
-
hook.setOwner(2, attacker);
|
|
237
|
-
hook.setOwner(3, attacker);
|
|
238
|
-
|
|
239
|
-
checkpoints.setTotalSupply(100);
|
|
240
|
-
checkpoints.setVotes(attacker, 100);
|
|
241
|
-
|
|
242
|
-
rewardToken.mint(address(this), 1000 ether);
|
|
243
|
-
rewardToken.approve(address(distributor), 1000 ether);
|
|
244
|
-
distributor.fund(address(hook), IERC20(address(rewardToken)), 1000 ether);
|
|
245
|
-
|
|
246
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
247
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
248
|
-
|
|
249
|
-
uint256[] memory firstLateMint = new uint256[](1);
|
|
250
|
-
firstLateMint[0] = 2;
|
|
251
|
-
distributor.beginVesting(address(hook), firstLateMint, tokens);
|
|
252
|
-
|
|
253
|
-
assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
|
|
254
|
-
assertEq(
|
|
255
|
-
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
256
|
-
distributor.balanceOf(address(hook), tokens[0]),
|
|
257
|
-
"one post-snapshot token consumed the whole snapshot"
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
|
|
261
|
-
vm.roll(block.number + 1);
|
|
262
|
-
vm.prank(attacker);
|
|
263
|
-
distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
|
|
264
|
-
|
|
265
|
-
assertEq(rewardToken.balanceOf(attacker), 1000 ether, "one late-minted token drained the funded balance");
|
|
266
|
-
assertEq(rewardToken.balanceOf(address(distributor)), 0, "honest snapshot stake is left unfunded");
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function test_721SnapshotVotesCanBeReusedAcrossSeparateLateMintClaims() public {
|
|
270
|
-
JB721Distributor distributor =
|
|
271
|
-
new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
272
|
-
CodexNemesisStore store = new CodexNemesisStore();
|
|
273
|
-
CodexNemesisCheckpoints checkpoints = new CodexNemesisCheckpoints();
|
|
274
|
-
CodexNemesisHook hook = new CodexNemesisHook(store, checkpoints);
|
|
275
|
-
|
|
276
|
-
JB721TierFlags memory flags;
|
|
277
|
-
store.setMaxTierIdOf(1);
|
|
278
|
-
store.setTier({
|
|
279
|
-
tierId: 1,
|
|
280
|
-
tier: JB721Tier({
|
|
281
|
-
id: 1,
|
|
282
|
-
price: 1 ether,
|
|
283
|
-
remainingSupply: 97,
|
|
284
|
-
initialSupply: 100,
|
|
285
|
-
votingUnits: 100,
|
|
286
|
-
reserveFrequency: 0,
|
|
287
|
-
reserveBeneficiary: address(0),
|
|
288
|
-
encodedIPFSUri: bytes32(0),
|
|
289
|
-
category: 0,
|
|
290
|
-
discountPercent: 0,
|
|
291
|
-
flags: flags,
|
|
292
|
-
splitPercent: 0,
|
|
293
|
-
resolvedUri: ""
|
|
294
|
-
})
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
store.setTokenTier(1, 1);
|
|
298
|
-
store.setTokenTier(2, 1);
|
|
299
|
-
store.setTokenTier(3, 1);
|
|
300
|
-
hook.setOwner(1, attacker);
|
|
301
|
-
hook.setOwner(2, attacker);
|
|
302
|
-
hook.setOwner(3, attacker);
|
|
303
|
-
|
|
304
|
-
checkpoints.setTotalSupply(100);
|
|
305
|
-
checkpoints.setVotes(attacker, 100);
|
|
306
|
-
|
|
307
|
-
rewardToken.mint(address(this), 1000 ether);
|
|
308
|
-
rewardToken.approve(address(distributor), 1000 ether);
|
|
309
|
-
distributor.fund(address(hook), IERC20(address(rewardToken)), 1000 ether);
|
|
310
|
-
|
|
311
|
-
IERC20[] memory tokens = new IERC20[](1);
|
|
312
|
-
tokens[0] = IERC20(address(rewardToken));
|
|
313
|
-
|
|
314
|
-
uint256[] memory firstLateMint = new uint256[](1);
|
|
315
|
-
firstLateMint[0] = 2;
|
|
316
|
-
distributor.beginVesting(address(hook), firstLateMint, tokens);
|
|
317
|
-
|
|
318
|
-
uint256[] memory secondLateMint = new uint256[](1);
|
|
319
|
-
secondLateMint[0] = 3;
|
|
320
|
-
distributor.beginVesting(address(hook), secondLateMint, tokens);
|
|
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.
|
|
324
|
-
assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
|
|
325
|
-
assertEq(distributor.claimedFor(address(hook), 3, tokens[0]), 0, "votes already consumed, no double-claim");
|
|
326
|
-
assertEq(
|
|
327
|
-
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
328
|
-
1000 ether,
|
|
329
|
-
"total vesting capped at funded balance"
|
|
330
|
-
);
|
|
331
|
-
assertLe(
|
|
332
|
-
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
333
|
-
distributor.balanceOf(address(hook), tokens[0]),
|
|
334
|
-
"vesting obligations do not exceed funded balance"
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
// Collection should succeed without underflow.
|
|
338
|
-
vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
|
|
339
|
-
vm.roll(block.number + 1);
|
|
340
|
-
vm.prank(attacker);
|
|
341
|
-
distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
|
|
342
|
-
assertEq(rewardToken.balanceOf(attacker), 1000 ether, "attacker gets only their fair share");
|
|
343
|
-
}
|
|
344
|
-
}
|
|
@@ -1,133 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,218 +0,0 @@
|
|
|
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
|
-
}
|