@bananapus/distributor-v6 0.0.33 → 0.0.35

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.
@@ -20,7 +20,6 @@ import {IJBDistributor} from "./interfaces/IJBDistributor.sol";
20
20
  import {JBVestingMath} from "./libraries/JBVestingMath.sol";
21
21
  import {JBBorrowContext} from "./structs/JBBorrowContext.sol";
22
22
  import {JBRewardRoundData} from "./structs/JBRewardRoundData.sol";
23
- import {JBTokenSnapshotData} from "./structs/JBTokenSnapshotData.sol";
24
23
  import {JBVestingData} from "./structs/JBVestingData.sol";
25
24
  import {JBVestingLoan} from "./structs/JBVestingLoan.sol";
26
25
 
@@ -59,9 +58,6 @@ abstract contract JBDistributor is IJBDistributor {
59
58
  /// @notice Thrown when the caller does not have access to the token.
60
59
  error JBDistributor_NoAccess(address hook, uint256 tokenId, address account);
61
60
 
62
- /// @notice Thrown when there is nothing to distribute for a token in the current round.
63
- error JBDistributor_NothingToDistribute(address hook, address token, uint256 round);
64
-
65
61
  /// @notice Thrown when there are no uncollected vesting revnet tokens to collateralize a loan.
66
62
  error JBDistributor_NothingToBorrow(address hook, address token);
67
63
 
@@ -147,17 +143,23 @@ abstract contract JBDistributor is IJBDistributor {
147
143
 
148
144
  /// @notice The active Revnet loan using one token ID's vesting rewards as collateral.
149
145
  /// @custom:param hook The hook the token ID belongs to.
146
+ /// @custom:param groupId The reward group (0 = the default group).
150
147
  /// @custom:param tokenId The token ID whose vesting rewards are collateralized.
151
148
  /// @custom:param token The reward token used as loan collateral.
152
- mapping(address hook => mapping(uint256 tokenId => mapping(IERC20 token => uint256)))
149
+ mapping(
150
+ address hook => mapping(uint256 groupId => mapping(uint256 tokenId => mapping(IERC20 token => uint256)))
151
+ )
153
152
  public
154
153
  override activeVestingLoanIdOf;
155
154
 
156
155
  /// @notice The index within `vestingDataOf` of the latest vest.
157
156
  /// @custom:param hook The hook the tokenId belongs to.
157
+ /// @custom:param groupId The reward group (0 = the default group).
158
158
  /// @custom:param tokenId The ID of the token to which the vests belong.
159
159
  /// @custom:param token The address of the token vested.
160
- mapping(address hook => mapping(uint256 tokenId => mapping(IERC20 token => uint256))) public latestVestedIndexOf;
160
+ mapping(
161
+ address hook => mapping(uint256 groupId => mapping(uint256 tokenId => mapping(IERC20 token => uint256)))
162
+ ) public latestVestedIndexOf;
161
163
 
162
164
  /// @notice The block number recorded as the snapshot point for each round.
163
165
  /// @dev Set to `block.number - 1` on first interaction in a round, so that `IVotes.getPastVotes` works.
@@ -165,9 +167,12 @@ abstract contract JBDistributor is IJBDistributor {
165
167
 
166
168
  /// @notice Reward data assigned to each funding round.
167
169
  /// @custom:param hook The stake source whose stakers receive rewards.
170
+ /// @custom:param groupId The reward group (0 = the default group).
168
171
  /// @custom:param token The reward token.
169
172
  /// @custom:param round The reward round.
170
- mapping(address hook => mapping(IERC20 token => mapping(uint256 round => JBRewardRoundData))) public rewardRoundOf;
173
+ mapping(
174
+ address hook => mapping(uint256 groupId => mapping(IERC20 token => mapping(uint256 round => JBRewardRoundData)))
175
+ ) public rewardRoundOf;
171
176
 
172
177
  /// @notice The amount of a token that is currently vesting for a hook's stakers.
173
178
  /// @custom:param hook The hook whose stakers are vesting.
@@ -181,9 +186,12 @@ abstract contract JBDistributor is IJBDistributor {
181
186
 
182
187
  /// @notice All vesting data of a tokenId for any number of vesting tokens.
183
188
  /// @custom:param hook The hook the tokenId belongs to.
189
+ /// @custom:param groupId The reward group (0 = the default group).
184
190
  /// @custom:param tokenId The ID of the token to which the vests belong.
185
191
  /// @custom:param token The address of the token vested.
186
- mapping(address hook => mapping(uint256 tokenId => mapping(IERC20 token => JBVestingData[]))) public vestingDataOf;
192
+ mapping(
193
+ address hook => mapping(uint256 groupId => mapping(uint256 tokenId => mapping(IERC20 token => JBVestingData[])))
194
+ ) public vestingDataOf;
187
195
 
188
196
  //*********************************************************************//
189
197
  // -------------------- internal stored properties ------------------- //
@@ -202,21 +210,6 @@ abstract contract JBDistributor is IJBDistributor {
202
210
  /// @custom:param loanId The Revnet loan NFT ID.
203
211
  mapping(uint256 loanId => JBVestingLoan) internal _vestingLoanOf;
204
212
 
205
- /// @notice The snapshot data of the token information for each round.
206
- /// @custom:param hook The hook the snapshot is for.
207
- /// @custom:param token The address of the token claimed and vested.
208
- /// @custom:param round The round to which the data applies.
209
- mapping(address hook => mapping(IERC20 token => mapping(uint256 round => JBTokenSnapshotData snapshot))) internal
210
- _snapshotAtRoundOf;
211
-
212
- /// @notice Whether a snapshot has been taken for a given (hook, token, round).
213
- /// @dev Required because a snapshot can legitimately store `{balance: 0, vestingAmount: 0}`,
214
- /// so a zero balance is not a usable sentinel for "uninitialized".
215
- /// @custom:param hook The hook the snapshot is for.
216
- /// @custom:param token The address of the token claimed and vested.
217
- /// @custom:param round The round to which the data applies.
218
- mapping(address hook => mapping(IERC20 token => mapping(uint256 round => bool))) internal _snapshotInitializedFor;
219
-
220
213
  //*********************************************************************//
221
214
  // ------------------- transient stored properties ------------------- //
222
215
  //*********************************************************************//
@@ -271,9 +264,11 @@ abstract contract JBDistributor is IJBDistributor {
271
264
  // ---------------------- external transactions ---------------------- //
272
265
  //*********************************************************************//
273
266
 
274
- /// @notice Snapshot the current round's distributable balance and begin vesting for the specified token IDs.
275
- /// Each token ID's share is proportional to its stake weight relative to the total stake at the snapshot block.
276
- /// Vesting completes after `VESTING_ROUNDS` rounds. Reverts if there's nothing to distribute.
267
+ /// @notice Begin vesting all unclaimed past reward rounds for the specified token IDs.
268
+ /// @dev Materializes each token ID's pro-rata share of every past (non-current) reward round into fresh vesting
269
+ /// entries that start now and unlock over `VESTING_ROUNDS`. Current-round funding is excluded until a later round
270
+ /// starts. The model-specific per-round claim math and the authorization check live in the `_claimPastRewards`
271
+ /// and `_requireCanClaimTokenIds` hooks each concrete distributor implements.
277
272
  /// @param hook The hook (IVotes token or 721 hook) whose stakers are vesting.
278
273
  /// @param tokenIds The staker token IDs to claim rewards for.
279
274
  /// @param tokens The reward tokens to begin vesting.
@@ -286,56 +281,7 @@ abstract contract JBDistributor is IJBDistributor {
286
281
  virtual
287
282
  override
288
283
  {
289
- // Reward accounting cannot change while an ERC-20 `transferFrom` is in progress. A callback-capable reward
290
- // token could otherwise snapshot, vest, or collect against balances between `balanceBefore` and
291
- // `balanceAfter`, distorting the delta credited to the funder.
292
- _requireNotAcceptingToken();
293
-
294
- // Revert if no token IDs are provided.
295
- if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
296
-
297
- // Keep a reference to the current round.
298
- uint256 round = currentRound();
299
-
300
- // Ensure the snapshot block is recorded for this round.
301
- _ensureSnapshotBlock(round);
302
-
303
- // Keep a reference to the total staked amount at the snapshot block.
304
- uint256 totalStakeAmount = _totalStake({hook: hook, blockNumber: roundSnapshotBlock[round]});
305
-
306
- // Skip vesting when there are no stakers — funds carry over to the next round.
307
- if (totalStakeAmount == 0) return;
308
-
309
- // Loop through each token for which vesting is beginning.
310
- for (uint256 i; i < tokens.length;) {
311
- IERC20 token = tokens[i];
312
-
313
- // Take a snapshot of the token balance if it hasn't been taken already.
314
- JBTokenSnapshotData memory snapshot = _takeSnapshotOf({hook: hook, token: token});
315
- uint256 distributable = snapshot.balance - snapshot.vestingAmount;
316
-
317
- // Revert if there is nothing to distribute for this token.
318
- if (distributable == 0) {
319
- revert JBDistributor_NothingToDistribute({hook: hook, token: address(token), round: round});
320
- }
321
-
322
- // Vest each token ID and get the total amount vested.
323
- uint256 totalVestingAmount = _vestTokenIds({
324
- hook: hook,
325
- tokenIds: tokenIds,
326
- token: token,
327
- distributable: distributable,
328
- totalStakeAmount: totalStakeAmount,
329
- vestingReleaseRound: round + VESTING_ROUNDS
330
- });
331
-
332
- unchecked {
333
- // Store the updated total claimed amount now vesting.
334
- totalVestingAmountOf[hook][token] += totalVestingAmount;
335
-
336
- ++i;
337
- }
338
- }
284
+ _beginVesting({hook: hook, groupId: 0, tokenIds: tokenIds, tokens: tokens});
339
285
  }
340
286
 
341
287
  /// @notice Directly fund the distributor for a specific hook by pulling tokens from the caller. An alternative
@@ -346,7 +292,7 @@ abstract contract JBDistributor is IJBDistributor {
346
292
  /// @param token The token to fund with.
347
293
  /// @param amount The amount to fund (ignored for native ETH — `msg.value` is used instead).
348
294
  function fund(address hook, IERC20 token, uint256 amount) external payable virtual override {
349
- _fund({hook: hook, token: token, amount: amount});
295
+ _fund({hook: hook, groupId: 0, token: token, amount: amount});
350
296
  }
351
297
 
352
298
  /// @notice Recycle unclaimed rewards from expired reward rounds into the current reward round.
@@ -365,19 +311,7 @@ abstract contract JBDistributor is IJBDistributor {
365
311
  override
366
312
  returns (uint256 amount)
367
313
  {
368
- // Do not let reward-token callbacks recycle inventory during an inbound balance-delta measurement.
369
- _requireNotAcceptingToken();
370
-
371
- // Process every requested round independently so callers can batch keeper work.
372
- for (uint256 i; i < rounds.length;) {
373
- // Add this round's expired remainder to the batch total.
374
- amount += _recycleExpiredRewardRound({hook: hook, token: token, round: rounds[i]});
375
-
376
- unchecked {
377
- // Safe because the loop is bounded by calldata length.
378
- ++i;
379
- }
380
- }
314
+ amount = _burnExpiredRewards({hook: hook, groupId: 0, token: token, rounds: rounds});
381
315
  }
382
316
 
383
317
  /// @notice Record the snapshot block for the current round (and eagerly for the next round). Callable by anyone —
@@ -401,21 +335,7 @@ abstract contract JBDistributor is IJBDistributor {
401
335
  external
402
336
  override
403
337
  {
404
- // Do not let reward-token callbacks mutate vesting state during inbound balance-delta accounting.
405
- _requireNotAcceptingToken();
406
-
407
- // Make sure that all staker token IDs are burned.
408
- for (uint256 i; i < tokenIds.length;) {
409
- if (!_tokenBurned({hook: hook, tokenId: tokenIds[i]})) {
410
- revert JBDistributor_NoAccess({hook: hook, tokenId: tokenIds[i], account: msg.sender});
411
- }
412
- unchecked {
413
- ++i;
414
- }
415
- }
416
-
417
- // Unlock the rewards and recycle the forfeited amount.
418
- _unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: false});
338
+ _releaseForfeitedRewards({hook: hook, groupId: 0, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary});
419
339
  }
420
340
 
421
341
  //*********************************************************************//
@@ -445,7 +365,7 @@ abstract contract JBDistributor is IJBDistributor {
445
365
  override
446
366
  returns (uint256 tokenAmount)
447
367
  {
448
- tokenAmount = _unclaimedVestingAmountOf({hook: hook, tokenId: tokenId, token: token});
368
+ tokenAmount = _unclaimedVestingAmountOf({hook: hook, groupId: 0, tokenId: tokenId, token: token});
449
369
  }
450
370
 
451
371
  /// @notice Calculate how much of a reward token is currently unlocked and ready to be collected for a given
@@ -464,63 +384,7 @@ abstract contract JBDistributor is IJBDistributor {
464
384
  override
465
385
  returns (uint256 tokenAmount)
466
386
  {
467
- // A loan keeps this token ID's vesting rewards in collateral custody until the loan is repaid.
468
- if (activeVestingLoanIdOf[hook][tokenId][token] != 0) return 0;
469
-
470
- // The round that we are in right now.
471
- uint256 round = currentRound();
472
-
473
- // Keep a reference to the latest vested index.
474
- uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
475
-
476
- // Keep a reference to the vesting data array.
477
- JBVestingData[] storage vestings = vestingDataOf[hook][tokenId][token];
478
- uint256 numberOfVestingRounds = vestings.length;
479
-
480
- while (vestedIndex < numberOfVestingRounds) {
481
- uint256 lockedShare;
482
-
483
- // Keep a reference to the vested data being iterated on.
484
- JBVestingData memory vesting = vestings[vestedIndex];
485
-
486
- lockedShare = JBVestingMath.lockedShareOf({
487
- releaseRound: vesting.releaseRound,
488
- currentRound: round,
489
- vestingRounds: VESTING_ROUNDS,
490
- maxShare: MAX_SHARE
491
- });
492
-
493
- // Calculate the newly unlocked amount from cumulative shares rather than the incremental share delta.
494
- // Incremental floor rounding can otherwise underpay partial collections and leave dust stranded.
495
- (uint256 claimAmount,) = JBVestingMath.newlyClaimableAmountOf({
496
- amount: vesting.amount,
497
- shareClaimed: vesting.shareClaimed,
498
- lockedShare: lockedShare,
499
- maxShare: MAX_SHARE
500
- });
501
- tokenAmount += claimAmount;
502
-
503
- unchecked {
504
- ++vestedIndex;
505
- }
506
- }
507
- }
508
-
509
- /// @notice The snapshot data of the token information for each round.
510
- /// @param hook The hook the snapshot is for.
511
- /// @param token The address of the token claimed and vested.
512
- /// @param round The round to which the data applies.
513
- function snapshotAtRoundOf(
514
- address hook,
515
- IERC20 token,
516
- uint256 round
517
- )
518
- external
519
- view
520
- override
521
- returns (JBTokenSnapshotData memory)
522
- {
523
- return _snapshotAtRoundOf[hook][token][round];
387
+ tokenAmount = _collectableFor({hook: hook, groupId: 0, tokenId: tokenId, token: token});
524
388
  }
525
389
 
526
390
  /// @notice The vesting position collateralized by a Revnet loan.
@@ -548,11 +412,12 @@ abstract contract JBDistributor is IJBDistributor {
548
412
  // ----------------------- public transactions ----------------------- //
549
413
  //*********************************************************************//
550
414
 
551
- /// @notice Collect tokens that have vested (partially or fully) and transfer them to the beneficiary. Also
552
- /// auto-vests for the current round if rewards haven't been claimed yet — so callers don't need to separately
553
- /// call `beginVesting`. Only the token owner (verified via `_canClaim`) can collect.
415
+ /// @notice Begin vesting any unclaimed past reward rounds, then collect everything that has since unlocked and
416
+ /// transfer it to the beneficiary — so callers don't need to separately call `beginVesting`.
417
+ /// @dev The model-specific per-round claim math and the authorization check live in the `_claimPastRewards`
418
+ /// and `_requireCanClaimTokenIds` hooks each concrete distributor implements.
554
419
  /// @param hook The hook whose stakers are collecting.
555
- /// @param tokenIds The IDs of the tokens to collect for (caller must own all of them).
420
+ /// @param tokenIds The IDs of the tokens to collect for (caller must be authorized for all of them).
556
421
  /// @param tokens The reward tokens to collect vested amounts of.
557
422
  /// @param beneficiary The recipient of the collected tokens.
558
423
  function collectVestedRewards(
@@ -565,63 +430,7 @@ abstract contract JBDistributor is IJBDistributor {
565
430
  virtual
566
431
  override
567
432
  {
568
- // Collections transfer reward tokens out. If this runs inside the same reward token's inbound transfer, the
569
- // outgoing transfer can net against the incoming balance delta and strand the new funds unaccounted.
570
- _requireNotAcceptingToken();
571
-
572
- // Revert if no token IDs are provided.
573
- if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
574
-
575
- // Make sure that all tokens can be claimed by this sender.
576
- for (uint256 i; i < tokenIds.length;) {
577
- if (!_canClaim({hook: hook, tokenId: tokenIds[i], account: msg.sender})) {
578
- revert JBDistributor_NoAccess({hook: hook, tokenId: tokenIds[i], account: msg.sender});
579
- }
580
- unchecked {
581
- ++i;
582
- }
583
- }
584
-
585
- // --- Auto-vest for the current round ---
586
- uint256 round = currentRound();
587
-
588
- // Ensure the snapshot block is recorded for this round.
589
- _ensureSnapshotBlock(round);
590
-
591
- // Keep a reference to the total staked amount at the snapshot block.
592
- uint256 totalStakeAmount = _totalStake({hook: hook, blockNumber: roundSnapshotBlock[round]});
593
-
594
- // Loop through each token and auto-vest if there's something distributable.
595
- for (uint256 i; i < tokens.length;) {
596
- IERC20 token = tokens[i];
597
-
598
- // Take a snapshot of the token balance if it hasn't been taken already.
599
- JBTokenSnapshotData memory snapshot = _takeSnapshotOf({hook: hook, token: token});
600
- uint256 distributable = snapshot.balance - snapshot.vestingAmount;
601
-
602
- // Only auto-vest if there's something to distribute and there's stake.
603
- if (distributable > 0 && totalStakeAmount > 0) {
604
- uint256 totalVestingAmount = _vestTokenIds({
605
- hook: hook,
606
- tokenIds: tokenIds,
607
- token: token,
608
- distributable: distributable,
609
- totalStakeAmount: totalStakeAmount,
610
- vestingReleaseRound: round + VESTING_ROUNDS
611
- });
612
-
613
- unchecked {
614
- totalVestingAmountOf[hook][token] += totalVestingAmount;
615
- }
616
- }
617
-
618
- unchecked {
619
- ++i;
620
- }
621
- }
622
-
623
- // Unlock the rewards and send them to the beneficiary.
624
- _unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: true});
433
+ _collectVestedRewards({hook: hook, groupId: 0, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary});
625
434
  }
626
435
 
627
436
  /// @notice Borrow from a revnet using one token ID's uncollected vesting rewards as collateral.
@@ -650,41 +459,16 @@ abstract contract JBDistributor is IJBDistributor {
650
459
  override
651
460
  returns (uint256 loanId, uint256 collateralCount)
652
461
  {
653
- // Do not let reward-token callbacks mutate claim accounting during an inbound transfer.
654
- _requireNotAcceptingToken();
655
-
656
- // Revert if no token IDs are provided.
657
- if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
658
-
659
- // One distributor-held Revnet loan tracks one token ID so one repayment restores one vesting schedule.
660
- if (tokenIds.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokenIds.length});
661
-
662
- // One loan collateralizes one revnet reward token.
663
- if (tokens.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokens.length});
664
-
665
- // Zero vesting means rewards are immediately collectible, so there is no locked position to borrow against.
666
- if (VESTING_ROUNDS == 0) revert JBDistributor_VestingLoansDisabled();
667
-
668
- // Revnet loan-backed collection is disabled unless a trusted loans contract was set at deployment.
669
- if (address(REV_LOANS) == address(0)) revert JBDistributor_RevnetLoansNotConfigured();
670
-
671
- // Make sure that all tokens can be claimed by this sender.
672
- _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
673
-
674
- // Bundle the remaining borrow parameters to keep the loan workflow readable and stack-safe.
675
- JBBorrowContext memory ctx = JBBorrowContext({
462
+ (loanId, collateralCount) = _borrowAgainstVestingFor({
676
463
  hook: hook,
677
- tokenId: tokenIds[0],
678
- token: tokens[0],
464
+ groupId: 0,
465
+ tokenIds: tokenIds,
466
+ tokens: tokens,
679
467
  sourceToken: sourceToken,
680
468
  minBorrowAmount: minBorrowAmount,
681
469
  prepaidFeePercent: prepaidFeePercent,
682
- beneficiary: beneficiary,
683
- revnetId: _revnetIdOf(tokens[0])
470
+ beneficiary: beneficiary
684
471
  });
685
-
686
- // Open and track the distributor-owned loan.
687
- (loanId, collateralCount) = _borrowAgainstVesting({ctx: ctx, tokenIds: tokenIds, tokens: tokens});
688
472
  }
689
473
 
690
474
  /// @notice Repay a distributor-held Revnet loan and restore its collateral to the original vesting schedule.
@@ -767,15 +551,210 @@ abstract contract JBDistributor is IJBDistributor {
767
551
 
768
552
  /// @notice Claim all past reward rounds for the given token IDs and reward tokens into fresh vesting entries.
769
553
  /// @param hook The hook whose stakers are claiming.
554
+ /// @param groupId The reward group being claimed (0 = the default group).
770
555
  /// @param tokenIds The token IDs to claim for.
771
556
  /// @param tokens The reward tokens to claim.
772
- function _claimPastRewards(address hook, uint256[] calldata tokenIds, IERC20[] calldata tokens) internal virtual;
557
+ function _claimPastRewards(
558
+ address hook,
559
+ uint256 groupId,
560
+ uint256[] calldata tokenIds,
561
+ IERC20[] calldata tokens
562
+ )
563
+ internal
564
+ virtual;
773
565
 
774
566
  /// @notice Revert unless the caller is authorized to claim each token ID.
775
567
  /// @param hook The hook whose token IDs are being checked.
776
568
  /// @param tokenIds The token IDs to check.
777
569
  function _requireCanClaimTokenIds(address hook, uint256[] calldata tokenIds) internal view virtual;
778
570
 
571
+ /// @notice Shared begin-vesting logic across reward groups.
572
+ /// @param hook The hook whose stakers are vesting.
573
+ /// @param groupId The reward group (0 = the default group).
574
+ /// @param tokenIds The staker token IDs to claim rewards for.
575
+ /// @param tokens The reward tokens to begin vesting.
576
+ function _beginVesting(
577
+ address hook,
578
+ uint256 groupId,
579
+ uint256[] calldata tokenIds,
580
+ IERC20[] calldata tokens
581
+ )
582
+ internal
583
+ {
584
+ // Reward accounting cannot change while an ERC-20 `transferFrom` is in progress.
585
+ _requireNotAcceptingToken();
586
+
587
+ // Revert if no token IDs are provided.
588
+ if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
589
+
590
+ // Only the entity authorized for these token IDs may start their vesting clock.
591
+ _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
592
+
593
+ // Materialize all unclaimed historical reward rounds into fresh vesting entries that start now.
594
+ _claimPastRewards({hook: hook, groupId: groupId, tokenIds: tokenIds, tokens: tokens});
595
+ }
596
+
597
+ /// @notice Shared begin-vesting-then-collect logic across reward groups.
598
+ /// @param hook The hook whose stakers are collecting.
599
+ /// @param groupId The reward group (0 = the default group).
600
+ /// @param tokenIds The token IDs to collect for.
601
+ /// @param tokens The reward tokens to collect.
602
+ /// @param beneficiary The recipient of the collected tokens.
603
+ function _collectVestedRewards(
604
+ address hook,
605
+ uint256 groupId,
606
+ uint256[] calldata tokenIds,
607
+ IERC20[] calldata tokens,
608
+ address beneficiary
609
+ )
610
+ internal
611
+ {
612
+ // Collections transfer reward tokens out; block them mid inbound transfer.
613
+ _requireNotAcceptingToken();
614
+
615
+ // Revert if no token IDs are provided.
616
+ if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
617
+
618
+ // Only the entity authorized for these token IDs may materialize and collect their rewards.
619
+ _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
620
+
621
+ // Before collecting, bring the token IDs current by starting vesting for any past reward rounds.
622
+ _claimPastRewards({hook: hook, groupId: groupId, tokenIds: tokenIds, tokens: tokens});
623
+
624
+ // Release whatever portion of existing vesting entries has unlocked by this round.
625
+ _unlockRewards({
626
+ hook: hook, groupId: groupId, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: true
627
+ });
628
+ }
629
+
630
+ /// @notice Shared forfeiture-release logic across reward groups.
631
+ /// @param hook The hook whose tokens were burned.
632
+ /// @param groupId The reward group (0 = the default group).
633
+ /// @param tokenIds The IDs of the burned tokens.
634
+ /// @param tokens The reward tokens to recycle.
635
+ /// @param beneficiary Unused for forfeiture. Kept for interface compatibility.
636
+ function _releaseForfeitedRewards(
637
+ address hook,
638
+ uint256 groupId,
639
+ uint256[] calldata tokenIds,
640
+ IERC20[] calldata tokens,
641
+ address beneficiary
642
+ )
643
+ internal
644
+ {
645
+ // Do not let reward-token callbacks mutate vesting state during inbound balance-delta accounting.
646
+ _requireNotAcceptingToken();
647
+
648
+ // Make sure that all staker token IDs are burned.
649
+ for (uint256 i; i < tokenIds.length;) {
650
+ if (!_tokenBurned({hook: hook, tokenId: tokenIds[i]})) {
651
+ revert JBDistributor_NoAccess({hook: hook, tokenId: tokenIds[i], account: msg.sender});
652
+ }
653
+ unchecked {
654
+ ++i;
655
+ }
656
+ }
657
+
658
+ // Unlock the rewards and recycle the forfeited amount.
659
+ _unlockRewards({
660
+ hook: hook,
661
+ groupId: groupId,
662
+ tokenIds: tokenIds,
663
+ tokens: tokens,
664
+ beneficiary: beneficiary,
665
+ ownerClaim: false
666
+ });
667
+ }
668
+
669
+ /// @notice Shared expired-reward recycling logic across reward groups.
670
+ /// @param hook The hook whose expired rewards should be recycled.
671
+ /// @param groupId The reward group (0 = the default group).
672
+ /// @param token The reward token to recycle.
673
+ /// @param rounds The reward rounds to recycle.
674
+ /// @return amount The total amount recycled.
675
+ function _burnExpiredRewards(
676
+ address hook,
677
+ uint256 groupId,
678
+ IERC20 token,
679
+ uint256[] calldata rounds
680
+ )
681
+ internal
682
+ returns (uint256 amount)
683
+ {
684
+ // Do not let reward-token callbacks recycle inventory during an inbound balance-delta measurement.
685
+ _requireNotAcceptingToken();
686
+
687
+ // Process every requested round independently so callers can batch keeper work.
688
+ for (uint256 i; i < rounds.length;) {
689
+ amount += _recycleExpiredRewardRound({hook: hook, groupId: groupId, token: token, round: rounds[i]});
690
+ unchecked {
691
+ ++i;
692
+ }
693
+ }
694
+ }
695
+
696
+ /// @notice Shared borrow-against-vesting logic across reward groups.
697
+ /// @param hook The hook whose staker is borrowing against vesting rewards.
698
+ /// @param groupId The reward group (0 = the default group).
699
+ /// @param tokenIds The single token ID to borrow against.
700
+ /// @param tokens The single revnet reward token to collateralize.
701
+ /// @param sourceToken The token to borrow from the revnet.
702
+ /// @param minBorrowAmount The minimum amount to borrow, denominated in `sourceToken`.
703
+ /// @param prepaidFeePercent The fee percent to charge upfront.
704
+ /// @param beneficiary The recipient of the borrowed funds.
705
+ /// @return loanId The Revnet loan NFT ID held by this distributor.
706
+ /// @return collateralCount The amount of vesting rewards used as collateral.
707
+ function _borrowAgainstVestingFor(
708
+ address hook,
709
+ uint256 groupId,
710
+ uint256[] calldata tokenIds,
711
+ IERC20[] calldata tokens,
712
+ address sourceToken,
713
+ uint256 minBorrowAmount,
714
+ uint256 prepaidFeePercent,
715
+ address payable beneficiary
716
+ )
717
+ internal
718
+ returns (uint256 loanId, uint256 collateralCount)
719
+ {
720
+ // Do not let reward-token callbacks mutate claim accounting during an inbound transfer.
721
+ _requireNotAcceptingToken();
722
+
723
+ // Revert if no token IDs are provided.
724
+ if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
725
+
726
+ // One distributor-held Revnet loan tracks one token ID so one repayment restores one vesting schedule.
727
+ if (tokenIds.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokenIds.length});
728
+
729
+ // One loan collateralizes one revnet reward token.
730
+ if (tokens.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokens.length});
731
+
732
+ // Zero vesting means rewards are immediately collectible, so there is no locked position to borrow against.
733
+ if (VESTING_ROUNDS == 0) revert JBDistributor_VestingLoansDisabled();
734
+
735
+ // Revnet loan-backed collection is disabled unless a trusted loans contract was set at deployment.
736
+ if (address(REV_LOANS) == address(0)) revert JBDistributor_RevnetLoansNotConfigured();
737
+
738
+ // Make sure that all tokens can be claimed by this sender.
739
+ _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
740
+
741
+ // Bundle the remaining borrow parameters to keep the loan workflow readable and stack-safe.
742
+ JBBorrowContext memory ctx = JBBorrowContext({
743
+ hook: hook,
744
+ groupId: groupId,
745
+ tokenId: tokenIds[0],
746
+ token: tokens[0],
747
+ sourceToken: sourceToken,
748
+ minBorrowAmount: minBorrowAmount,
749
+ prepaidFeePercent: prepaidFeePercent,
750
+ beneficiary: beneficiary,
751
+ revnetId: _revnetIdOf(tokens[0])
752
+ });
753
+
754
+ // Open and track the distributor-owned loan.
755
+ (loanId, collateralCount) = _borrowAgainstVesting({ctx: ctx, tokenIds: tokenIds, tokens: tokens});
756
+ }
757
+
779
758
  /// @notice Open and track a distributor-held Revnet loan against one vesting position.
780
759
  /// @param ctx The borrow context.
781
760
  /// @param tokenIds The single token ID being collateralized.
@@ -791,7 +770,7 @@ abstract contract JBDistributor is IJBDistributor {
791
770
  returns (uint256 loanId, uint256 collateralCount)
792
771
  {
793
772
  // One vesting position cannot be collateralized by two outstanding loans.
794
- uint256 activeLoanId = activeVestingLoanIdOf[ctx.hook][ctx.tokenId][ctx.token];
773
+ uint256 activeLoanId = activeVestingLoanIdOf[ctx.hook][ctx.groupId][ctx.tokenId][ctx.token];
795
774
  if (activeLoanId != 0) {
796
775
  revert JBDistributor_VestingLoanOutstanding({
797
776
  hook: ctx.hook, tokenId: ctx.tokenId, token: address(ctx.token), loanId: activeLoanId
@@ -799,13 +778,14 @@ abstract contract JBDistributor is IJBDistributor {
799
778
  }
800
779
 
801
780
  // Bring the claimant current before measuring collateral.
802
- _claimPastRewards({hook: ctx.hook, tokenIds: tokenIds, tokens: tokens});
781
+ _claimPastRewards({hook: ctx.hook, groupId: ctx.groupId, tokenIds: tokenIds, tokens: tokens});
803
782
 
804
783
  // Use the remaining uncollected vesting amount as collateral without advancing the vesting schedule.
805
- collateralCount = _unclaimedVestingAmountOf({hook: ctx.hook, tokenId: ctx.tokenId, token: ctx.token});
784
+ collateralCount =
785
+ _unclaimedVestingAmountOf({hook: ctx.hook, groupId: ctx.groupId, tokenId: ctx.tokenId, token: ctx.token});
806
786
 
807
787
  // Remember the vesting-entry boundary so liquidation write-off cannot consume later rewards.
808
- uint48 vestingDataCount = _toUint48(vestingDataOf[ctx.hook][ctx.tokenId][ctx.token].length);
788
+ uint48 vestingDataCount = _toUint48(vestingDataOf[ctx.hook][ctx.groupId][ctx.tokenId][ctx.token].length);
809
789
 
810
790
  // A zero-collateral loan would revert in Revnet, but this local error explains why.
811
791
  if (collateralCount == 0) {
@@ -818,7 +798,7 @@ abstract contract JBDistributor is IJBDistributor {
818
798
  totalLoanedVestingAmountOf[ctx.hook][ctx.token] += collateralCount;
819
799
 
820
800
  // Block same-position reentrancy before the loan contract burns collateral and returns the real loan ID.
821
- activeVestingLoanIdOf[ctx.hook][ctx.tokenId][ctx.token] = _PENDING_VESTING_LOAN_ID;
801
+ activeVestingLoanIdOf[ctx.hook][ctx.groupId][ctx.tokenId][ctx.token] = _PENDING_VESTING_LOAN_ID;
822
802
 
823
803
  // Open the Revnet loan with this distributor as the holder whose tokens are burned as collateral.
824
804
  loanId = _openVestingLoan({ctx: ctx, collateralCount: collateralCount});
@@ -827,9 +807,10 @@ abstract contract JBDistributor is IJBDistributor {
827
807
  }
828
808
 
829
809
  // Track the distributor-held loan so repayment can restore the same vesting position.
830
- activeVestingLoanIdOf[ctx.hook][ctx.tokenId][ctx.token] = loanId;
810
+ activeVestingLoanIdOf[ctx.hook][ctx.groupId][ctx.tokenId][ctx.token] = loanId;
831
811
  _vestingLoanOf[loanId] = JBVestingLoan({
832
812
  hook: ctx.hook,
813
+ groupId: ctx.groupId,
833
814
  tokenId: ctx.tokenId,
834
815
  token: ctx.token,
835
816
  vestingDataCount: vestingDataCount,
@@ -979,7 +960,7 @@ abstract contract JBDistributor is IJBDistributor {
979
960
  totalLoanedVestingAmountOf[vestingLoan.hook][vestingLoan.token] -= vestingLoan.collateralCount;
980
961
 
981
962
  // Clear the lock that prevented this position from being collected while collateralized.
982
- delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
963
+ delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token];
983
964
  delete _vestingLoanOf[loanId];
984
965
 
985
966
  // Return any excess reward tokens created during source-fee payment to the repayer.
@@ -1013,10 +994,12 @@ abstract contract JBDistributor is IJBDistributor {
1013
994
  collateralCount = vestingLoan.collateralCount;
1014
995
 
1015
996
  // Load the vesting entries for the token ID whose rewards were collateralized.
1016
- JBVestingData[] storage vestings = vestingDataOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
997
+ JBVestingData[] storage vestings =
998
+ vestingDataOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token];
1017
999
 
1018
1000
  // Start at the first unexhausted vesting entry.
1019
- uint256 vestedIndex = latestVestedIndexOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
1001
+ uint256 vestedIndex =
1002
+ latestVestedIndexOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token];
1020
1003
 
1021
1004
  // Stop at the boundary recorded when the loan opened, preserving newer vesting entries.
1022
1005
  uint256 vestingDataCount = vestingLoan.vestingDataCount;
@@ -1032,7 +1015,7 @@ abstract contract JBDistributor is IJBDistributor {
1032
1015
  }
1033
1016
 
1034
1017
  // Skip over the written-off vesting entries without ever moving the cursor backwards.
1035
- latestVestedIndexOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token] = vestedIndex;
1018
+ latestVestedIndexOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token] = vestedIndex;
1036
1019
 
1037
1020
  // Remove the liquidated collateral from the amount still considered vesting.
1038
1021
  totalVestingAmountOf[vestingLoan.hook][vestingLoan.token] -= collateralCount;
@@ -1041,7 +1024,7 @@ abstract contract JBDistributor is IJBDistributor {
1041
1024
  totalLoanedVestingAmountOf[vestingLoan.hook][vestingLoan.token] -= collateralCount;
1042
1025
 
1043
1026
  // Clear the active loan lock for this token ID and reward token.
1044
- delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
1027
+ delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token];
1045
1028
 
1046
1029
  // Clear the loan metadata so it cannot be written off or repaid again.
1047
1030
  delete _vestingLoanOf[loanId];
@@ -1092,9 +1075,10 @@ abstract contract JBDistributor is IJBDistributor {
1092
1075
 
1093
1076
  /// @notice Accept funds and assign them to this round's reward ledger.
1094
1077
  /// @param hook The stake source whose stakers receive the rewards.
1078
+ /// @param groupId The reward group being funded (0 = the default group).
1095
1079
  /// @param token The reward token being funded.
1096
1080
  /// @param amount The nominal amount to fund.
1097
- function _fund(address hook, IERC20 token, uint256 amount) internal {
1081
+ function _fund(address hook, uint256 groupId, IERC20 token, uint256 amount) internal {
1098
1082
  // Native funding is measured by msg.value, not the caller-provided amount.
1099
1083
  if (address(token) == JBConstants.NATIVE_TOKEN) {
1100
1084
  amount = msg.value;
@@ -1109,38 +1093,41 @@ abstract contract JBDistributor is IJBDistributor {
1109
1093
  }
1110
1094
 
1111
1095
  // Store the accepted amount in this round's historical reward ledger.
1112
- _recordRewardFunding({hook: hook, token: token, amount: amount});
1096
+ _recordRewardFunding({hook: hook, groupId: groupId, token: token, amount: amount});
1113
1097
  }
1114
1098
 
1115
1099
  /// @notice Record accepted funding as the current round's reward pot.
1116
1100
  /// @param hook The stake source whose stakers receive the rewards.
1101
+ /// @param groupId The reward group (0 = the default group).
1117
1102
  /// @param token The reward token.
1118
1103
  /// @param amount The accepted funding amount.
1119
- function _recordRewardFunding(address hook, IERC20 token, uint256 amount) internal {
1104
+ function _recordRewardFunding(address hook, uint256 groupId, IERC20 token, uint256 amount) internal {
1120
1105
  // Zero-value transfers do not create reward rounds or alter tracked balances.
1121
1106
  if (amount == 0) return;
1122
1107
 
1123
1108
  // Add the accepted amount to the current reward ledger.
1124
- _recordRewardRound({hook: hook, token: token, amount: amount});
1109
+ _recordRewardRound({hook: hook, groupId: groupId, token: token, amount: amount});
1125
1110
 
1126
- // Keep the base distributor's balance accounting in sync for collection and conservation checks.
1111
+ // Keep the base distributor's balance accounting in sync for collection and conservation checks. Balances
1112
+ // are tracked per (hook, token) across all groups because they share one token custody pool.
1127
1113
  _balanceOf[hook][token] += amount;
1128
1114
  _accountedBalanceOf[token] += amount;
1129
1115
  }
1130
1116
 
1131
1117
  /// @notice Record rewards as the current round's claimable historical reward pot.
1132
1118
  /// @param hook The stake source whose stakers receive the rewards.
1119
+ /// @param groupId The reward group (0 = the default group).
1133
1120
  /// @param token The reward token.
1134
1121
  /// @param amount The amount to add to the current reward round.
1135
- function _recordRewardRound(address hook, IERC20 token, uint256 amount) internal {
1122
+ function _recordRewardRound(address hook, uint256 groupId, IERC20 token, uint256 amount) internal {
1136
1123
  // Zero-value rewards do not create reward rounds.
1137
1124
  if (amount == 0) return;
1138
1125
 
1139
1126
  // Rewards belong to the round in progress when they enter the ledger.
1140
1127
  uint256 round = currentRound();
1141
1128
 
1142
- // Load the current round's ledger entry for this hook and reward token.
1143
- JBRewardRoundData storage rewardRound = rewardRoundOf[hook][token][round];
1129
+ // Load the current round's ledger entry for this hook, group, and reward token.
1130
+ JBRewardRoundData storage rewardRound = rewardRoundOf[hook][groupId][token][round];
1144
1131
 
1145
1132
  // Every reward round in this contract uses the same immutable claim duration.
1146
1133
  uint48 claimDeadline = _claimDeadlineFor(round);
@@ -1156,8 +1143,8 @@ abstract contract JBDistributor is IJBDistributor {
1156
1143
  // Store the packed claim deadline fixed for this distributor.
1157
1144
  rewardRound.claimDeadline = claimDeadline;
1158
1145
 
1159
- // Store the packed total stake that shares this round's reward pot.
1160
- rewardRound.totalStake = _toUint208(_totalStake({hook: hook, blockNumber: snapshotBlock}));
1146
+ // Store the packed total stake that shares this group's round reward pot.
1147
+ rewardRound.totalStake = _toUint208(_totalStake({hook: hook, groupId: groupId, blockNumber: snapshotBlock}));
1161
1148
  }
1162
1149
 
1163
1150
  // Multiple additions in the same round share the same snapshot and reward pot.
@@ -1166,11 +1153,13 @@ abstract contract JBDistributor is IJBDistributor {
1166
1153
 
1167
1154
  /// @notice Recycle one expired reward round's unclaimed inventory into the current reward round.
1168
1155
  /// @param hook The hook whose expired rewards should be recycled.
1156
+ /// @param groupId The reward group (0 = the default group).
1169
1157
  /// @param token The reward token to recycle.
1170
1158
  /// @param round The reward round to recycle.
1171
1159
  /// @return recycleAmount The amount recycled.
1172
1160
  function _recycleExpiredRewardRound(
1173
1161
  address hook,
1162
+ uint256 groupId,
1174
1163
  IERC20 token,
1175
1164
  uint256 round
1176
1165
  )
@@ -1178,7 +1167,7 @@ abstract contract JBDistributor is IJBDistributor {
1178
1167
  returns (uint256 recycleAmount)
1179
1168
  {
1180
1169
  // Load the reward round once so expiry, claimed amount, and funded amount stay in sync.
1181
- JBRewardRoundData storage rewardRound = rewardRoundOf[hook][token][round];
1170
+ JBRewardRoundData storage rewardRound = rewardRoundOf[hook][groupId][token][round];
1182
1171
 
1183
1172
  // Ignore rounds that either never expire or have not reached their deadline yet.
1184
1173
  if (!_rewardRoundExpired(rewardRound)) return 0;
@@ -1194,7 +1183,7 @@ abstract contract JBDistributor is IJBDistributor {
1194
1183
 
1195
1184
  // Keep the inventory in the distributor and give the current staker set a new claimable round.
1196
1185
  uint256 recycledToRound = currentRound();
1197
- _recordRewardRound({hook: hook, token: token, amount: recycleAmount});
1186
+ _recordRewardRound({hook: hook, groupId: groupId, token: token, amount: recycleAmount});
1198
1187
 
1199
1188
  // Surface the permissionless recycle for off-chain accounting.
1200
1189
  emit ExpiredRewardsRecycled({
@@ -1261,41 +1250,6 @@ abstract contract JBDistributor is IJBDistributor {
1261
1250
  }
1262
1251
  }
1263
1252
 
1264
- /// @notice Takes a snapshot of the token balance and vesting amount for the current round.
1265
- /// @param hook The hook to take the snapshot for.
1266
- /// @param token The token address to take a snapshot of.
1267
- /// @return snapshot The snapshot data.
1268
- function _takeSnapshotOf(address hook, IERC20 token) internal returns (JBTokenSnapshotData memory snapshot) {
1269
- // Keep a reference to the current round.
1270
- uint256 round = currentRound();
1271
-
1272
- // If a snapshot was already taken at this round, do not take a new one. The init flag must be used as the
1273
- // sentinel: a zero balance is a valid snapshot value (round started with no funded balance), not a signal
1274
- // to re-snapshot. Re-snapshotting would let mid-round deposits leak into the current round's allocation.
1275
- if (_snapshotInitializedFor[hook][token][round]) {
1276
- return _snapshotAtRoundOf[hook][token][round];
1277
- }
1278
-
1279
- // Exclude collateralized vesting inventory because those tokens have been burned into distributor-held loans.
1280
- uint256 vestingAmount = totalVestingAmountOf[hook][token] - totalLoanedVestingAmountOf[hook][token];
1281
-
1282
- // Take a snapshot using the hook's tracked balance.
1283
- snapshot = JBTokenSnapshotData({balance: _balanceOf[hook][token], vestingAmount: vestingAmount});
1284
-
1285
- // Store the snapshot and mark it initialized.
1286
- _snapshotAtRoundOf[hook][token][round] = snapshot;
1287
- _snapshotInitializedFor[hook][token][round] = true;
1288
-
1289
- emit SnapshotCreated({
1290
- hook: hook,
1291
- round: round,
1292
- token: token,
1293
- balance: snapshot.balance,
1294
- vestingAmount: snapshot.vestingAmount,
1295
- caller: msg.sender
1296
- });
1297
- }
1298
-
1299
1253
  /// @notice The deadline for a reward round using this distributor's immutable claim duration.
1300
1254
  /// @param round The reward round.
1301
1255
  /// @return claimDeadline The deadline timestamp. Zero means no expiration.
@@ -1321,12 +1275,14 @@ abstract contract JBDistributor is IJBDistributor {
1321
1275
 
1322
1276
  /// @notice Unlocks rewards for the given token IDs and tokens, either for collection or forfeiture.
1323
1277
  /// @param hook The hook the tokens belong to.
1278
+ /// @param groupId The reward group (0 = the default group).
1324
1279
  /// @param tokenIds The IDs of the tokens to unlock rewards for.
1325
1280
  /// @param tokens The addresses of the tokens to unlock.
1326
1281
  /// @param beneficiary The recipient of the unlocked tokens.
1327
1282
  /// @param ownerClaim Whether this is a claim by the owner (true) or a forfeiture release (false).
1328
1283
  function _unlockRewards(
1329
1284
  address hook,
1285
+ uint256 groupId,
1330
1286
  uint256[] calldata tokenIds,
1331
1287
  IERC20[] calldata tokens,
1332
1288
  address beneficiary,
@@ -1341,7 +1297,8 @@ abstract contract JBDistributor is IJBDistributor {
1341
1297
  IERC20 token = tokens[i];
1342
1298
 
1343
1299
  // Process all token IDs for this reward token.
1344
- uint256 totalTokenAmount = _unlockTokenIds({hook: hook, tokenIds: tokenIds, token: token, round: round});
1300
+ uint256 totalTokenAmount =
1301
+ _unlockTokenIds({hook: hook, groupId: groupId, tokenIds: tokenIds, token: token, round: round});
1345
1302
 
1346
1303
  // Perform the transfer.
1347
1304
  if (totalTokenAmount != 0) {
@@ -1368,7 +1325,7 @@ abstract contract JBDistributor is IJBDistributor {
1368
1325
  }
1369
1326
  } else {
1370
1327
  // If forfeiture: keep inventory in the distributor and give the current staker set a fresh round.
1371
- _recordRewardRound({hook: hook, token: token, amount: totalTokenAmount});
1328
+ _recordRewardRound({hook: hook, groupId: groupId, token: token, amount: totalTokenAmount});
1372
1329
  emit ForfeitedRewardsRecycled({
1373
1330
  hook: hook, round: round, token: token, amount: totalTokenAmount, caller: msg.sender
1374
1331
  });
@@ -1383,12 +1340,14 @@ abstract contract JBDistributor is IJBDistributor {
1383
1340
 
1384
1341
  /// @notice Unlocks rewards for a set of token IDs for a single reward token.
1385
1342
  /// @param hook The hook the tokens belong to.
1343
+ /// @param groupId The reward group (0 = the default group).
1386
1344
  /// @param tokenIds The IDs of the tokens to unlock rewards for.
1387
1345
  /// @param token The reward token to unlock.
1388
1346
  /// @param round The current round.
1389
1347
  /// @return totalTokenAmount The total amount of reward tokens unlocked.
1390
1348
  function _unlockTokenIds(
1391
1349
  address hook,
1350
+ uint256 groupId,
1392
1351
  uint256[] calldata tokenIds,
1393
1352
  IERC20 token,
1394
1353
  uint256 round
@@ -1400,13 +1359,13 @@ abstract contract JBDistributor is IJBDistributor {
1400
1359
  uint256 tokenId = tokenIds[j];
1401
1360
 
1402
1361
  // Loan collateral stays locked until repayment restores it to this distributor.
1403
- _requireNoActiveVestingLoan({hook: hook, tokenId: tokenId, token: token});
1362
+ _requireNoActiveVestingLoan({hook: hook, groupId: groupId, tokenId: tokenId, token: token});
1404
1363
 
1405
1364
  // Keep a reference to the latest vested index.
1406
- uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
1365
+ uint256 vestedIndex = latestVestedIndexOf[hook][groupId][tokenId][token];
1407
1366
 
1408
1367
  // Keep a reference to the vesting data array.
1409
- JBVestingData[] storage vestings = vestingDataOf[hook][tokenId][token];
1368
+ JBVestingData[] storage vestings = vestingDataOf[hook][groupId][tokenId][token];
1410
1369
  uint256 numberOfVestingRounds = vestings.length;
1411
1370
 
1412
1371
  // Keep a reference to a vested index that will be incremented.
@@ -1441,6 +1400,7 @@ abstract contract JBDistributor is IJBDistributor {
1441
1400
  emit Collected({
1442
1401
  hook: hook,
1443
1402
  tokenId: tokenId,
1403
+ groupId: groupId,
1444
1404
  token: token,
1445
1405
  amount: claimAmount,
1446
1406
  vestingReleaseRound: vesting.releaseRound,
@@ -1462,7 +1422,7 @@ abstract contract JBDistributor is IJBDistributor {
1462
1422
  }
1463
1423
  }
1464
1424
 
1465
- latestVestedIndexOf[hook][tokenId][token] = newLatestVestedIndex;
1425
+ latestVestedIndexOf[hook][groupId][tokenId][token] = newLatestVestedIndex;
1466
1426
 
1467
1427
  unchecked {
1468
1428
  ++j;
@@ -1470,93 +1430,77 @@ abstract contract JBDistributor is IJBDistributor {
1470
1430
  }
1471
1431
  }
1472
1432
 
1473
- /// @notice Vests each token ID for a given reward token and returns the total amount vested.
1474
- /// @dev Silently skips already-vested tokenIds instead of reverting, to support auto-vest.
1475
- /// @param hook The hook whose stakers are vesting.
1476
- /// @param tokenIds The IDs to claim rewards for.
1477
- /// @param token The reward token.
1478
- /// @param distributable The distributable amount for this round.
1479
- /// @param totalStakeAmount The total stake amount.
1480
- /// @param vestingReleaseRound The round at which vesting will be released.
1481
- /// @return totalVestingAmount The total amount that began vesting.
1482
- function _vestTokenIds(
1433
+ //*********************************************************************//
1434
+ // ----------------------- internal views ---------------------------- //
1435
+ //*********************************************************************//
1436
+
1437
+ /// @notice The collectable (unlocked, uncollected) amount for a token ID in a specific reward group.
1438
+ /// @param hook The hook the tokenId belongs to.
1439
+ /// @param groupId The reward group (0 = the default group).
1440
+ /// @param tokenId The ID of the staker token to calculate for.
1441
+ /// @param token The reward token to check.
1442
+ /// @return tokenAmount The amount of tokens that can be collected right now.
1443
+ function _collectableFor(
1483
1444
  address hook,
1484
- uint256[] calldata tokenIds,
1485
- IERC20 token,
1486
- uint256 distributable,
1487
- uint256 totalStakeAmount,
1488
- uint256 vestingReleaseRound
1445
+ uint256 groupId,
1446
+ uint256 tokenId,
1447
+ IERC20 token
1489
1448
  )
1490
1449
  internal
1491
- virtual
1492
- returns (uint256 totalVestingAmount)
1450
+ view
1451
+ returns (uint256 tokenAmount)
1493
1452
  {
1494
- for (uint256 j; j < tokenIds.length;) {
1495
- uint256 tokenId = tokenIds[j];
1453
+ // A loan keeps this token ID's vesting rewards in collateral custody until the loan is repaid.
1454
+ if (activeVestingLoanIdOf[hook][groupId][tokenId][token] != 0) return 0;
1496
1455
 
1497
- // Skip burned tokens they are excluded from _totalStake, so including them would overbook vesting.
1498
- if (_tokenBurned({hook: hook, tokenId: tokenId})) {
1499
- unchecked {
1500
- ++j;
1501
- }
1502
- continue;
1503
- }
1456
+ // The round that we are in right now.
1457
+ uint256 round = currentRound();
1504
1458
 
1505
- // Keep a reference to the vesting data for this hook/tokenId/token.
1506
- JBVestingData[] storage vestings = vestingDataOf[hook][tokenId][token];
1459
+ // Keep a reference to the latest vested index.
1460
+ uint256 vestedIndex = latestVestedIndexOf[hook][groupId][tokenId][token];
1507
1461
 
1508
- // Skip if this token has already been vested for this round (same releaseRound).
1509
- uint256 numVesting = vestings.length;
1510
- if (numVesting != 0 && vestings[numVesting - 1].releaseRound == vestingReleaseRound) {
1511
- unchecked {
1512
- ++j;
1513
- }
1514
- continue;
1515
- }
1462
+ // Keep a reference to the vesting data array.
1463
+ JBVestingData[] storage vestings = vestingDataOf[hook][groupId][tokenId][token];
1464
+ uint256 numberOfVestingRounds = vestings.length;
1516
1465
 
1517
- // Keep a reference to the amount of tokens being claimed.
1518
- uint256 tokenAmount = mulDiv({
1519
- x: distributable, y: _tokenStake({hook: hook, tokenId: tokenId}), denominator: totalStakeAmount
1520
- });
1466
+ while (vestedIndex < numberOfVestingRounds) {
1467
+ uint256 lockedShare;
1521
1468
 
1522
- // Skip zero-amount entries to prevent stalling latestVestedIndexOf advancement.
1523
- if (tokenAmount == 0) {
1524
- unchecked {
1525
- ++j;
1526
- }
1527
- continue;
1528
- }
1469
+ // Keep a reference to the vested data being iterated on.
1470
+ JBVestingData memory vesting = vestings[vestedIndex];
1529
1471
 
1530
- // Add to the list of vesting data.
1531
- vestings.push(JBVestingData({releaseRound: vestingReleaseRound, amount: tokenAmount, shareClaimed: 0}));
1472
+ lockedShare = JBVestingMath.lockedShareOf({
1473
+ releaseRound: vesting.releaseRound,
1474
+ currentRound: round,
1475
+ vestingRounds: VESTING_ROUNDS,
1476
+ maxShare: MAX_SHARE
1477
+ });
1532
1478
 
1533
- emit Claimed({
1534
- hook: hook,
1535
- tokenId: tokenId,
1536
- token: token,
1537
- amount: tokenAmount,
1538
- vestingReleaseRound: vestingReleaseRound,
1539
- caller: msg.sender
1479
+ // Calculate the newly unlocked amount from cumulative shares rather than the incremental share delta.
1480
+ // Incremental floor rounding can otherwise underpay partial collections and leave dust stranded.
1481
+ (uint256 claimAmount,) = JBVestingMath.newlyClaimableAmountOf({
1482
+ amount: vesting.amount,
1483
+ shareClaimed: vesting.shareClaimed,
1484
+ lockedShare: lockedShare,
1485
+ maxShare: MAX_SHARE
1540
1486
  });
1487
+ tokenAmount += claimAmount;
1541
1488
 
1542
1489
  unchecked {
1543
- totalVestingAmount += tokenAmount;
1544
- ++j;
1490
+ ++vestedIndex;
1545
1491
  }
1546
1492
  }
1547
1493
  }
1548
1494
 
1549
- //*********************************************************************//
1550
- // ----------------------- internal views ---------------------------- //
1551
- //*********************************************************************//
1552
-
1553
1495
  /// @notice The remaining uncollected vesting amount for one token ID and reward token.
1554
1496
  /// @param hook The hook the token ID belongs to.
1497
+ /// @param groupId The reward group (0 = the default group).
1555
1498
  /// @param tokenId The token ID to check.
1556
1499
  /// @param token The reward token to check.
1557
1500
  /// @return tokenAmount The amount still locked or unlocked-but-uncollected.
1558
1501
  function _unclaimedVestingAmountOf(
1559
1502
  address hook,
1503
+ uint256 groupId,
1560
1504
  uint256 tokenId,
1561
1505
  IERC20 token
1562
1506
  )
@@ -1565,10 +1509,10 @@ abstract contract JBDistributor is IJBDistributor {
1565
1509
  returns (uint256 tokenAmount)
1566
1510
  {
1567
1511
  // Keep a reference to the latest fully vested index.
1568
- uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
1512
+ uint256 vestedIndex = latestVestedIndexOf[hook][groupId][tokenId][token];
1569
1513
 
1570
1514
  // Keep a reference to the vesting data array.
1571
- JBVestingData[] storage vestings = vestingDataOf[hook][tokenId][token];
1515
+ JBVestingData[] storage vestings = vestingDataOf[hook][groupId][tokenId][token];
1572
1516
  uint256 numberOfVestingRounds = vestings.length;
1573
1517
 
1574
1518
  while (vestedIndex < numberOfVestingRounds) {
@@ -1604,10 +1548,11 @@ abstract contract JBDistributor is IJBDistributor {
1604
1548
 
1605
1549
  /// @notice Revert if a token ID's vesting rewards are locked in a distributor-owned loan.
1606
1550
  /// @param hook The hook the token ID belongs to.
1551
+ /// @param groupId The reward group (0 = the default group).
1607
1552
  /// @param tokenId The token ID to check.
1608
1553
  /// @param token The reward token to check.
1609
- function _requireNoActiveVestingLoan(address hook, uint256 tokenId, IERC20 token) internal view {
1610
- uint256 loanId = activeVestingLoanIdOf[hook][tokenId][token];
1554
+ function _requireNoActiveVestingLoan(address hook, uint256 groupId, uint256 tokenId, IERC20 token) internal view {
1555
+ uint256 loanId = activeVestingLoanIdOf[hook][groupId][tokenId][token];
1611
1556
  if (loanId != 0) {
1612
1557
  revert JBDistributor_VestingLoanOutstanding({
1613
1558
  hook: hook, tokenId: tokenId, token: address(token), loanId: loanId
@@ -1623,17 +1568,26 @@ abstract contract JBDistributor is IJBDistributor {
1623
1568
  function _tokenBurned(address hook, uint256 tokenId) internal view virtual returns (bool tokenWasBurned);
1624
1569
 
1625
1570
  /// @notice The stake weight of a specific token ID, used to calculate its pro-rata share of distributions.
1626
- /// For 721 distributors this is the tier's voting units; for token distributors this is delegated voting power.
1571
+ /// @dev Subclasses define how stake is measured.
1627
1572
  /// @param hook The hook the token belongs to.
1628
1573
  /// @param tokenId The ID of the token to get the stake weight of.
1629
1574
  /// @return tokenStakeAmount The stake weight represented by this token ID.
1630
1575
  function _tokenStake(address hook, uint256 tokenId) internal view virtual returns (uint256 tokenStakeAmount);
1631
1576
 
1632
- /// @notice The total stake across all token IDs at a given block. Used as the denominator when calculating each
1633
- /// token ID's pro-rata share. For 721 distributors this is `getPastTotalSupply` from the checkpoints module;
1634
- /// for token distributors this is `getPastTotalSupply` from the IVotes token.
1577
+ /// @notice The total stake sharing a group's round rewards at a given block. Used as the denominator when
1578
+ /// calculating each token ID's pro-rata share.
1579
+ /// @dev Subclasses define how the per-group total stake is measured.
1635
1580
  /// @param hook The hook to get the total stake for.
1581
+ /// @param groupId The reward group (0 = the default group).
1636
1582
  /// @param blockNumber The block number to query (must be strictly in the past).
1637
1583
  /// @return totalStakedAmount The total stake at the given block.
1638
- function _totalStake(address hook, uint256 blockNumber) internal view virtual returns (uint256 totalStakedAmount);
1584
+ function _totalStake(
1585
+ address hook,
1586
+ uint256 groupId,
1587
+ uint256 blockNumber
1588
+ )
1589
+ internal
1590
+ view
1591
+ virtual
1592
+ returns (uint256 totalStakedAmount);
1639
1593
  }