@bananapus/distributor-v6 0.0.11 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/distributor-v6",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "deploy:testnets": "source ./.env && npx sphinx propose ./script/Deploy.s.sol --networks testnets"
27
27
  },
28
28
  "dependencies": {
29
- "@bananapus/721-hook-v6": "0.0.43",
30
- "@bananapus/core-v6": "0.0.39",
29
+ "@bananapus/721-hook-v6": "0.0.47",
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"
@@ -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` | Terminal allowance flow and controller pre-funding flow both preserve actual received balances |
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 path was misclassified between allowance-pull and pre-funded controller flow |
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
 
@@ -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 split
17
+ ### Funding path
18
18
 
19
- `processSplitWith` supports two funding patterns:
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
 
@@ -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 The terminal grants an ERC-20 allowance before calling — we pull via `transferFrom`.
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
- uint256 allowance = IERC20(context.token).allowance(msg.sender, address(this));
123
- if (allowance >= context.amount) {
124
- // Terminal path: the caller granted an allowance — pull tokens via transferFrom.
125
- // Use balance delta to handle fee-on-transfer tokens correctly.
126
- uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
127
- IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
128
- uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
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) revert JB721Distributor_TokenMismatch();
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(ctx.hook, tokenId, ctx.token, tokenAmount, ctx.vestingReleaseRound);
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
  }
@@ -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) revert JBDistributor_NothingToDistribute();
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
- _vestTokenIds(hook, tokenIds, token, distributable, totalStakeAmount, round + vestingRounds);
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) revert JBDistributor_UnexpectedNativeValue();
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])) revert JBDistributor_NoAccess();
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
- tokenAmount += mulDiv(vesting.amount, MAX_SHARE - vesting.shareClaimed, MAX_SHARE);
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
- tokenAmount += mulDiv(vesting.amount, MAX_SHARE - vesting.shareClaimed - lockedShare, MAX_SHARE);
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)) revert JBDistributor_NoAccess();
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
- _vestTokenIds(hook, tokenIds, token, distributable, totalStakeAmount, round + vestingRounds);
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(hook, round, token, snapshot.balance, snapshot.vestingAmount);
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) revert JBDistributor_NativeTransferFailed();
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 = mulDiv(vesting.amount, MAX_SHARE - vesting.shareClaimed - lockedShare, MAX_SHARE);
586
+ uint256 claimAmount;
587
+
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
+ }
561
595
 
562
596
  if (claimAmount != 0) {
563
- // Only update the claimed share when a nonzero transfer will occur.
564
- // This keeps dust entries unconsumed so future claims can accumulate enough for a nonzero amount.
565
597
  vestings[vestedIndex].shareClaimed = MAX_SHARE - lockedShare;
566
598
  totalTokenAmount += claimAmount;
567
- emit Collected(hook, tokenId, token, claimAmount, vesting.releaseRound);
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
- // slither-disable-next-line incorrect-equality
575
- if (lockedShare == 0 && vestedIndex == newLatestVestedIndex + 1) {
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(hook, tokenId, token, tokenAmount, vestingReleaseRound);
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
- uint256 allowance = IERC20(context.token).allowance(msg.sender, address(this));
92
- if (allowance >= context.amount) {
93
- // Terminal path: the caller granted an allowance — pull tokens via transferFrom.
94
- // Use balance delta to handle fee-on-transfer tokens correctly.
95
- uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
96
- IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
97
- uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
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) revert JBTokenDistributor_TokenMismatch();
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()]);