@bananapus/distributor-v6 0.0.10 → 0.0.12
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 +2 -2
- package/references/operations.md +2 -2
- package/references/runtime.md +2 -7
- package/src/JB721Distributor.sol +23 -29
- package/src/JBDistributor.sol +96 -50
- package/src/JBTokenDistributor.sol +18 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/distributor-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.12",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@bananapus/721-hook-v6": "0.0.43",
|
|
30
|
-
"@bananapus/core-v6": "0.0.
|
|
30
|
+
"@bananapus/core-v6": "0.0.43",
|
|
31
31
|
"@bananapus/permission-ids-v6": "0.0.22",
|
|
32
32
|
"@openzeppelin/contracts": "5.6.1",
|
|
33
33
|
"@prb/math": "4.1.1"
|
package/references/operations.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
| `JBDistributor` vesting math | Claim totals, `totalVestingAmountOf`, and pool balances still reconcile across rounds |
|
|
8
8
|
| `JBTokenDistributor` checkpoint logic | `getPastVotes` and `getPastTotalSupply` are read at the intended round-start block |
|
|
9
9
|
| `JB721Distributor` stake math | Minted, remaining, and burned supply still produce the intended tier-weighted total stake |
|
|
10
|
-
| `processSplitWith` |
|
|
10
|
+
| `processSplitWith` | Allowance-based `transferFrom` flow preserves actual received balances |
|
|
11
11
|
| Deployment inputs | `DIRECTORY_ADDRESS`, `ROUND_DURATION`, and `VESTING_ROUNDS` match the intended chain and operator plan |
|
|
12
12
|
|
|
13
13
|
## Common failure modes
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
| A holder gets no rewards in the token distributor | They never delegated, so `getPastVotes` returned zero |
|
|
18
18
|
| Rewards appear stuck in the distributor | Supply was undelegated, vesting never began for the target token IDs, or the round boundary assumption is wrong |
|
|
19
19
|
| 721 reward shares look diluted | Burned supply was not excluded correctly or token-to-tier mapping is wrong |
|
|
20
|
-
| Split-hook funding credits the wrong amount | The caller
|
|
20
|
+
| Split-hook funding credits the wrong amount | The caller did not grant a sufficient ERC-20 allowance before calling `processSplitWith` |
|
|
21
21
|
|
|
22
22
|
## Read Next
|
|
23
23
|
|
package/references/runtime.md
CHANGED
|
@@ -14,14 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
The token distributor depends on checkpointed voting power at the round start block. Holders must delegate for `getPastVotes` to count them, and undelegated supply can leave rewards stranded in the pool for later rounds.
|
|
16
16
|
|
|
17
|
-
### Funding path
|
|
17
|
+
### Funding path
|
|
18
18
|
|
|
19
|
-
`processSplitWith`
|
|
20
|
-
|
|
21
|
-
- Terminal path: pull tokens via allowance and credit the actual received amount.
|
|
22
|
-
- Controller path: assume tokens were transferred before the hook call and credit `context.amount`.
|
|
23
|
-
|
|
24
|
-
Mixing these assumptions causes under- or over-accounting.
|
|
19
|
+
`processSplitWith` uses a single funding pattern: the caller grants an ERC-20 allowance and `processSplitWith` pulls tokens via `transferFrom`, crediting the actual received amount.
|
|
25
20
|
|
|
26
21
|
### 721 burned-token behavior
|
|
27
22
|
|
package/src/JB721Distributor.sol
CHANGED
|
@@ -34,10 +34,10 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
34
34
|
//*********************************************************************//
|
|
35
35
|
|
|
36
36
|
/// @notice Thrown when native ETH is sent but context.token is not NATIVE_TOKEN.
|
|
37
|
-
error JB721Distributor_TokenMismatch();
|
|
37
|
+
error JB721Distributor_TokenMismatch(address token, address expectedToken, uint256 msgValue);
|
|
38
38
|
|
|
39
39
|
/// @notice Thrown when the caller is not a terminal or controller for the project.
|
|
40
|
-
error JB721Distributor_Unauthorized();
|
|
40
|
+
error JB721Distributor_Unauthorized(uint256 projectId, address caller);
|
|
41
41
|
|
|
42
42
|
//*********************************************************************//
|
|
43
43
|
// ----------------------------- structs ----------------------------- //
|
|
@@ -103,8 +103,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
103
103
|
/// @notice Receives tokens from a Juicebox payout split.
|
|
104
104
|
/// @dev Only callable by a terminal or controller for the project in the context.
|
|
105
105
|
/// @dev The hook address is read from `context.split.beneficiary`.
|
|
106
|
-
/// @dev
|
|
107
|
-
/// The controller sends tokens directly before calling — nothing to pull.
|
|
106
|
+
/// @dev Both terminals and controllers grant an ERC-20 allowance before calling — we pull via `transferFrom`.
|
|
108
107
|
/// For native ETH, the terminal sends the amount as `msg.value`.
|
|
109
108
|
/// @param context The split hook context from the terminal or controller.
|
|
110
109
|
function processSplitWith(JBSplitHookContext calldata context) external payable override {
|
|
@@ -112,33 +111,27 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
112
111
|
if (
|
|
113
112
|
!DIRECTORY.isTerminalOf(context.projectId, IJBTerminal(msg.sender))
|
|
114
113
|
&& DIRECTORY.controllerOf(context.projectId) != IERC165(msg.sender)
|
|
115
|
-
) revert JB721Distributor_Unauthorized();
|
|
114
|
+
) revert JB721Distributor_Unauthorized({projectId: context.projectId, caller: msg.sender});
|
|
116
115
|
|
|
117
116
|
// The target hook is the split's beneficiary.
|
|
118
117
|
address hook = address(context.split.beneficiary);
|
|
119
118
|
|
|
120
119
|
// If it's not a native-token transfer, credit the ERC-20 amount.
|
|
121
120
|
if (msg.value == 0 && context.amount != 0) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
_balanceOf[hook][IERC20(context.token)] += delta;
|
|
130
|
-
_accountedBalanceOf[IERC20(context.token)] += delta;
|
|
131
|
-
} else {
|
|
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;
|
|
137
|
-
_balanceOf[hook][IERC20(context.token)] += context.amount;
|
|
138
|
-
}
|
|
121
|
+
// Pull tokens via transferFrom. Both terminals and controllers grant an ERC-20
|
|
122
|
+
// allowance before calling. Balance delta handles fee-on-transfer tokens correctly.
|
|
123
|
+
uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
|
|
124
|
+
IERC20(context.token).safeTransferFrom({from: msg.sender, to: address(this), value: context.amount});
|
|
125
|
+
uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
|
|
126
|
+
_balanceOf[hook][IERC20(context.token)] += delta;
|
|
127
|
+
_accountedBalanceOf[IERC20(context.token)] += delta;
|
|
139
128
|
} else if (msg.value != 0) {
|
|
140
129
|
// Validate that context.token matches NATIVE_TOKEN to prevent cross-booking attacks.
|
|
141
|
-
if (context.token != JBConstants.NATIVE_TOKEN)
|
|
130
|
+
if (context.token != JBConstants.NATIVE_TOKEN) {
|
|
131
|
+
revert JB721Distributor_TokenMismatch({
|
|
132
|
+
token: context.token, expectedToken: JBConstants.NATIVE_TOKEN, msgValue: msg.value
|
|
133
|
+
});
|
|
134
|
+
}
|
|
142
135
|
// Native ETH: credit actual value received.
|
|
143
136
|
_balanceOf[hook][IERC20(context.token)] += msg.value;
|
|
144
137
|
_accountedBalanceOf[IERC20(context.token)] += msg.value;
|
|
@@ -246,7 +239,6 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
246
239
|
/// @param tokenId The tokenId to check.
|
|
247
240
|
/// @return tokenWasBurned True if the token was burned.
|
|
248
241
|
function _tokenBurned(address hook, uint256 tokenId) internal view override returns (bool tokenWasBurned) {
|
|
249
|
-
// slither-disable-next-line unused-return
|
|
250
242
|
try IERC721(hook).ownerOf(tokenId) returns (address) {
|
|
251
243
|
tokenWasBurned = false;
|
|
252
244
|
} catch {
|
|
@@ -278,7 +270,6 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
278
270
|
.getPastVotes({account: owner, timepoint: snapshotBlock});
|
|
279
271
|
|
|
280
272
|
// If the owner had no voting power at round start, the token is ineligible.
|
|
281
|
-
// slither-disable-next-line incorrect-equality
|
|
282
273
|
if (pastVotes == 0) return 0;
|
|
283
274
|
|
|
284
275
|
// Cap at the token's tier voting units — the owner's past votes may cover multiple tokens,
|
|
@@ -344,7 +335,6 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
344
335
|
/// @param uniqueCount The number of distinct owners seen so far in the batch.
|
|
345
336
|
/// @return tokenAmount The reward amount vested for this token ID (0 if skipped).
|
|
346
337
|
/// @return newUniqueCount The updated count of distinct owners after processing this token ID.
|
|
347
|
-
// slither-disable-next-line incorrect-equality
|
|
348
338
|
function _vestSingleToken(
|
|
349
339
|
VestContext memory ctx,
|
|
350
340
|
uint256 tokenId,
|
|
@@ -396,7 +386,6 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
396
386
|
.getPastVotes({account: owner, timepoint: snapshotBlock});
|
|
397
387
|
|
|
398
388
|
// If the snapshot owner had no voting power at round start, the token is ineligible for this round.
|
|
399
|
-
// slither-disable-next-line incorrect-equality
|
|
400
389
|
if (pastVotes == 0) return (0, newUniqueCount);
|
|
401
390
|
|
|
402
391
|
// Search the owners array for an existing slot belonging to this owner.
|
|
@@ -439,7 +428,6 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
439
428
|
consumed[ownerIndex] += stake;
|
|
440
429
|
|
|
441
430
|
// If the effective stake is zero, the owner's budget is exhausted — skip this token.
|
|
442
|
-
// slither-disable-next-line incorrect-equality
|
|
443
431
|
if (stake == 0) return (0, newUniqueCount);
|
|
444
432
|
|
|
445
433
|
// Calculate the pro-rata reward amount: (distributable * stake) / totalStakeAmount.
|
|
@@ -453,7 +441,13 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
453
441
|
);
|
|
454
442
|
|
|
455
443
|
// Emit the claim event for off-chain indexers.
|
|
456
|
-
emit Claimed(
|
|
444
|
+
emit Claimed({
|
|
445
|
+
hook: ctx.hook,
|
|
446
|
+
tokenId: tokenId,
|
|
447
|
+
token: ctx.token,
|
|
448
|
+
amount: tokenAmount,
|
|
449
|
+
vestingReleaseRound: ctx.vestingReleaseRound
|
|
450
|
+
});
|
|
457
451
|
}
|
|
458
452
|
}
|
|
459
453
|
}
|
package/src/JBDistributor.sol
CHANGED
|
@@ -25,25 +25,22 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
25
25
|
//*********************************************************************//
|
|
26
26
|
|
|
27
27
|
/// @notice Thrown when an empty tokenIds array is passed.
|
|
28
|
-
error JBDistributor_EmptyTokenIds();
|
|
28
|
+
error JBDistributor_EmptyTokenIds(uint256 tokenIdCount);
|
|
29
29
|
|
|
30
30
|
/// @notice Thrown when a native ETH transfer fails.
|
|
31
|
-
error JBDistributor_NativeTransferFailed();
|
|
31
|
+
error JBDistributor_NativeTransferFailed(address beneficiary, uint256 amount);
|
|
32
32
|
|
|
33
33
|
/// @notice Thrown when the caller does not have access to the token.
|
|
34
|
-
error JBDistributor_NoAccess();
|
|
34
|
+
error JBDistributor_NoAccess(address hook, uint256 tokenId, address account);
|
|
35
35
|
|
|
36
36
|
/// @notice Thrown when the round duration is zero.
|
|
37
|
-
error JBDistributor_InvalidRoundDuration();
|
|
37
|
+
error JBDistributor_InvalidRoundDuration(uint256 roundDuration);
|
|
38
38
|
|
|
39
39
|
/// @notice Thrown when there is nothing to distribute for a token in the current round.
|
|
40
|
-
error JBDistributor_NothingToDistribute();
|
|
41
|
-
|
|
42
|
-
/// @notice Thrown when a controller-prepaid split credit is not backed by actual token balance.
|
|
43
|
-
error JBDistributor_UnfundedSplitCredit();
|
|
40
|
+
error JBDistributor_NothingToDistribute(address hook, address token, uint256 round);
|
|
44
41
|
|
|
45
42
|
/// @notice Thrown when unexpected native ETH is sent with an ERC-20 operation.
|
|
46
|
-
error JBDistributor_UnexpectedNativeValue();
|
|
43
|
+
error JBDistributor_UnexpectedNativeValue(uint256 msgValue, address token);
|
|
47
44
|
|
|
48
45
|
//*********************************************************************//
|
|
49
46
|
// ------------------------- public constants ------------------------ //
|
|
@@ -88,7 +85,6 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
88
85
|
/// @custom:param hook The hook the tokenId belongs to.
|
|
89
86
|
/// @custom:param tokenId The ID of the token to which the vests belong.
|
|
90
87
|
/// @custom:param token The address of the token vested.
|
|
91
|
-
// slither-disable-next-line uninitialized-state
|
|
92
88
|
mapping(address hook => mapping(uint256 tokenId => mapping(IERC20 token => JBVestingData[]))) public vestingDataOf;
|
|
93
89
|
|
|
94
90
|
//*********************************************************************//
|
|
@@ -118,7 +114,7 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
118
114
|
/// @param roundDuration_ The duration of each round, specified in seconds.
|
|
119
115
|
/// @param vestingRounds_ The number of rounds until tokens are fully vested.
|
|
120
116
|
constructor(uint256 roundDuration_, uint256 vestingRounds_) {
|
|
121
|
-
if (roundDuration_ == 0) revert JBDistributor_InvalidRoundDuration();
|
|
117
|
+
if (roundDuration_ == 0) revert JBDistributor_InvalidRoundDuration({roundDuration: roundDuration_});
|
|
122
118
|
startingTimestamp = block.timestamp;
|
|
123
119
|
roundDuration = roundDuration_;
|
|
124
120
|
vestingRounds = vestingRounds_;
|
|
@@ -136,7 +132,7 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
136
132
|
/// @param tokens The reward tokens to begin vesting.
|
|
137
133
|
function beginVesting(address hook, uint256[] calldata tokenIds, IERC20[] calldata tokens) external override {
|
|
138
134
|
// Revert if no token IDs are provided.
|
|
139
|
-
if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds();
|
|
135
|
+
if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
|
|
140
136
|
|
|
141
137
|
// Keep a reference to the current round.
|
|
142
138
|
uint256 round = currentRound();
|
|
@@ -145,10 +141,9 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
145
141
|
_ensureSnapshotBlock(round);
|
|
146
142
|
|
|
147
143
|
// Keep a reference to the total staked amount at the snapshot block.
|
|
148
|
-
uint256 totalStakeAmount = _totalStake(hook, roundSnapshotBlock[round]);
|
|
144
|
+
uint256 totalStakeAmount = _totalStake({hook: hook, blockNumber: roundSnapshotBlock[round]});
|
|
149
145
|
|
|
150
146
|
// Skip vesting when there are no stakers — funds carry over to the next round.
|
|
151
|
-
// slither-disable-next-line incorrect-equality
|
|
152
147
|
if (totalStakeAmount == 0) return;
|
|
153
148
|
|
|
154
149
|
// Loop through each token for which vesting is beginning.
|
|
@@ -156,15 +151,23 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
156
151
|
IERC20 token = tokens[i];
|
|
157
152
|
|
|
158
153
|
// Take a snapshot of the token balance if it hasn't been taken already.
|
|
159
|
-
JBTokenSnapshotData memory snapshot = _takeSnapshotOf(hook, token);
|
|
154
|
+
JBTokenSnapshotData memory snapshot = _takeSnapshotOf({hook: hook, token: token});
|
|
160
155
|
uint256 distributable = snapshot.balance - snapshot.vestingAmount;
|
|
161
156
|
|
|
162
157
|
// Revert if there is nothing to distribute for this token.
|
|
163
|
-
if (distributable == 0)
|
|
158
|
+
if (distributable == 0) {
|
|
159
|
+
revert JBDistributor_NothingToDistribute({hook: hook, token: address(token), round: round});
|
|
160
|
+
}
|
|
164
161
|
|
|
165
162
|
// Vest each token ID and get the total amount vested.
|
|
166
|
-
uint256 totalVestingAmount =
|
|
167
|
-
|
|
163
|
+
uint256 totalVestingAmount = _vestTokenIds({
|
|
164
|
+
hook: hook,
|
|
165
|
+
tokenIds: tokenIds,
|
|
166
|
+
token: token,
|
|
167
|
+
distributable: distributable,
|
|
168
|
+
totalStakeAmount: totalStakeAmount,
|
|
169
|
+
vestingReleaseRound: round + vestingRounds
|
|
170
|
+
});
|
|
168
171
|
|
|
169
172
|
unchecked {
|
|
170
173
|
// Store the updated total claimed amount now vesting.
|
|
@@ -186,7 +189,9 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
186
189
|
if (address(token) == JBConstants.NATIVE_TOKEN) {
|
|
187
190
|
amount = msg.value;
|
|
188
191
|
} else {
|
|
189
|
-
if (msg.value != 0)
|
|
192
|
+
if (msg.value != 0) {
|
|
193
|
+
revert JBDistributor_UnexpectedNativeValue({msgValue: msg.value, token: address(token)});
|
|
194
|
+
}
|
|
190
195
|
// Use balance delta to handle fee-on-transfer tokens correctly.
|
|
191
196
|
uint256 balanceBefore = token.balanceOf(address(this));
|
|
192
197
|
token.safeTransferFrom(msg.sender, address(this), amount);
|
|
@@ -220,14 +225,16 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
220
225
|
{
|
|
221
226
|
// Make sure that all tokens are burned.
|
|
222
227
|
for (uint256 i; i < tokenIds.length;) {
|
|
223
|
-
if (!_tokenBurned(hook, tokenIds[i]))
|
|
228
|
+
if (!_tokenBurned({hook: hook, tokenId: tokenIds[i]})) {
|
|
229
|
+
revert JBDistributor_NoAccess({hook: hook, tokenId: tokenIds[i], account: msg.sender});
|
|
230
|
+
}
|
|
224
231
|
unchecked {
|
|
225
232
|
++i;
|
|
226
233
|
}
|
|
227
234
|
}
|
|
228
235
|
|
|
229
236
|
// Unlock the rewards and send them to the beneficiary.
|
|
230
|
-
_unlockRewards(hook, tokenIds, tokens, beneficiary, false);
|
|
237
|
+
_unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: false});
|
|
231
238
|
}
|
|
232
239
|
|
|
233
240
|
//*********************************************************************//
|
|
@@ -267,7 +274,8 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
267
274
|
// Keep a reference to the vested data being iterated on.
|
|
268
275
|
JBVestingData memory vesting = vestingDataOf[hook][tokenId][token][vestedIndex];
|
|
269
276
|
|
|
270
|
-
|
|
277
|
+
// Use `original - alreadyPaid` to include rounding dust in the remaining amount.
|
|
278
|
+
tokenAmount += vesting.amount - mulDiv(vesting.amount, vesting.shareClaimed, MAX_SHARE);
|
|
271
279
|
|
|
272
280
|
unchecked {
|
|
273
281
|
++vestedIndex;
|
|
@@ -311,7 +319,15 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
311
319
|
lockedShare = (vesting.releaseRound - round) * MAX_SHARE / vestingRounds;
|
|
312
320
|
}
|
|
313
321
|
|
|
314
|
-
|
|
322
|
+
if (lockedShare == 0 && vesting.shareClaimed < MAX_SHARE) {
|
|
323
|
+
// Final unlock: compute remaining as `original - alreadyPaid` to include dust.
|
|
324
|
+
tokenAmount += vesting.amount - mulDiv(vesting.amount, vesting.shareClaimed, MAX_SHARE);
|
|
325
|
+
} else {
|
|
326
|
+
uint256 newShareClaimed = MAX_SHARE - lockedShare;
|
|
327
|
+
if (newShareClaimed > vesting.shareClaimed) {
|
|
328
|
+
tokenAmount += mulDiv(vesting.amount, newShareClaimed - vesting.shareClaimed, MAX_SHARE);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
315
331
|
|
|
316
332
|
unchecked {
|
|
317
333
|
++vestedIndex;
|
|
@@ -372,11 +388,13 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
372
388
|
override
|
|
373
389
|
{
|
|
374
390
|
// Revert if no token IDs are provided.
|
|
375
|
-
if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds();
|
|
391
|
+
if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
|
|
376
392
|
|
|
377
393
|
// Make sure that all tokens can be claimed by this sender.
|
|
378
394
|
for (uint256 i; i < tokenIds.length;) {
|
|
379
|
-
if (!_canClaim(hook, tokenIds[i], msg.sender))
|
|
395
|
+
if (!_canClaim({hook: hook, tokenId: tokenIds[i], account: msg.sender})) {
|
|
396
|
+
revert JBDistributor_NoAccess({hook: hook, tokenId: tokenIds[i], account: msg.sender});
|
|
397
|
+
}
|
|
380
398
|
unchecked {
|
|
381
399
|
++i;
|
|
382
400
|
}
|
|
@@ -389,20 +407,26 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
389
407
|
_ensureSnapshotBlock(round);
|
|
390
408
|
|
|
391
409
|
// Keep a reference to the total staked amount at the snapshot block.
|
|
392
|
-
uint256 totalStakeAmount = _totalStake(hook, roundSnapshotBlock[round]);
|
|
410
|
+
uint256 totalStakeAmount = _totalStake({hook: hook, blockNumber: roundSnapshotBlock[round]});
|
|
393
411
|
|
|
394
412
|
// Loop through each token and auto-vest if there's something distributable.
|
|
395
413
|
for (uint256 i; i < tokens.length;) {
|
|
396
414
|
IERC20 token = tokens[i];
|
|
397
415
|
|
|
398
416
|
// Take a snapshot of the token balance if it hasn't been taken already.
|
|
399
|
-
JBTokenSnapshotData memory snapshot = _takeSnapshotOf(hook, token);
|
|
417
|
+
JBTokenSnapshotData memory snapshot = _takeSnapshotOf({hook: hook, token: token});
|
|
400
418
|
uint256 distributable = snapshot.balance - snapshot.vestingAmount;
|
|
401
419
|
|
|
402
420
|
// Only auto-vest if there's something to distribute and there's stake.
|
|
403
421
|
if (distributable > 0 && totalStakeAmount > 0) {
|
|
404
|
-
uint256 totalVestingAmount =
|
|
405
|
-
|
|
422
|
+
uint256 totalVestingAmount = _vestTokenIds({
|
|
423
|
+
hook: hook,
|
|
424
|
+
tokenIds: tokenIds,
|
|
425
|
+
token: token,
|
|
426
|
+
distributable: distributable,
|
|
427
|
+
totalStakeAmount: totalStakeAmount,
|
|
428
|
+
vestingReleaseRound: round + vestingRounds
|
|
429
|
+
});
|
|
406
430
|
|
|
407
431
|
unchecked {
|
|
408
432
|
totalVestingAmountOf[hook][token] += totalVestingAmount;
|
|
@@ -415,7 +439,7 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
415
439
|
}
|
|
416
440
|
|
|
417
441
|
// Unlock the rewards and send them to the beneficiary.
|
|
418
|
-
_unlockRewards(hook, tokenIds, tokens, beneficiary, true);
|
|
442
|
+
_unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: true});
|
|
419
443
|
}
|
|
420
444
|
|
|
421
445
|
//*********************************************************************//
|
|
@@ -426,16 +450,14 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
426
450
|
/// @dev Uses `block.number - 1` because `IVotes.getPastVotes` requires a strictly past block.
|
|
427
451
|
/// @param round The round to ensure a snapshot block for.
|
|
428
452
|
function _ensureSnapshotBlock(uint256 round) internal {
|
|
429
|
-
// slither-disable-next-line incorrect-equality
|
|
430
453
|
if (roundSnapshotBlock[round] == 0) {
|
|
431
454
|
roundSnapshotBlock[round] = block.number - 1;
|
|
432
|
-
emit RoundSnapshotRecorded(round, block.number - 1);
|
|
455
|
+
emit RoundSnapshotRecorded({round: round, snapshotBlock: block.number - 1});
|
|
433
456
|
}
|
|
434
457
|
// Eagerly lock the next round's snapshot to prevent first-caller manipulation.
|
|
435
|
-
// slither-disable-next-line incorrect-equality
|
|
436
458
|
if (roundSnapshotBlock[round + 1] == 0) {
|
|
437
459
|
roundSnapshotBlock[round + 1] = block.number - 1;
|
|
438
|
-
emit RoundSnapshotRecorded(round + 1, block.number - 1);
|
|
460
|
+
emit RoundSnapshotRecorded({round: round + 1, snapshotBlock: block.number - 1});
|
|
439
461
|
}
|
|
440
462
|
}
|
|
441
463
|
|
|
@@ -460,7 +482,9 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
460
482
|
// Store the snapshot.
|
|
461
483
|
_snapshotAtRoundOf[hook][token][round] = snapshot;
|
|
462
484
|
|
|
463
|
-
emit SnapshotCreated(
|
|
485
|
+
emit SnapshotCreated({
|
|
486
|
+
hook: hook, round: round, token: token, balance: snapshot.balance, vestingAmount: snapshot.vestingAmount
|
|
487
|
+
});
|
|
464
488
|
}
|
|
465
489
|
|
|
466
490
|
/// @notice Unlocks rewards for the given token IDs and tokens, either for collection or forfeiture.
|
|
@@ -485,7 +509,7 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
485
509
|
IERC20 token = tokens[i];
|
|
486
510
|
|
|
487
511
|
// Process all token IDs for this reward token.
|
|
488
|
-
uint256 totalTokenAmount = _unlockTokenIds(hook, tokenIds, token, round);
|
|
512
|
+
uint256 totalTokenAmount = _unlockTokenIds({hook: hook, tokenIds: tokenIds, token: token, round: round});
|
|
489
513
|
|
|
490
514
|
// Perform the transfer.
|
|
491
515
|
if (totalTokenAmount != 0) {
|
|
@@ -501,9 +525,12 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
501
525
|
_accountedBalanceOf[token] -= totalTokenAmount;
|
|
502
526
|
|
|
503
527
|
if (address(token) == JBConstants.NATIVE_TOKEN) {
|
|
504
|
-
// slither-disable-next-line arbitrary-send-eth,reentrancy-eth
|
|
505
528
|
(bool success,) = beneficiary.call{value: totalTokenAmount}("");
|
|
506
|
-
if (!success)
|
|
529
|
+
if (!success) {
|
|
530
|
+
revert JBDistributor_NativeTransferFailed({
|
|
531
|
+
beneficiary: beneficiary, amount: totalTokenAmount
|
|
532
|
+
});
|
|
533
|
+
}
|
|
507
534
|
} else {
|
|
508
535
|
token.safeTransfer(beneficiary, totalTokenAmount);
|
|
509
536
|
}
|
|
@@ -547,32 +574,46 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
547
574
|
uint256 newLatestVestedIndex = vestedIndex;
|
|
548
575
|
|
|
549
576
|
while (vestedIndex < numberOfVestingRounds) {
|
|
550
|
-
uint256 lockedShare;
|
|
551
|
-
|
|
552
577
|
// Keep a reference to the vested data being iterated on.
|
|
553
578
|
JBVestingData memory vesting = vestings[vestedIndex];
|
|
554
579
|
|
|
555
580
|
// Calculate the share amount that is locked.
|
|
581
|
+
uint256 lockedShare;
|
|
556
582
|
if (vesting.releaseRound > round) {
|
|
557
583
|
lockedShare = (vesting.releaseRound - round) * MAX_SHARE / vestingRounds;
|
|
558
584
|
}
|
|
559
585
|
|
|
560
|
-
uint256 claimAmount
|
|
586
|
+
uint256 claimAmount;
|
|
561
587
|
|
|
562
|
-
|
|
563
|
-
|
|
588
|
+
if (lockedShare == 0 && vesting.shareClaimed < MAX_SHARE) {
|
|
589
|
+
// Final unlock: compute remaining amount as `original - alreadyPaid` to force
|
|
590
|
+
// rounding dust out so nothing is stranded in the entry.
|
|
591
|
+
claimAmount = vesting.amount - mulDiv(vesting.amount, vesting.shareClaimed, MAX_SHARE);
|
|
592
|
+
} else if (MAX_SHARE - lockedShare > vesting.shareClaimed) {
|
|
593
|
+
claimAmount = mulDiv(vesting.amount, MAX_SHARE - lockedShare - vesting.shareClaimed, MAX_SHARE);
|
|
594
|
+
}
|
|
564
595
|
|
|
565
596
|
if (claimAmount != 0) {
|
|
597
|
+
vestings[vestedIndex].shareClaimed = MAX_SHARE - lockedShare;
|
|
566
598
|
totalTokenAmount += claimAmount;
|
|
567
|
-
emit Collected(
|
|
599
|
+
emit Collected({
|
|
600
|
+
hook: hook,
|
|
601
|
+
tokenId: tokenId,
|
|
602
|
+
token: token,
|
|
603
|
+
amount: claimAmount,
|
|
604
|
+
vestingReleaseRound: vesting.releaseRound
|
|
605
|
+
});
|
|
568
606
|
}
|
|
569
607
|
|
|
570
608
|
unchecked {
|
|
571
609
|
++vestedIndex;
|
|
572
610
|
|
|
573
611
|
// Only advance the latest-vested index contiguously past fully exhausted entries.
|
|
574
|
-
//
|
|
575
|
-
if (
|
|
612
|
+
// An entry is exhausted only when its entire share has been claimed (lockedShare == 0).
|
|
613
|
+
if (
|
|
614
|
+
lockedShare == 0 && vestings[vestedIndex - 1].shareClaimed == MAX_SHARE
|
|
615
|
+
&& vestedIndex == newLatestVestedIndex + 1
|
|
616
|
+
) {
|
|
576
617
|
++newLatestVestedIndex;
|
|
577
618
|
}
|
|
578
619
|
}
|
|
@@ -611,7 +652,7 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
611
652
|
uint256 tokenId = tokenIds[j];
|
|
612
653
|
|
|
613
654
|
// Skip burned tokens — they are excluded from _totalStake, so including them would overbook vesting.
|
|
614
|
-
if (_tokenBurned(hook, tokenId)) {
|
|
655
|
+
if (_tokenBurned({hook: hook, tokenId: tokenId})) {
|
|
615
656
|
unchecked {
|
|
616
657
|
++j;
|
|
617
658
|
}
|
|
@@ -623,7 +664,6 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
623
664
|
|
|
624
665
|
// Skip if this token has already been vested for this round (same releaseRound).
|
|
625
666
|
uint256 numVesting = vestings.length;
|
|
626
|
-
// slither-disable-next-line incorrect-equality
|
|
627
667
|
if (numVesting != 0 && vestings[numVesting - 1].releaseRound == vestingReleaseRound) {
|
|
628
668
|
unchecked {
|
|
629
669
|
++j;
|
|
@@ -632,12 +672,18 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
632
672
|
}
|
|
633
673
|
|
|
634
674
|
// Keep a reference to the amount of tokens being claimed.
|
|
635
|
-
uint256 tokenAmount = mulDiv(distributable, _tokenStake(hook, tokenId), totalStakeAmount);
|
|
675
|
+
uint256 tokenAmount = mulDiv(distributable, _tokenStake({hook: hook, tokenId: tokenId}), totalStakeAmount);
|
|
636
676
|
|
|
637
677
|
// Add to the list of vesting data.
|
|
638
678
|
vestings.push(JBVestingData({releaseRound: vestingReleaseRound, amount: tokenAmount, shareClaimed: 0}));
|
|
639
679
|
|
|
640
|
-
emit Claimed(
|
|
680
|
+
emit Claimed({
|
|
681
|
+
hook: hook,
|
|
682
|
+
tokenId: tokenId,
|
|
683
|
+
token: token,
|
|
684
|
+
amount: tokenAmount,
|
|
685
|
+
vestingReleaseRound: vestingReleaseRound
|
|
686
|
+
});
|
|
641
687
|
|
|
642
688
|
unchecked {
|
|
643
689
|
totalVestingAmount += tokenAmount;
|
|
@@ -29,13 +29,13 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
|
|
|
29
29
|
//*********************************************************************//
|
|
30
30
|
|
|
31
31
|
/// @notice Thrown when a tokenId has non-zero upper bits (above 160), which would alias to the same staker address.
|
|
32
|
-
error JBTokenDistributor_InvalidTokenId();
|
|
32
|
+
error JBTokenDistributor_InvalidTokenId(uint256 tokenId);
|
|
33
33
|
|
|
34
34
|
/// @notice Thrown when native ETH is sent but context.token is not NATIVE_TOKEN.
|
|
35
|
-
error JBTokenDistributor_TokenMismatch();
|
|
35
|
+
error JBTokenDistributor_TokenMismatch(address token, address expectedToken, uint256 msgValue);
|
|
36
36
|
|
|
37
37
|
/// @notice Thrown when the caller is not a terminal or controller for the project.
|
|
38
|
-
error JBTokenDistributor_Unauthorized();
|
|
38
|
+
error JBTokenDistributor_Unauthorized(uint256 projectId, address caller);
|
|
39
39
|
|
|
40
40
|
//*********************************************************************//
|
|
41
41
|
// ---------------- public immutable stored properties --------------- //
|
|
@@ -81,33 +81,27 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
|
|
|
81
81
|
if (
|
|
82
82
|
!DIRECTORY.isTerminalOf(context.projectId, IJBTerminal(msg.sender))
|
|
83
83
|
&& DIRECTORY.controllerOf(context.projectId) != IERC165(msg.sender)
|
|
84
|
-
) revert JBTokenDistributor_Unauthorized();
|
|
84
|
+
) revert JBTokenDistributor_Unauthorized({projectId: context.projectId, caller: msg.sender});
|
|
85
85
|
|
|
86
86
|
// The target hook is the split's beneficiary (the IVotes token address).
|
|
87
87
|
address hook = address(context.split.beneficiary);
|
|
88
88
|
|
|
89
89
|
// If it's not a native-token transfer, credit the ERC-20 amount.
|
|
90
90
|
if (msg.value == 0 && context.amount != 0) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
_balanceOf[hook][IERC20(context.token)] += delta;
|
|
99
|
-
_accountedBalanceOf[IERC20(context.token)] += delta;
|
|
100
|
-
} else {
|
|
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;
|
|
106
|
-
_balanceOf[hook][IERC20(context.token)] += context.amount;
|
|
107
|
-
}
|
|
91
|
+
// Pull tokens via transferFrom. Both terminals and controllers grant an ERC-20
|
|
92
|
+
// allowance before calling. Balance delta handles fee-on-transfer tokens correctly.
|
|
93
|
+
uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
|
|
94
|
+
IERC20(context.token).safeTransferFrom({from: msg.sender, to: address(this), value: context.amount});
|
|
95
|
+
uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
|
|
96
|
+
_balanceOf[hook][IERC20(context.token)] += delta;
|
|
97
|
+
_accountedBalanceOf[IERC20(context.token)] += delta;
|
|
108
98
|
} else if (msg.value != 0) {
|
|
109
99
|
// Validate that context.token matches NATIVE_TOKEN to prevent cross-booking attacks.
|
|
110
|
-
if (context.token != JBConstants.NATIVE_TOKEN)
|
|
100
|
+
if (context.token != JBConstants.NATIVE_TOKEN) {
|
|
101
|
+
revert JBTokenDistributor_TokenMismatch({
|
|
102
|
+
token: context.token, expectedToken: JBConstants.NATIVE_TOKEN, msgValue: msg.value
|
|
103
|
+
});
|
|
104
|
+
}
|
|
111
105
|
// Native ETH: credit actual value received.
|
|
112
106
|
_balanceOf[hook][IERC20(context.token)] += msg.value;
|
|
113
107
|
_accountedBalanceOf[IERC20(context.token)] += msg.value;
|
|
@@ -138,7 +132,7 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
|
|
|
138
132
|
/// @return canClaim True if the account matches the encoded address.
|
|
139
133
|
function _canClaim(address hook, uint256 tokenId, address account) internal pure override returns (bool canClaim) {
|
|
140
134
|
hook; // Silence unused variable warning.
|
|
141
|
-
if (tokenId >> 160 != 0) revert JBTokenDistributor_InvalidTokenId();
|
|
135
|
+
if (tokenId >> 160 != 0) revert JBTokenDistributor_InvalidTokenId({tokenId: tokenId});
|
|
142
136
|
// The high bits were checked above, so this cast recovers the encoded address.
|
|
143
137
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
144
138
|
canClaim = address(uint160(tokenId)) == account;
|
|
@@ -163,7 +157,7 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
|
|
|
163
157
|
/// @param tokenId The encoded staker address (`uint256(uint160(stakerAddress))`).
|
|
164
158
|
/// @return tokenStakeAmount The delegated voting power at the round's snapshot block.
|
|
165
159
|
function _tokenStake(address hook, uint256 tokenId) internal view override returns (uint256 tokenStakeAmount) {
|
|
166
|
-
if (tokenId >> 160 != 0) revert JBTokenDistributor_InvalidTokenId();
|
|
160
|
+
if (tokenId >> 160 != 0) revert JBTokenDistributor_InvalidTokenId({tokenId: tokenId});
|
|
167
161
|
// The high bits were checked above, so this cast recovers the encoded address.
|
|
168
162
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
169
163
|
tokenStakeAmount = IVotes(hook).getPastVotes(address(uint160(tokenId)), roundSnapshotBlock[currentRound()]);
|