@bananapus/distributor-v6 0.0.34 → 0.0.36

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.
@@ -143,17 +143,23 @@ abstract contract JBDistributor is IJBDistributor {
143
143
 
144
144
  /// @notice The active Revnet loan using one token ID's vesting rewards as collateral.
145
145
  /// @custom:param hook The hook the token ID belongs to.
146
+ /// @custom:param groupId The reward group (0 = the default group).
146
147
  /// @custom:param tokenId The token ID whose vesting rewards are collateralized.
147
148
  /// @custom:param token The reward token used as loan collateral.
148
- 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
+ )
149
152
  public
150
153
  override activeVestingLoanIdOf;
151
154
 
152
155
  /// @notice The index within `vestingDataOf` of the latest vest.
153
156
  /// @custom:param hook The hook the tokenId belongs to.
157
+ /// @custom:param groupId The reward group (0 = the default group).
154
158
  /// @custom:param tokenId The ID of the token to which the vests belong.
155
159
  /// @custom:param token The address of the token vested.
156
- 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;
157
163
 
158
164
  /// @notice The block number recorded as the snapshot point for each round.
159
165
  /// @dev Set to `block.number - 1` on first interaction in a round, so that `IVotes.getPastVotes` works.
@@ -161,9 +167,12 @@ abstract contract JBDistributor is IJBDistributor {
161
167
 
162
168
  /// @notice Reward data assigned to each funding round.
163
169
  /// @custom:param hook The stake source whose stakers receive rewards.
170
+ /// @custom:param groupId The reward group (0 = the default group).
164
171
  /// @custom:param token The reward token.
165
172
  /// @custom:param round The reward round.
166
- 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;
167
176
 
168
177
  /// @notice The amount of a token that is currently vesting for a hook's stakers.
169
178
  /// @custom:param hook The hook whose stakers are vesting.
@@ -177,9 +186,12 @@ abstract contract JBDistributor is IJBDistributor {
177
186
 
178
187
  /// @notice All vesting data of a tokenId for any number of vesting tokens.
179
188
  /// @custom:param hook The hook the tokenId belongs to.
189
+ /// @custom:param groupId The reward group (0 = the default group).
180
190
  /// @custom:param tokenId The ID of the token to which the vests belong.
181
191
  /// @custom:param token The address of the token vested.
182
- 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;
183
195
 
184
196
  //*********************************************************************//
185
197
  // -------------------- internal stored properties ------------------- //
@@ -269,19 +281,7 @@ abstract contract JBDistributor is IJBDistributor {
269
281
  virtual
270
282
  override
271
283
  {
272
- // Reward accounting cannot change while an ERC-20 `transferFrom` is in progress. A callback-capable reward
273
- // token could otherwise vest or collect against balances mid-transfer, distorting the credited delta.
274
- _requireNotAcceptingToken();
275
-
276
- // Revert if no token IDs are provided.
277
- if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
278
-
279
- // Only the entity authorized for these token IDs (current NFT owner / encoded staker) may start their
280
- // vesting clock — third parties must not start it for them.
281
- _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
282
-
283
- // Materialize all unclaimed historical reward rounds into fresh vesting entries that start now.
284
- _claimPastRewards({hook: hook, tokenIds: tokenIds, tokens: tokens});
284
+ _beginVesting({hook: hook, groupId: 0, tokenIds: tokenIds, tokens: tokens});
285
285
  }
286
286
 
287
287
  /// @notice Directly fund the distributor for a specific hook by pulling tokens from the caller. An alternative
@@ -292,7 +292,7 @@ abstract contract JBDistributor is IJBDistributor {
292
292
  /// @param token The token to fund with.
293
293
  /// @param amount The amount to fund (ignored for native ETH — `msg.value` is used instead).
294
294
  function fund(address hook, IERC20 token, uint256 amount) external payable virtual override {
295
- _fund({hook: hook, token: token, amount: amount});
295
+ _fund({hook: hook, groupId: 0, token: token, amount: amount});
296
296
  }
297
297
 
298
298
  /// @notice Recycle unclaimed rewards from expired reward rounds into the current reward round.
@@ -311,19 +311,7 @@ abstract contract JBDistributor is IJBDistributor {
311
311
  override
312
312
  returns (uint256 amount)
313
313
  {
314
- // Do not let reward-token callbacks recycle inventory during an inbound balance-delta measurement.
315
- _requireNotAcceptingToken();
316
-
317
- // Process every requested round independently so callers can batch keeper work.
318
- for (uint256 i; i < rounds.length;) {
319
- // Add this round's expired remainder to the batch total.
320
- amount += _recycleExpiredRewardRound({hook: hook, token: token, round: rounds[i]});
321
-
322
- unchecked {
323
- // Safe because the loop is bounded by calldata length.
324
- ++i;
325
- }
326
- }
314
+ amount = _burnExpiredRewards({hook: hook, groupId: 0, token: token, rounds: rounds});
327
315
  }
328
316
 
329
317
  /// @notice Record the snapshot block for the current round (and eagerly for the next round). Callable by anyone —
@@ -347,21 +335,7 @@ abstract contract JBDistributor is IJBDistributor {
347
335
  external
348
336
  override
349
337
  {
350
- // Do not let reward-token callbacks mutate vesting state during inbound balance-delta accounting.
351
- _requireNotAcceptingToken();
352
-
353
- // Make sure that all staker token IDs are burned.
354
- for (uint256 i; i < tokenIds.length;) {
355
- if (!_tokenBurned({hook: hook, tokenId: tokenIds[i]})) {
356
- revert JBDistributor_NoAccess({hook: hook, tokenId: tokenIds[i], account: msg.sender});
357
- }
358
- unchecked {
359
- ++i;
360
- }
361
- }
362
-
363
- // Unlock the rewards and recycle the forfeited amount.
364
- _unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: false});
338
+ _releaseForfeitedRewards({hook: hook, groupId: 0, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary});
365
339
  }
366
340
 
367
341
  //*********************************************************************//
@@ -391,7 +365,7 @@ abstract contract JBDistributor is IJBDistributor {
391
365
  override
392
366
  returns (uint256 tokenAmount)
393
367
  {
394
- tokenAmount = _unclaimedVestingAmountOf({hook: hook, tokenId: tokenId, token: token});
368
+ tokenAmount = _unclaimedVestingAmountOf({hook: hook, groupId: 0, tokenId: tokenId, token: token});
395
369
  }
396
370
 
397
371
  /// @notice Calculate how much of a reward token is currently unlocked and ready to be collected for a given
@@ -410,46 +384,7 @@ abstract contract JBDistributor is IJBDistributor {
410
384
  override
411
385
  returns (uint256 tokenAmount)
412
386
  {
413
- // A loan keeps this token ID's vesting rewards in collateral custody until the loan is repaid.
414
- if (activeVestingLoanIdOf[hook][tokenId][token] != 0) return 0;
415
-
416
- // The round that we are in right now.
417
- uint256 round = currentRound();
418
-
419
- // Keep a reference to the latest vested index.
420
- uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
421
-
422
- // Keep a reference to the vesting data array.
423
- JBVestingData[] storage vestings = vestingDataOf[hook][tokenId][token];
424
- uint256 numberOfVestingRounds = vestings.length;
425
-
426
- while (vestedIndex < numberOfVestingRounds) {
427
- uint256 lockedShare;
428
-
429
- // Keep a reference to the vested data being iterated on.
430
- JBVestingData memory vesting = vestings[vestedIndex];
431
-
432
- lockedShare = JBVestingMath.lockedShareOf({
433
- releaseRound: vesting.releaseRound,
434
- currentRound: round,
435
- vestingRounds: VESTING_ROUNDS,
436
- maxShare: MAX_SHARE
437
- });
438
-
439
- // Calculate the newly unlocked amount from cumulative shares rather than the incremental share delta.
440
- // Incremental floor rounding can otherwise underpay partial collections and leave dust stranded.
441
- (uint256 claimAmount,) = JBVestingMath.newlyClaimableAmountOf({
442
- amount: vesting.amount,
443
- shareClaimed: vesting.shareClaimed,
444
- lockedShare: lockedShare,
445
- maxShare: MAX_SHARE
446
- });
447
- tokenAmount += claimAmount;
448
-
449
- unchecked {
450
- ++vestedIndex;
451
- }
452
- }
387
+ tokenAmount = _collectableFor({hook: hook, groupId: 0, tokenId: tokenId, token: token});
453
388
  }
454
389
 
455
390
  /// @notice The vesting position collateralized by a Revnet loan.
@@ -495,21 +430,7 @@ abstract contract JBDistributor is IJBDistributor {
495
430
  virtual
496
431
  override
497
432
  {
498
- // Collections transfer reward tokens out; block them mid inbound transfer so the outgoing transfer cannot
499
- // net against the incoming balance delta and strand the new funds unaccounted.
500
- _requireNotAcceptingToken();
501
-
502
- // Revert if no token IDs are provided.
503
- if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
504
-
505
- // Only the entity authorized for these token IDs may materialize and collect their rewards.
506
- _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
507
-
508
- // Before collecting, bring the token IDs current by starting vesting for any past reward rounds.
509
- _claimPastRewards({hook: hook, tokenIds: tokenIds, tokens: tokens});
510
-
511
- // Release whatever portion of existing vesting entries has unlocked by this round.
512
- _unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: true});
433
+ _collectVestedRewards({hook: hook, groupId: 0, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary});
513
434
  }
514
435
 
515
436
  /// @notice Borrow from a revnet using one token ID's uncollected vesting rewards as collateral.
@@ -538,41 +459,16 @@ abstract contract JBDistributor is IJBDistributor {
538
459
  override
539
460
  returns (uint256 loanId, uint256 collateralCount)
540
461
  {
541
- // Do not let reward-token callbacks mutate claim accounting during an inbound transfer.
542
- _requireNotAcceptingToken();
543
-
544
- // Revert if no token IDs are provided.
545
- if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
546
-
547
- // One distributor-held Revnet loan tracks one token ID so one repayment restores one vesting schedule.
548
- if (tokenIds.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokenIds.length});
549
-
550
- // One loan collateralizes one revnet reward token.
551
- if (tokens.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokens.length});
552
-
553
- // Zero vesting means rewards are immediately collectible, so there is no locked position to borrow against.
554
- if (VESTING_ROUNDS == 0) revert JBDistributor_VestingLoansDisabled();
555
-
556
- // Revnet loan-backed collection is disabled unless a trusted loans contract was set at deployment.
557
- if (address(REV_LOANS) == address(0)) revert JBDistributor_RevnetLoansNotConfigured();
558
-
559
- // Make sure that all tokens can be claimed by this sender.
560
- _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
561
-
562
- // Bundle the remaining borrow parameters to keep the loan workflow readable and stack-safe.
563
- JBBorrowContext memory ctx = JBBorrowContext({
462
+ (loanId, collateralCount) = _borrowAgainstVestingFor({
564
463
  hook: hook,
565
- tokenId: tokenIds[0],
566
- token: tokens[0],
464
+ groupId: 0,
465
+ tokenIds: tokenIds,
466
+ tokens: tokens,
567
467
  sourceToken: sourceToken,
568
468
  minBorrowAmount: minBorrowAmount,
569
469
  prepaidFeePercent: prepaidFeePercent,
570
- beneficiary: beneficiary,
571
- revnetId: _revnetIdOf(tokens[0])
470
+ beneficiary: beneficiary
572
471
  });
573
-
574
- // Open and track the distributor-owned loan.
575
- (loanId, collateralCount) = _borrowAgainstVesting({ctx: ctx, tokenIds: tokenIds, tokens: tokens});
576
472
  }
577
473
 
578
474
  /// @notice Repay a distributor-held Revnet loan and restore its collateral to the original vesting schedule.
@@ -611,15 +507,18 @@ abstract contract JBDistributor is IJBDistributor {
611
507
  // Measure any returned project tokens while excluding any source-token payment effects.
612
508
  uint256 rewardBalanceBefore = vestingLoan.token.balanceOf(address(this));
613
509
 
614
- // Repay through this distributor because it owns the loan NFT and must receive the returned collateral.
615
- paidOffLoanId = _repayLoanSource({
510
+ // Repay through this distributor because it owns the loan NFT and must receive the returned collateral. Any
511
+ // native overpayment is reported back so it can be refunded only after this loan's state is fully settled.
512
+ uint256 nativeRefundAmount;
513
+ (paidOffLoanId, nativeRefundAmount) = _repayLoanSource({
616
514
  loanId: loanId,
617
515
  loan: loan,
618
516
  repayBorrowAmount: repayBorrowAmount,
619
517
  collateralCount: vestingLoan.collateralCount
620
518
  });
621
519
 
622
- // Restore the collateral to inventory while preserving the original vesting data untouched.
520
+ // Restore the collateral to inventory while preserving the original vesting data untouched. This deletes the
521
+ // loan record and decrements the loaned-vesting inventory before any value leaves the contract.
623
522
  _restoreVestingCollateral({
624
523
  loanId: loanId,
625
524
  paidOffLoanId: paidOffLoanId,
@@ -627,6 +526,15 @@ abstract contract JBDistributor is IJBDistributor {
627
526
  rewardBalanceBefore: rewardBalanceBefore,
628
527
  repayBorrowAmount: repayBorrowAmount
629
528
  });
529
+
530
+ // Return any native overpayment last, following checks-effects-interactions. The loan is already settled, so a
531
+ // re-entrant call during this transfer cannot observe a half-settled loan.
532
+ if (nativeRefundAmount != 0) {
533
+ (bool success,) = msg.sender.call{value: nativeRefundAmount}("");
534
+ if (!success) {
535
+ revert JBDistributor_NativeTransferFailed({beneficiary: msg.sender, amount: nativeRefundAmount});
536
+ }
537
+ }
630
538
  }
631
539
 
632
540
  /// @notice Write off a distributor-held Revnet loan after Revnet liquidation permanently destroys its collateral.
@@ -655,15 +563,210 @@ abstract contract JBDistributor is IJBDistributor {
655
563
 
656
564
  /// @notice Claim all past reward rounds for the given token IDs and reward tokens into fresh vesting entries.
657
565
  /// @param hook The hook whose stakers are claiming.
566
+ /// @param groupId The reward group being claimed (0 = the default group).
658
567
  /// @param tokenIds The token IDs to claim for.
659
568
  /// @param tokens The reward tokens to claim.
660
- function _claimPastRewards(address hook, uint256[] calldata tokenIds, IERC20[] calldata tokens) internal virtual;
569
+ function _claimPastRewards(
570
+ address hook,
571
+ uint256 groupId,
572
+ uint256[] calldata tokenIds,
573
+ IERC20[] calldata tokens
574
+ )
575
+ internal
576
+ virtual;
661
577
 
662
578
  /// @notice Revert unless the caller is authorized to claim each token ID.
663
579
  /// @param hook The hook whose token IDs are being checked.
664
580
  /// @param tokenIds The token IDs to check.
665
581
  function _requireCanClaimTokenIds(address hook, uint256[] calldata tokenIds) internal view virtual;
666
582
 
583
+ /// @notice Shared begin-vesting logic across reward groups.
584
+ /// @param hook The hook whose stakers are vesting.
585
+ /// @param groupId The reward group (0 = the default group).
586
+ /// @param tokenIds The staker token IDs to claim rewards for.
587
+ /// @param tokens The reward tokens to begin vesting.
588
+ function _beginVesting(
589
+ address hook,
590
+ uint256 groupId,
591
+ uint256[] calldata tokenIds,
592
+ IERC20[] calldata tokens
593
+ )
594
+ internal
595
+ {
596
+ // Reward accounting cannot change while an ERC-20 `transferFrom` is in progress.
597
+ _requireNotAcceptingToken();
598
+
599
+ // Revert if no token IDs are provided.
600
+ if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
601
+
602
+ // Only the entity authorized for these token IDs may start their vesting clock.
603
+ _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
604
+
605
+ // Materialize all unclaimed historical reward rounds into fresh vesting entries that start now.
606
+ _claimPastRewards({hook: hook, groupId: groupId, tokenIds: tokenIds, tokens: tokens});
607
+ }
608
+
609
+ /// @notice Shared begin-vesting-then-collect logic across reward groups.
610
+ /// @param hook The hook whose stakers are collecting.
611
+ /// @param groupId The reward group (0 = the default group).
612
+ /// @param tokenIds The token IDs to collect for.
613
+ /// @param tokens The reward tokens to collect.
614
+ /// @param beneficiary The recipient of the collected tokens.
615
+ function _collectVestedRewards(
616
+ address hook,
617
+ uint256 groupId,
618
+ uint256[] calldata tokenIds,
619
+ IERC20[] calldata tokens,
620
+ address beneficiary
621
+ )
622
+ internal
623
+ {
624
+ // Collections transfer reward tokens out; block them mid inbound transfer.
625
+ _requireNotAcceptingToken();
626
+
627
+ // Revert if no token IDs are provided.
628
+ if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
629
+
630
+ // Only the entity authorized for these token IDs may materialize and collect their rewards.
631
+ _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
632
+
633
+ // Before collecting, bring the token IDs current by starting vesting for any past reward rounds.
634
+ _claimPastRewards({hook: hook, groupId: groupId, tokenIds: tokenIds, tokens: tokens});
635
+
636
+ // Release whatever portion of existing vesting entries has unlocked by this round.
637
+ _unlockRewards({
638
+ hook: hook, groupId: groupId, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: true
639
+ });
640
+ }
641
+
642
+ /// @notice Shared forfeiture-release logic across reward groups.
643
+ /// @param hook The hook whose tokens were burned.
644
+ /// @param groupId The reward group (0 = the default group).
645
+ /// @param tokenIds The IDs of the burned tokens.
646
+ /// @param tokens The reward tokens to recycle.
647
+ /// @param beneficiary Unused for forfeiture. Kept for interface compatibility.
648
+ function _releaseForfeitedRewards(
649
+ address hook,
650
+ uint256 groupId,
651
+ uint256[] calldata tokenIds,
652
+ IERC20[] calldata tokens,
653
+ address beneficiary
654
+ )
655
+ internal
656
+ {
657
+ // Do not let reward-token callbacks mutate vesting state during inbound balance-delta accounting.
658
+ _requireNotAcceptingToken();
659
+
660
+ // Make sure that all staker token IDs are burned.
661
+ for (uint256 i; i < tokenIds.length;) {
662
+ if (!_tokenBurned({hook: hook, tokenId: tokenIds[i]})) {
663
+ revert JBDistributor_NoAccess({hook: hook, tokenId: tokenIds[i], account: msg.sender});
664
+ }
665
+ unchecked {
666
+ ++i;
667
+ }
668
+ }
669
+
670
+ // Unlock the rewards and recycle the forfeited amount.
671
+ _unlockRewards({
672
+ hook: hook,
673
+ groupId: groupId,
674
+ tokenIds: tokenIds,
675
+ tokens: tokens,
676
+ beneficiary: beneficiary,
677
+ ownerClaim: false
678
+ });
679
+ }
680
+
681
+ /// @notice Shared expired-reward recycling logic across reward groups.
682
+ /// @param hook The hook whose expired rewards should be recycled.
683
+ /// @param groupId The reward group (0 = the default group).
684
+ /// @param token The reward token to recycle.
685
+ /// @param rounds The reward rounds to recycle.
686
+ /// @return amount The total amount recycled.
687
+ function _burnExpiredRewards(
688
+ address hook,
689
+ uint256 groupId,
690
+ IERC20 token,
691
+ uint256[] calldata rounds
692
+ )
693
+ internal
694
+ returns (uint256 amount)
695
+ {
696
+ // Do not let reward-token callbacks recycle inventory during an inbound balance-delta measurement.
697
+ _requireNotAcceptingToken();
698
+
699
+ // Process every requested round independently so callers can batch keeper work.
700
+ for (uint256 i; i < rounds.length;) {
701
+ amount += _recycleExpiredRewardRound({hook: hook, groupId: groupId, token: token, round: rounds[i]});
702
+ unchecked {
703
+ ++i;
704
+ }
705
+ }
706
+ }
707
+
708
+ /// @notice Shared borrow-against-vesting logic across reward groups.
709
+ /// @param hook The hook whose staker is borrowing against vesting rewards.
710
+ /// @param groupId The reward group (0 = the default group).
711
+ /// @param tokenIds The single token ID to borrow against.
712
+ /// @param tokens The single revnet reward token to collateralize.
713
+ /// @param sourceToken The token to borrow from the revnet.
714
+ /// @param minBorrowAmount The minimum amount to borrow, denominated in `sourceToken`.
715
+ /// @param prepaidFeePercent The fee percent to charge upfront.
716
+ /// @param beneficiary The recipient of the borrowed funds.
717
+ /// @return loanId The Revnet loan NFT ID held by this distributor.
718
+ /// @return collateralCount The amount of vesting rewards used as collateral.
719
+ function _borrowAgainstVestingFor(
720
+ address hook,
721
+ uint256 groupId,
722
+ uint256[] calldata tokenIds,
723
+ IERC20[] calldata tokens,
724
+ address sourceToken,
725
+ uint256 minBorrowAmount,
726
+ uint256 prepaidFeePercent,
727
+ address payable beneficiary
728
+ )
729
+ internal
730
+ returns (uint256 loanId, uint256 collateralCount)
731
+ {
732
+ // Do not let reward-token callbacks mutate claim accounting during an inbound transfer.
733
+ _requireNotAcceptingToken();
734
+
735
+ // Revert if no token IDs are provided.
736
+ if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
737
+
738
+ // One distributor-held Revnet loan tracks one token ID so one repayment restores one vesting schedule.
739
+ if (tokenIds.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokenIds.length});
740
+
741
+ // One loan collateralizes one revnet reward token.
742
+ if (tokens.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokens.length});
743
+
744
+ // Zero vesting means rewards are immediately collectible, so there is no locked position to borrow against.
745
+ if (VESTING_ROUNDS == 0) revert JBDistributor_VestingLoansDisabled();
746
+
747
+ // Revnet loan-backed collection is disabled unless a trusted loans contract was set at deployment.
748
+ if (address(REV_LOANS) == address(0)) revert JBDistributor_RevnetLoansNotConfigured();
749
+
750
+ // Make sure that all tokens can be claimed by this sender.
751
+ _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
752
+
753
+ // Bundle the remaining borrow parameters to keep the loan workflow readable and stack-safe.
754
+ JBBorrowContext memory ctx = JBBorrowContext({
755
+ hook: hook,
756
+ groupId: groupId,
757
+ tokenId: tokenIds[0],
758
+ token: tokens[0],
759
+ sourceToken: sourceToken,
760
+ minBorrowAmount: minBorrowAmount,
761
+ prepaidFeePercent: prepaidFeePercent,
762
+ beneficiary: beneficiary,
763
+ revnetId: _revnetIdOf(tokens[0])
764
+ });
765
+
766
+ // Open and track the distributor-owned loan.
767
+ (loanId, collateralCount) = _borrowAgainstVesting({ctx: ctx, tokenIds: tokenIds, tokens: tokens});
768
+ }
769
+
667
770
  /// @notice Open and track a distributor-held Revnet loan against one vesting position.
668
771
  /// @param ctx The borrow context.
669
772
  /// @param tokenIds The single token ID being collateralized.
@@ -679,7 +782,7 @@ abstract contract JBDistributor is IJBDistributor {
679
782
  returns (uint256 loanId, uint256 collateralCount)
680
783
  {
681
784
  // One vesting position cannot be collateralized by two outstanding loans.
682
- uint256 activeLoanId = activeVestingLoanIdOf[ctx.hook][ctx.tokenId][ctx.token];
785
+ uint256 activeLoanId = activeVestingLoanIdOf[ctx.hook][ctx.groupId][ctx.tokenId][ctx.token];
683
786
  if (activeLoanId != 0) {
684
787
  revert JBDistributor_VestingLoanOutstanding({
685
788
  hook: ctx.hook, tokenId: ctx.tokenId, token: address(ctx.token), loanId: activeLoanId
@@ -687,13 +790,14 @@ abstract contract JBDistributor is IJBDistributor {
687
790
  }
688
791
 
689
792
  // Bring the claimant current before measuring collateral.
690
- _claimPastRewards({hook: ctx.hook, tokenIds: tokenIds, tokens: tokens});
793
+ _claimPastRewards({hook: ctx.hook, groupId: ctx.groupId, tokenIds: tokenIds, tokens: tokens});
691
794
 
692
795
  // Use the remaining uncollected vesting amount as collateral without advancing the vesting schedule.
693
- collateralCount = _unclaimedVestingAmountOf({hook: ctx.hook, tokenId: ctx.tokenId, token: ctx.token});
796
+ collateralCount =
797
+ _unclaimedVestingAmountOf({hook: ctx.hook, groupId: ctx.groupId, tokenId: ctx.tokenId, token: ctx.token});
694
798
 
695
799
  // Remember the vesting-entry boundary so liquidation write-off cannot consume later rewards.
696
- uint48 vestingDataCount = _toUint48(vestingDataOf[ctx.hook][ctx.tokenId][ctx.token].length);
800
+ uint48 vestingDataCount = _toUint48(vestingDataOf[ctx.hook][ctx.groupId][ctx.tokenId][ctx.token].length);
697
801
 
698
802
  // A zero-collateral loan would revert in Revnet, but this local error explains why.
699
803
  if (collateralCount == 0) {
@@ -706,7 +810,7 @@ abstract contract JBDistributor is IJBDistributor {
706
810
  totalLoanedVestingAmountOf[ctx.hook][ctx.token] += collateralCount;
707
811
 
708
812
  // Block same-position reentrancy before the loan contract burns collateral and returns the real loan ID.
709
- activeVestingLoanIdOf[ctx.hook][ctx.tokenId][ctx.token] = _PENDING_VESTING_LOAN_ID;
813
+ activeVestingLoanIdOf[ctx.hook][ctx.groupId][ctx.tokenId][ctx.token] = _PENDING_VESTING_LOAN_ID;
710
814
 
711
815
  // Open the Revnet loan with this distributor as the holder whose tokens are burned as collateral.
712
816
  loanId = _openVestingLoan({ctx: ctx, collateralCount: collateralCount});
@@ -715,9 +819,10 @@ abstract contract JBDistributor is IJBDistributor {
715
819
  }
716
820
 
717
821
  // Track the distributor-held loan so repayment can restore the same vesting position.
718
- activeVestingLoanIdOf[ctx.hook][ctx.tokenId][ctx.token] = loanId;
822
+ activeVestingLoanIdOf[ctx.hook][ctx.groupId][ctx.tokenId][ctx.token] = loanId;
719
823
  _vestingLoanOf[loanId] = JBVestingLoan({
720
824
  hook: ctx.hook,
825
+ groupId: ctx.groupId,
721
826
  tokenId: ctx.tokenId,
722
827
  token: ctx.token,
723
828
  vestingDataCount: vestingDataCount,
@@ -764,11 +869,14 @@ abstract contract JBDistributor is IJBDistributor {
764
869
  }
765
870
 
766
871
  /// @notice Repay a Revnet loan with the source token it borrowed.
872
+ /// @dev Any native overpayment is reported via `nativeRefundAmount` instead of being refunded here, so the caller
873
+ /// can settle the loan's state before returning the overpayment (checks-effects-interactions).
767
874
  /// @param loanId The Revnet loan NFT ID to repay.
768
875
  /// @param loan The Revnet loan data.
769
876
  /// @param repayBorrowAmount The amount of source token needed to repay the loan.
770
877
  /// @param collateralCount The amount of collateral to return.
771
878
  /// @return paidOffLoanId The paid-off loan ID returned by Revnet loans.
879
+ /// @return nativeRefundAmount The native overpayment the caller must refund after settling the loan.
772
880
  function _repayLoanSource(
773
881
  uint256 loanId,
774
882
  REVLoan memory loan,
@@ -776,7 +884,7 @@ abstract contract JBDistributor is IJBDistributor {
776
884
  uint256 collateralCount
777
885
  )
778
886
  internal
779
- returns (uint256 paidOffLoanId)
887
+ returns (uint256 paidOffLoanId, uint256 nativeRefundAmount)
780
888
  {
781
889
  JBSingleAllowance memory allowance;
782
890
 
@@ -795,14 +903,8 @@ abstract contract JBDistributor is IJBDistributor {
795
903
  allowance: allowance
796
904
  });
797
905
 
798
- // Return any native overpayment to the caller.
799
- uint256 refundAmount = msg.value - repayBorrowAmount;
800
- if (refundAmount != 0) {
801
- (bool success,) = msg.sender.call{value: refundAmount}("");
802
- if (!success) {
803
- revert JBDistributor_NativeTransferFailed({beneficiary: msg.sender, amount: refundAmount});
804
- }
805
- }
906
+ // Report any native overpayment so the caller can refund it only after the loan's state is settled.
907
+ nativeRefundAmount = msg.value - repayBorrowAmount;
806
908
  } else {
807
909
  // ERC-20 repayments must not carry native ETH.
808
910
  if (msg.value != 0) {
@@ -867,7 +969,7 @@ abstract contract JBDistributor is IJBDistributor {
867
969
  totalLoanedVestingAmountOf[vestingLoan.hook][vestingLoan.token] -= vestingLoan.collateralCount;
868
970
 
869
971
  // Clear the lock that prevented this position from being collected while collateralized.
870
- delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
972
+ delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token];
871
973
  delete _vestingLoanOf[loanId];
872
974
 
873
975
  // Return any excess reward tokens created during source-fee payment to the repayer.
@@ -901,10 +1003,12 @@ abstract contract JBDistributor is IJBDistributor {
901
1003
  collateralCount = vestingLoan.collateralCount;
902
1004
 
903
1005
  // Load the vesting entries for the token ID whose rewards were collateralized.
904
- JBVestingData[] storage vestings = vestingDataOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
1006
+ JBVestingData[] storage vestings =
1007
+ vestingDataOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token];
905
1008
 
906
1009
  // Start at the first unexhausted vesting entry.
907
- uint256 vestedIndex = latestVestedIndexOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
1010
+ uint256 vestedIndex =
1011
+ latestVestedIndexOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token];
908
1012
 
909
1013
  // Stop at the boundary recorded when the loan opened, preserving newer vesting entries.
910
1014
  uint256 vestingDataCount = vestingLoan.vestingDataCount;
@@ -920,7 +1024,7 @@ abstract contract JBDistributor is IJBDistributor {
920
1024
  }
921
1025
 
922
1026
  // Skip over the written-off vesting entries without ever moving the cursor backwards.
923
- latestVestedIndexOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token] = vestedIndex;
1027
+ latestVestedIndexOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token] = vestedIndex;
924
1028
 
925
1029
  // Remove the liquidated collateral from the amount still considered vesting.
926
1030
  totalVestingAmountOf[vestingLoan.hook][vestingLoan.token] -= collateralCount;
@@ -929,7 +1033,7 @@ abstract contract JBDistributor is IJBDistributor {
929
1033
  totalLoanedVestingAmountOf[vestingLoan.hook][vestingLoan.token] -= collateralCount;
930
1034
 
931
1035
  // Clear the active loan lock for this token ID and reward token.
932
- delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
1036
+ delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.groupId][vestingLoan.tokenId][vestingLoan.token];
933
1037
 
934
1038
  // Clear the loan metadata so it cannot be written off or repaid again.
935
1039
  delete _vestingLoanOf[loanId];
@@ -980,9 +1084,10 @@ abstract contract JBDistributor is IJBDistributor {
980
1084
 
981
1085
  /// @notice Accept funds and assign them to this round's reward ledger.
982
1086
  /// @param hook The stake source whose stakers receive the rewards.
1087
+ /// @param groupId The reward group being funded (0 = the default group).
983
1088
  /// @param token The reward token being funded.
984
1089
  /// @param amount The nominal amount to fund.
985
- function _fund(address hook, IERC20 token, uint256 amount) internal {
1090
+ function _fund(address hook, uint256 groupId, IERC20 token, uint256 amount) internal {
986
1091
  // Native funding is measured by msg.value, not the caller-provided amount.
987
1092
  if (address(token) == JBConstants.NATIVE_TOKEN) {
988
1093
  amount = msg.value;
@@ -997,38 +1102,41 @@ abstract contract JBDistributor is IJBDistributor {
997
1102
  }
998
1103
 
999
1104
  // Store the accepted amount in this round's historical reward ledger.
1000
- _recordRewardFunding({hook: hook, token: token, amount: amount});
1105
+ _recordRewardFunding({hook: hook, groupId: groupId, token: token, amount: amount});
1001
1106
  }
1002
1107
 
1003
1108
  /// @notice Record accepted funding as the current round's reward pot.
1004
1109
  /// @param hook The stake source whose stakers receive the rewards.
1110
+ /// @param groupId The reward group (0 = the default group).
1005
1111
  /// @param token The reward token.
1006
1112
  /// @param amount The accepted funding amount.
1007
- function _recordRewardFunding(address hook, IERC20 token, uint256 amount) internal {
1113
+ function _recordRewardFunding(address hook, uint256 groupId, IERC20 token, uint256 amount) internal {
1008
1114
  // Zero-value transfers do not create reward rounds or alter tracked balances.
1009
1115
  if (amount == 0) return;
1010
1116
 
1011
1117
  // Add the accepted amount to the current reward ledger.
1012
- _recordRewardRound({hook: hook, token: token, amount: amount});
1118
+ _recordRewardRound({hook: hook, groupId: groupId, token: token, amount: amount});
1013
1119
 
1014
- // Keep the base distributor's balance accounting in sync for collection and conservation checks.
1120
+ // Keep the base distributor's balance accounting in sync for collection and conservation checks. Balances
1121
+ // are tracked per (hook, token) across all groups because they share one token custody pool.
1015
1122
  _balanceOf[hook][token] += amount;
1016
1123
  _accountedBalanceOf[token] += amount;
1017
1124
  }
1018
1125
 
1019
1126
  /// @notice Record rewards as the current round's claimable historical reward pot.
1020
1127
  /// @param hook The stake source whose stakers receive the rewards.
1128
+ /// @param groupId The reward group (0 = the default group).
1021
1129
  /// @param token The reward token.
1022
1130
  /// @param amount The amount to add to the current reward round.
1023
- function _recordRewardRound(address hook, IERC20 token, uint256 amount) internal {
1131
+ function _recordRewardRound(address hook, uint256 groupId, IERC20 token, uint256 amount) internal {
1024
1132
  // Zero-value rewards do not create reward rounds.
1025
1133
  if (amount == 0) return;
1026
1134
 
1027
1135
  // Rewards belong to the round in progress when they enter the ledger.
1028
1136
  uint256 round = currentRound();
1029
1137
 
1030
- // Load the current round's ledger entry for this hook and reward token.
1031
- JBRewardRoundData storage rewardRound = rewardRoundOf[hook][token][round];
1138
+ // Load the current round's ledger entry for this hook, group, and reward token.
1139
+ JBRewardRoundData storage rewardRound = rewardRoundOf[hook][groupId][token][round];
1032
1140
 
1033
1141
  // Every reward round in this contract uses the same immutable claim duration.
1034
1142
  uint48 claimDeadline = _claimDeadlineFor(round);
@@ -1044,8 +1152,8 @@ abstract contract JBDistributor is IJBDistributor {
1044
1152
  // Store the packed claim deadline fixed for this distributor.
1045
1153
  rewardRound.claimDeadline = claimDeadline;
1046
1154
 
1047
- // Store the packed total stake that shares this round's reward pot.
1048
- rewardRound.totalStake = _toUint208(_totalStake({hook: hook, blockNumber: snapshotBlock}));
1155
+ // Store the packed total stake that shares this group's round reward pot.
1156
+ rewardRound.totalStake = _toUint208(_totalStake({hook: hook, groupId: groupId, blockNumber: snapshotBlock}));
1049
1157
  }
1050
1158
 
1051
1159
  // Multiple additions in the same round share the same snapshot and reward pot.
@@ -1054,11 +1162,13 @@ abstract contract JBDistributor is IJBDistributor {
1054
1162
 
1055
1163
  /// @notice Recycle one expired reward round's unclaimed inventory into the current reward round.
1056
1164
  /// @param hook The hook whose expired rewards should be recycled.
1165
+ /// @param groupId The reward group (0 = the default group).
1057
1166
  /// @param token The reward token to recycle.
1058
1167
  /// @param round The reward round to recycle.
1059
1168
  /// @return recycleAmount The amount recycled.
1060
1169
  function _recycleExpiredRewardRound(
1061
1170
  address hook,
1171
+ uint256 groupId,
1062
1172
  IERC20 token,
1063
1173
  uint256 round
1064
1174
  )
@@ -1066,7 +1176,7 @@ abstract contract JBDistributor is IJBDistributor {
1066
1176
  returns (uint256 recycleAmount)
1067
1177
  {
1068
1178
  // Load the reward round once so expiry, claimed amount, and funded amount stay in sync.
1069
- JBRewardRoundData storage rewardRound = rewardRoundOf[hook][token][round];
1179
+ JBRewardRoundData storage rewardRound = rewardRoundOf[hook][groupId][token][round];
1070
1180
 
1071
1181
  // Ignore rounds that either never expire or have not reached their deadline yet.
1072
1182
  if (!_rewardRoundExpired(rewardRound)) return 0;
@@ -1082,7 +1192,7 @@ abstract contract JBDistributor is IJBDistributor {
1082
1192
 
1083
1193
  // Keep the inventory in the distributor and give the current staker set a new claimable round.
1084
1194
  uint256 recycledToRound = currentRound();
1085
- _recordRewardRound({hook: hook, token: token, amount: recycleAmount});
1195
+ _recordRewardRound({hook: hook, groupId: groupId, token: token, amount: recycleAmount});
1086
1196
 
1087
1197
  // Surface the permissionless recycle for off-chain accounting.
1088
1198
  emit ExpiredRewardsRecycled({
@@ -1174,12 +1284,14 @@ abstract contract JBDistributor is IJBDistributor {
1174
1284
 
1175
1285
  /// @notice Unlocks rewards for the given token IDs and tokens, either for collection or forfeiture.
1176
1286
  /// @param hook The hook the tokens belong to.
1287
+ /// @param groupId The reward group (0 = the default group).
1177
1288
  /// @param tokenIds The IDs of the tokens to unlock rewards for.
1178
1289
  /// @param tokens The addresses of the tokens to unlock.
1179
1290
  /// @param beneficiary The recipient of the unlocked tokens.
1180
1291
  /// @param ownerClaim Whether this is a claim by the owner (true) or a forfeiture release (false).
1181
1292
  function _unlockRewards(
1182
1293
  address hook,
1294
+ uint256 groupId,
1183
1295
  uint256[] calldata tokenIds,
1184
1296
  IERC20[] calldata tokens,
1185
1297
  address beneficiary,
@@ -1194,7 +1306,8 @@ abstract contract JBDistributor is IJBDistributor {
1194
1306
  IERC20 token = tokens[i];
1195
1307
 
1196
1308
  // Process all token IDs for this reward token.
1197
- uint256 totalTokenAmount = _unlockTokenIds({hook: hook, tokenIds: tokenIds, token: token, round: round});
1309
+ uint256 totalTokenAmount =
1310
+ _unlockTokenIds({hook: hook, groupId: groupId, tokenIds: tokenIds, token: token, round: round});
1198
1311
 
1199
1312
  // Perform the transfer.
1200
1313
  if (totalTokenAmount != 0) {
@@ -1221,7 +1334,7 @@ abstract contract JBDistributor is IJBDistributor {
1221
1334
  }
1222
1335
  } else {
1223
1336
  // If forfeiture: keep inventory in the distributor and give the current staker set a fresh round.
1224
- _recordRewardRound({hook: hook, token: token, amount: totalTokenAmount});
1337
+ _recordRewardRound({hook: hook, groupId: groupId, token: token, amount: totalTokenAmount});
1225
1338
  emit ForfeitedRewardsRecycled({
1226
1339
  hook: hook, round: round, token: token, amount: totalTokenAmount, caller: msg.sender
1227
1340
  });
@@ -1236,12 +1349,14 @@ abstract contract JBDistributor is IJBDistributor {
1236
1349
 
1237
1350
  /// @notice Unlocks rewards for a set of token IDs for a single reward token.
1238
1351
  /// @param hook The hook the tokens belong to.
1352
+ /// @param groupId The reward group (0 = the default group).
1239
1353
  /// @param tokenIds The IDs of the tokens to unlock rewards for.
1240
1354
  /// @param token The reward token to unlock.
1241
1355
  /// @param round The current round.
1242
1356
  /// @return totalTokenAmount The total amount of reward tokens unlocked.
1243
1357
  function _unlockTokenIds(
1244
1358
  address hook,
1359
+ uint256 groupId,
1245
1360
  uint256[] calldata tokenIds,
1246
1361
  IERC20 token,
1247
1362
  uint256 round
@@ -1253,13 +1368,13 @@ abstract contract JBDistributor is IJBDistributor {
1253
1368
  uint256 tokenId = tokenIds[j];
1254
1369
 
1255
1370
  // Loan collateral stays locked until repayment restores it to this distributor.
1256
- _requireNoActiveVestingLoan({hook: hook, tokenId: tokenId, token: token});
1371
+ _requireNoActiveVestingLoan({hook: hook, groupId: groupId, tokenId: tokenId, token: token});
1257
1372
 
1258
1373
  // Keep a reference to the latest vested index.
1259
- uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
1374
+ uint256 vestedIndex = latestVestedIndexOf[hook][groupId][tokenId][token];
1260
1375
 
1261
1376
  // Keep a reference to the vesting data array.
1262
- JBVestingData[] storage vestings = vestingDataOf[hook][tokenId][token];
1377
+ JBVestingData[] storage vestings = vestingDataOf[hook][groupId][tokenId][token];
1263
1378
  uint256 numberOfVestingRounds = vestings.length;
1264
1379
 
1265
1380
  // Keep a reference to a vested index that will be incremented.
@@ -1294,6 +1409,7 @@ abstract contract JBDistributor is IJBDistributor {
1294
1409
  emit Collected({
1295
1410
  hook: hook,
1296
1411
  tokenId: tokenId,
1412
+ groupId: groupId,
1297
1413
  token: token,
1298
1414
  amount: claimAmount,
1299
1415
  vestingReleaseRound: vesting.releaseRound,
@@ -1315,7 +1431,7 @@ abstract contract JBDistributor is IJBDistributor {
1315
1431
  }
1316
1432
  }
1317
1433
 
1318
- latestVestedIndexOf[hook][tokenId][token] = newLatestVestedIndex;
1434
+ latestVestedIndexOf[hook][groupId][tokenId][token] = newLatestVestedIndex;
1319
1435
 
1320
1436
  unchecked {
1321
1437
  ++j;
@@ -1327,13 +1443,73 @@ abstract contract JBDistributor is IJBDistributor {
1327
1443
  // ----------------------- internal views ---------------------------- //
1328
1444
  //*********************************************************************//
1329
1445
 
1446
+ /// @notice The collectable (unlocked, uncollected) amount for a token ID in a specific reward group.
1447
+ /// @param hook The hook the tokenId belongs to.
1448
+ /// @param groupId The reward group (0 = the default group).
1449
+ /// @param tokenId The ID of the staker token to calculate for.
1450
+ /// @param token The reward token to check.
1451
+ /// @return tokenAmount The amount of tokens that can be collected right now.
1452
+ function _collectableFor(
1453
+ address hook,
1454
+ uint256 groupId,
1455
+ uint256 tokenId,
1456
+ IERC20 token
1457
+ )
1458
+ internal
1459
+ view
1460
+ returns (uint256 tokenAmount)
1461
+ {
1462
+ // A loan keeps this token ID's vesting rewards in collateral custody until the loan is repaid.
1463
+ if (activeVestingLoanIdOf[hook][groupId][tokenId][token] != 0) return 0;
1464
+
1465
+ // The round that we are in right now.
1466
+ uint256 round = currentRound();
1467
+
1468
+ // Keep a reference to the latest vested index.
1469
+ uint256 vestedIndex = latestVestedIndexOf[hook][groupId][tokenId][token];
1470
+
1471
+ // Keep a reference to the vesting data array.
1472
+ JBVestingData[] storage vestings = vestingDataOf[hook][groupId][tokenId][token];
1473
+ uint256 numberOfVestingRounds = vestings.length;
1474
+
1475
+ while (vestedIndex < numberOfVestingRounds) {
1476
+ uint256 lockedShare;
1477
+
1478
+ // Keep a reference to the vested data being iterated on.
1479
+ JBVestingData memory vesting = vestings[vestedIndex];
1480
+
1481
+ lockedShare = JBVestingMath.lockedShareOf({
1482
+ releaseRound: vesting.releaseRound,
1483
+ currentRound: round,
1484
+ vestingRounds: VESTING_ROUNDS,
1485
+ maxShare: MAX_SHARE
1486
+ });
1487
+
1488
+ // Calculate the newly unlocked amount from cumulative shares rather than the incremental share delta.
1489
+ // Incremental floor rounding can otherwise underpay partial collections and leave dust stranded.
1490
+ (uint256 claimAmount,) = JBVestingMath.newlyClaimableAmountOf({
1491
+ amount: vesting.amount,
1492
+ shareClaimed: vesting.shareClaimed,
1493
+ lockedShare: lockedShare,
1494
+ maxShare: MAX_SHARE
1495
+ });
1496
+ tokenAmount += claimAmount;
1497
+
1498
+ unchecked {
1499
+ ++vestedIndex;
1500
+ }
1501
+ }
1502
+ }
1503
+
1330
1504
  /// @notice The remaining uncollected vesting amount for one token ID and reward token.
1331
1505
  /// @param hook The hook the token ID belongs to.
1506
+ /// @param groupId The reward group (0 = the default group).
1332
1507
  /// @param tokenId The token ID to check.
1333
1508
  /// @param token The reward token to check.
1334
1509
  /// @return tokenAmount The amount still locked or unlocked-but-uncollected.
1335
1510
  function _unclaimedVestingAmountOf(
1336
1511
  address hook,
1512
+ uint256 groupId,
1337
1513
  uint256 tokenId,
1338
1514
  IERC20 token
1339
1515
  )
@@ -1342,10 +1518,10 @@ abstract contract JBDistributor is IJBDistributor {
1342
1518
  returns (uint256 tokenAmount)
1343
1519
  {
1344
1520
  // Keep a reference to the latest fully vested index.
1345
- uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
1521
+ uint256 vestedIndex = latestVestedIndexOf[hook][groupId][tokenId][token];
1346
1522
 
1347
1523
  // Keep a reference to the vesting data array.
1348
- JBVestingData[] storage vestings = vestingDataOf[hook][tokenId][token];
1524
+ JBVestingData[] storage vestings = vestingDataOf[hook][groupId][tokenId][token];
1349
1525
  uint256 numberOfVestingRounds = vestings.length;
1350
1526
 
1351
1527
  while (vestedIndex < numberOfVestingRounds) {
@@ -1381,10 +1557,11 @@ abstract contract JBDistributor is IJBDistributor {
1381
1557
 
1382
1558
  /// @notice Revert if a token ID's vesting rewards are locked in a distributor-owned loan.
1383
1559
  /// @param hook The hook the token ID belongs to.
1560
+ /// @param groupId The reward group (0 = the default group).
1384
1561
  /// @param tokenId The token ID to check.
1385
1562
  /// @param token The reward token to check.
1386
- function _requireNoActiveVestingLoan(address hook, uint256 tokenId, IERC20 token) internal view {
1387
- uint256 loanId = activeVestingLoanIdOf[hook][tokenId][token];
1563
+ function _requireNoActiveVestingLoan(address hook, uint256 groupId, uint256 tokenId, IERC20 token) internal view {
1564
+ uint256 loanId = activeVestingLoanIdOf[hook][groupId][tokenId][token];
1388
1565
  if (loanId != 0) {
1389
1566
  revert JBDistributor_VestingLoanOutstanding({
1390
1567
  hook: hook, tokenId: tokenId, token: address(token), loanId: loanId
@@ -1400,17 +1577,26 @@ abstract contract JBDistributor is IJBDistributor {
1400
1577
  function _tokenBurned(address hook, uint256 tokenId) internal view virtual returns (bool tokenWasBurned);
1401
1578
 
1402
1579
  /// @notice The stake weight of a specific token ID, used to calculate its pro-rata share of distributions.
1403
- /// For 721 distributors this is the tier's voting units; for token distributors this is delegated voting power.
1580
+ /// @dev Subclasses define how stake is measured.
1404
1581
  /// @param hook The hook the token belongs to.
1405
1582
  /// @param tokenId The ID of the token to get the stake weight of.
1406
1583
  /// @return tokenStakeAmount The stake weight represented by this token ID.
1407
1584
  function _tokenStake(address hook, uint256 tokenId) internal view virtual returns (uint256 tokenStakeAmount);
1408
1585
 
1409
- /// @notice The total stake across all token IDs at a given block. Used as the denominator when calculating each
1410
- /// token ID's pro-rata share. For 721 distributors this is `getPastTotalSupply` from the checkpoints module;
1411
- /// for token distributors this is `getPastTotalSupply` from the IVotes token.
1586
+ /// @notice The total stake sharing a group's round rewards at a given block. Used as the denominator when
1587
+ /// calculating each token ID's pro-rata share.
1588
+ /// @dev Subclasses define how the per-group total stake is measured.
1412
1589
  /// @param hook The hook to get the total stake for.
1590
+ /// @param groupId The reward group (0 = the default group).
1413
1591
  /// @param blockNumber The block number to query (must be strictly in the past).
1414
1592
  /// @return totalStakedAmount The total stake at the given block.
1415
- function _totalStake(address hook, uint256 blockNumber) internal view virtual returns (uint256 totalStakedAmount);
1593
+ function _totalStake(
1594
+ address hook,
1595
+ uint256 groupId,
1596
+ uint256 blockNumber
1597
+ )
1598
+ internal
1599
+ view
1600
+ virtual
1601
+ returns (uint256 totalStakedAmount);
1416
1602
  }