@bananapus/distributor-v6 0.0.4 → 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/.github/pull_request_template.md +1 -1
- package/.github/workflows/test.yml +2 -0
- package/USER_JOURNEYS.md +2 -0
- package/package.json +4 -4
- package/src/JB721Distributor.sol +32 -4
- package/src/JBDistributor.sol +17 -0
- package/src/JBTokenDistributor.sol +9 -4
- package/test/audit/CodexNemesisAccountingPoC.t.sol +339 -0
- package/test/audit/CodexNemesisPoC.t.sol +191 -0
- package/test/audit/Pass12Fixes.t.sol +344 -0
- package/test/fork/TokenDistributorFork.t.sol +603 -0
|
@@ -13,7 +13,7 @@ What new trust boundary, failure mode, operational dependency, or integration ca
|
|
|
13
13
|
- [ ] Updated `/v6/evm/RISKS.md` because ecosystem behavior changed
|
|
14
14
|
- [ ] If no `RISKS.md` update was needed, I explained why in this PR
|
|
15
15
|
|
|
16
|
-
Reference: [`/v6/evm/
|
|
16
|
+
Reference: [`/v6/evm/RISKS_MAINTENANCE.md`](../../RISKS_MAINTENANCE.md)
|
|
17
17
|
|
|
18
18
|
## Checklist
|
|
19
19
|
|
|
@@ -22,5 +22,7 @@ jobs:
|
|
|
22
22
|
uses: foundry-rs/foundry-toolchain@v1
|
|
23
23
|
- name: Run tests
|
|
24
24
|
run: forge test --fail-fast --summary --detailed --skip "*/script/**"
|
|
25
|
+
env:
|
|
26
|
+
RPC_ETHEREUM_MAINNET: ${{ secrets.RPC_ETHEREUM_MAINNET }}
|
|
25
27
|
- name: Check contract sizes
|
|
26
28
|
run: forge build --sizes --skip "*/test/**" --skip "*/script/**" --skip SphinxUtils
|
package/USER_JOURNEYS.md
CHANGED
|
@@ -55,6 +55,8 @@ This repo distributes already-owned assets over time. It snapshots stake, starts
|
|
|
55
55
|
2. The distributor snapshots the relevant balance and stake source.
|
|
56
56
|
3. Vesting entries become claimable over the configured schedule.
|
|
57
57
|
|
|
58
|
+
**Snapshot timing:** The snapshot for each round is taken when the previous round first sees activity (`poke`, `beginVesting`, or `collectVestedRewards`). To be included in round N's distribution, make sure your tokens are held and delegated before anyone interacts with round N-1. In practice, keep your delegation current — if it is set before the previous round's activity begins, your voting power will be counted for the next round.
|
|
59
|
+
|
|
58
60
|
**Failure Modes**
|
|
59
61
|
- zero total stake
|
|
60
62
|
- bad deployment parameters such as zero round duration or zero vesting rounds
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/distributor-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"deploy:testnets": "source ./.env && npx sphinx propose ./script/Deploy.s.sol --networks testnets"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@bananapus/721-hook-v6": "^0.0.
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
22
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
20
|
+
"@bananapus/721-hook-v6": "^0.0.38",
|
|
21
|
+
"@bananapus/core-v6": "^0.0.36",
|
|
22
|
+
"@bananapus/permission-ids-v6": "^0.0.19",
|
|
23
23
|
"@openzeppelin/contracts": "^5.6.1",
|
|
24
24
|
"@prb/math": "^4.1.0"
|
|
25
25
|
},
|
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
|
|
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
|
|
88
|
+
contract CodexNemesisCheckpoints {
|
|
89
|
+
uint256 public totalSupplyAtSnapshot;
|
|
90
|
+
mapping(address account => uint256 votes) public votesAtSnapshot;
|
|
91
|
+
|
|
92
|
+
function setTotalSupply(uint256 totalSupply) external {
|
|
93
|
+
totalSupplyAtSnapshot = totalSupply;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function setVotes(address account, uint256 votes) external {
|
|
97
|
+
votesAtSnapshot[account] = votes;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getPastTotalSupply(uint256) external view returns (uint256) {
|
|
101
|
+
return totalSupplyAtSnapshot;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getPastVotes(address account, uint256) external view returns (uint256) {
|
|
105
|
+
return votesAtSnapshot[account];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
contract CodexNemesisHook {
|
|
110
|
+
CodexNemesisStore public immutable STORE;
|
|
111
|
+
CodexNemesisCheckpoints public immutable CHECKPOINTS;
|
|
112
|
+
|
|
113
|
+
mapping(uint256 tokenId => address owner) public owners;
|
|
114
|
+
|
|
115
|
+
constructor(CodexNemesisStore store, CodexNemesisCheckpoints checkpoints) {
|
|
116
|
+
STORE = store;
|
|
117
|
+
CHECKPOINTS = checkpoints;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function ownerOf(uint256 tokenId) external view returns (address) {
|
|
121
|
+
address owner = owners[tokenId];
|
|
122
|
+
require(owner != address(0), "NO_OWNER");
|
|
123
|
+
return owner;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
127
|
+
owners[tokenId] = owner;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
contract CodexNemesisAccountingPoCTest is Test {
|
|
132
|
+
uint256 internal constant PROJECT_ID = 1;
|
|
133
|
+
uint256 internal constant ROUND_DURATION = 100;
|
|
134
|
+
uint256 internal constant VESTING_ROUNDS = 4;
|
|
135
|
+
|
|
136
|
+
address internal attacker = makeAddr("attacker");
|
|
137
|
+
address internal honest = makeAddr("honest");
|
|
138
|
+
address internal maliciousController = makeAddr("maliciousController");
|
|
139
|
+
|
|
140
|
+
CodexNemesisDirectory internal directory;
|
|
141
|
+
CodexNemesisRewardToken internal rewardToken;
|
|
142
|
+
|
|
143
|
+
function setUp() public {
|
|
144
|
+
directory = new CodexNemesisDirectory();
|
|
145
|
+
rewardToken = new CodexNemesisRewardToken();
|
|
146
|
+
directory.setController(PROJECT_ID, maliciousController);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function test_controllerCanCreditUndeliveredTokensAndDrainRealInventory() public {
|
|
150
|
+
JBTokenDistributor distributor =
|
|
151
|
+
new JBTokenDistributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
152
|
+
CodexNemesisVotesToken votesToken = new CodexNemesisVotesToken();
|
|
153
|
+
|
|
154
|
+
votesToken.mint(attacker, 10 ether);
|
|
155
|
+
votesToken.mint(honest, 990 ether);
|
|
156
|
+
|
|
157
|
+
vm.prank(attacker);
|
|
158
|
+
votesToken.delegate(attacker);
|
|
159
|
+
vm.prank(honest);
|
|
160
|
+
votesToken.delegate(honest);
|
|
161
|
+
vm.roll(block.number + 1);
|
|
162
|
+
|
|
163
|
+
rewardToken.mint(address(this), 1000 ether);
|
|
164
|
+
rewardToken.approve(address(distributor), 1000 ether);
|
|
165
|
+
distributor.fund(address(votesToken), IERC20(address(rewardToken)), 1000 ether);
|
|
166
|
+
|
|
167
|
+
JBSplit memory split = JBSplit({
|
|
168
|
+
percent: 1_000_000_000,
|
|
169
|
+
projectId: 0,
|
|
170
|
+
beneficiary: payable(address(votesToken)),
|
|
171
|
+
preferAddToBalance: false,
|
|
172
|
+
lockedUntil: 0,
|
|
173
|
+
hook: IJBSplitHook(address(distributor))
|
|
174
|
+
});
|
|
175
|
+
JBSplitHookContext memory fakeContext = JBSplitHookContext({
|
|
176
|
+
token: address(rewardToken),
|
|
177
|
+
amount: 99_000 ether,
|
|
178
|
+
decimals: 18,
|
|
179
|
+
projectId: PROJECT_ID,
|
|
180
|
+
groupId: 0,
|
|
181
|
+
split: split
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// C-6 FIX: The malicious controller did not actually transfer tokens, so the
|
|
185
|
+
// balance-delta check reverts with UnfundedSplitCredit.
|
|
186
|
+
vm.prank(maliciousController);
|
|
187
|
+
vm.expectRevert(JBDistributor.JBDistributor_UnfundedSplitCredit.selector);
|
|
188
|
+
distributor.processSplitWith(fakeContext);
|
|
189
|
+
|
|
190
|
+
// The real balance should remain intact — the attack was blocked.
|
|
191
|
+
assertEq(rewardToken.balanceOf(address(distributor)), 1000 ether, "balance unchanged after blocked attack");
|
|
192
|
+
assertEq(
|
|
193
|
+
distributor.balanceOf(address(votesToken), IERC20(address(rewardToken))),
|
|
194
|
+
1000 ether,
|
|
195
|
+
"tracked balance was not inflated"
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function test_721LateMintCanUseOwnersPastVotesAndDrainRound() public {
|
|
200
|
+
JB721Distributor distributor =
|
|
201
|
+
new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
202
|
+
CodexNemesisStore store = new CodexNemesisStore();
|
|
203
|
+
CodexNemesisCheckpoints checkpoints = new CodexNemesisCheckpoints();
|
|
204
|
+
CodexNemesisHook hook = new CodexNemesisHook(store, checkpoints);
|
|
205
|
+
|
|
206
|
+
JB721TierFlags memory flags;
|
|
207
|
+
store.setMaxTierIdOf(1);
|
|
208
|
+
store.setTier({
|
|
209
|
+
tierId: 1,
|
|
210
|
+
tier: JB721Tier({
|
|
211
|
+
id: 1,
|
|
212
|
+
price: 1 ether,
|
|
213
|
+
remainingSupply: 97,
|
|
214
|
+
initialSupply: 100,
|
|
215
|
+
votingUnits: 100,
|
|
216
|
+
reserveFrequency: 0,
|
|
217
|
+
reserveBeneficiary: address(0),
|
|
218
|
+
encodedIPFSUri: bytes32(0),
|
|
219
|
+
category: 0,
|
|
220
|
+
discountPercent: 0,
|
|
221
|
+
flags: flags,
|
|
222
|
+
splitPercent: 0,
|
|
223
|
+
resolvedUri: ""
|
|
224
|
+
})
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
store.setTokenTier(1, 1);
|
|
228
|
+
store.setTokenTier(2, 1);
|
|
229
|
+
store.setTokenTier(3, 1);
|
|
230
|
+
hook.setOwner(1, attacker);
|
|
231
|
+
hook.setOwner(2, attacker);
|
|
232
|
+
hook.setOwner(3, attacker);
|
|
233
|
+
|
|
234
|
+
checkpoints.setTotalSupply(100);
|
|
235
|
+
checkpoints.setVotes(attacker, 100);
|
|
236
|
+
|
|
237
|
+
rewardToken.mint(address(this), 1000 ether);
|
|
238
|
+
rewardToken.approve(address(distributor), 1000 ether);
|
|
239
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), 1000 ether);
|
|
240
|
+
|
|
241
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
242
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
243
|
+
|
|
244
|
+
uint256[] memory firstLateMint = new uint256[](1);
|
|
245
|
+
firstLateMint[0] = 2;
|
|
246
|
+
distributor.beginVesting(address(hook), firstLateMint, tokens);
|
|
247
|
+
|
|
248
|
+
assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
|
|
249
|
+
assertEq(
|
|
250
|
+
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
251
|
+
distributor.balanceOf(address(hook), tokens[0]),
|
|
252
|
+
"one post-snapshot token consumed the whole snapshot"
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
|
|
256
|
+
vm.roll(block.number + 1);
|
|
257
|
+
vm.prank(attacker);
|
|
258
|
+
distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
|
|
259
|
+
|
|
260
|
+
assertEq(rewardToken.balanceOf(attacker), 1000 ether, "one late-minted token drained the funded balance");
|
|
261
|
+
assertEq(rewardToken.balanceOf(address(distributor)), 0, "honest snapshot stake is left unfunded");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function test_721SnapshotVotesCanBeReusedAcrossSeparateLateMintClaims() public {
|
|
265
|
+
JB721Distributor distributor =
|
|
266
|
+
new JB721Distributor(IJBDirectory(address(directory)), ROUND_DURATION, VESTING_ROUNDS);
|
|
267
|
+
CodexNemesisStore store = new CodexNemesisStore();
|
|
268
|
+
CodexNemesisCheckpoints checkpoints = new CodexNemesisCheckpoints();
|
|
269
|
+
CodexNemesisHook hook = new CodexNemesisHook(store, checkpoints);
|
|
270
|
+
|
|
271
|
+
JB721TierFlags memory flags;
|
|
272
|
+
store.setMaxTierIdOf(1);
|
|
273
|
+
store.setTier({
|
|
274
|
+
tierId: 1,
|
|
275
|
+
tier: JB721Tier({
|
|
276
|
+
id: 1,
|
|
277
|
+
price: 1 ether,
|
|
278
|
+
remainingSupply: 97,
|
|
279
|
+
initialSupply: 100,
|
|
280
|
+
votingUnits: 100,
|
|
281
|
+
reserveFrequency: 0,
|
|
282
|
+
reserveBeneficiary: address(0),
|
|
283
|
+
encodedIPFSUri: bytes32(0),
|
|
284
|
+
category: 0,
|
|
285
|
+
discountPercent: 0,
|
|
286
|
+
flags: flags,
|
|
287
|
+
splitPercent: 0,
|
|
288
|
+
resolvedUri: ""
|
|
289
|
+
})
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
store.setTokenTier(1, 1);
|
|
293
|
+
store.setTokenTier(2, 1);
|
|
294
|
+
store.setTokenTier(3, 1);
|
|
295
|
+
hook.setOwner(1, attacker);
|
|
296
|
+
hook.setOwner(2, attacker);
|
|
297
|
+
hook.setOwner(3, attacker);
|
|
298
|
+
|
|
299
|
+
checkpoints.setTotalSupply(100);
|
|
300
|
+
checkpoints.setVotes(attacker, 100);
|
|
301
|
+
|
|
302
|
+
rewardToken.mint(address(this), 1000 ether);
|
|
303
|
+
rewardToken.approve(address(distributor), 1000 ether);
|
|
304
|
+
distributor.fund(address(hook), IERC20(address(rewardToken)), 1000 ether);
|
|
305
|
+
|
|
306
|
+
IERC20[] memory tokens = new IERC20[](1);
|
|
307
|
+
tokens[0] = IERC20(address(rewardToken));
|
|
308
|
+
|
|
309
|
+
uint256[] memory firstLateMint = new uint256[](1);
|
|
310
|
+
firstLateMint[0] = 2;
|
|
311
|
+
distributor.beginVesting(address(hook), firstLateMint, tokens);
|
|
312
|
+
|
|
313
|
+
uint256[] memory secondLateMint = new uint256[](1);
|
|
314
|
+
secondLateMint[0] = 3;
|
|
315
|
+
distributor.beginVesting(address(hook), secondLateMint, tokens);
|
|
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.
|
|
319
|
+
assertEq(distributor.claimedFor(address(hook), 2, tokens[0]), 1000 ether);
|
|
320
|
+
assertEq(distributor.claimedFor(address(hook), 3, tokens[0]), 0, "votes already consumed, no double-claim");
|
|
321
|
+
assertEq(
|
|
322
|
+
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
323
|
+
1000 ether,
|
|
324
|
+
"total vesting capped at funded balance"
|
|
325
|
+
);
|
|
326
|
+
assertLe(
|
|
327
|
+
distributor.totalVestingAmountOf(address(hook), tokens[0]),
|
|
328
|
+
distributor.balanceOf(address(hook), tokens[0]),
|
|
329
|
+
"vesting obligations do not exceed funded balance"
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
// Collection should succeed without underflow.
|
|
333
|
+
vm.warp(distributor.roundStartTimestamp(VESTING_ROUNDS) + 1);
|
|
334
|
+
vm.roll(block.number + 1);
|
|
335
|
+
vm.prank(attacker);
|
|
336
|
+
distributor.collectVestedRewards(address(hook), firstLateMint, tokens, attacker);
|
|
337
|
+
assertEq(rewardToken.balanceOf(attacker), 1000 ether, "attacker gets only their fair share");
|
|
338
|
+
}
|
|
339
|
+
}
|