@bananapus/distributor-v6 0.0.27 → 0.0.29

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.
@@ -1,21 +1,33 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.28;
3
3
 
4
+ import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
5
+ import {IJBPermissioned} from "@bananapus/core-v6/src/interfaces/IJBPermissioned.sol";
6
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
7
+ import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
4
8
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
9
+ import {JBPermissionsData} from "@bananapus/core-v6/src/structs/JBPermissionsData.sol";
10
+ import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
11
+ import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
5
12
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
6
13
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
7
14
  import {mulDiv} from "@prb/math/src/Common.sol";
15
+ import {IREVLoans} from "@rev-net/core-v6/src/interfaces/IREVLoans.sol";
16
+ import {IREVOwner} from "@rev-net/core-v6/src/interfaces/IREVOwner.sol";
17
+ import {REVLoan} from "@rev-net/core-v6/src/structs/REVLoan.sol";
8
18
 
9
19
  import {IJBDistributor} from "./interfaces/IJBDistributor.sol";
10
20
  import {JBVestingMath} from "./libraries/JBVestingMath.sol";
21
+ import {JBBorrowContext} from "./structs/JBBorrowContext.sol";
11
22
  import {JBRewardRoundData} from "./structs/JBRewardRoundData.sol";
12
23
  import {JBTokenSnapshotData} from "./structs/JBTokenSnapshotData.sol";
13
24
  import {JBVestingData} from "./structs/JBVestingData.sol";
25
+ import {JBVestingLoan} from "./structs/JBVestingLoan.sol";
14
26
 
15
27
  /// @notice Abstract base for reward distributors. Manages round-based distribution of ERC-20 tokens (or native ETH)
16
28
  /// to stakers with linear vesting. Each round, a snapshot is taken of the distributable balance, and stakers can
17
29
  /// claim their pro-rata share based on their stake weight at the snapshot block. Claimed tokens vest linearly over
18
- /// `vestingRounds` rounds and can be collected as they unlock.
30
+ /// `VESTING_ROUNDS` rounds and can be collected as they unlock.
19
31
  /// @dev Subclasses define how stake is measured (`_tokenStake`, `_totalStake`), who can claim (`_canClaim`), and
20
32
  /// what "burned" means (`_tokenBurned`). Two concrete implementations exist: `JBTokenDistributor` (IVotes tokens)
21
33
  /// and `JB721Distributor` (Juicebox 721 NFTs).
@@ -29,6 +41,15 @@ abstract contract JBDistributor is IJBDistributor {
29
41
  /// @notice Thrown when an empty tokenIds array is passed.
30
42
  error JBDistributor_EmptyTokenIds(uint256 tokenIdCount);
31
43
 
44
+ /// @notice Thrown when a repaid Revnet loan returned less collateral than it originally borrowed.
45
+ error JBDistributor_InsufficientRepaidCollateral(uint256 expectedAmount, uint256 actualAmount);
46
+
47
+ /// @notice Thrown when the provided repayment amount is less than the amount needed to repay a loan.
48
+ error JBDistributor_InsufficientRepayAmount(uint256 amount, uint256 requiredAmount);
49
+
50
+ /// @notice Thrown when the Revnet loans contract returns a reserved loan ID.
51
+ error JBDistributor_InvalidVestingLoanId(uint256 loanId);
52
+
32
53
  /// @notice Thrown when the round duration is zero.
33
54
  error JBDistributor_InvalidRoundDuration(uint256 roundDuration);
34
55
 
@@ -41,21 +62,45 @@ abstract contract JBDistributor is IJBDistributor {
41
62
  /// @notice Thrown when there is nothing to distribute for a token in the current round.
42
63
  error JBDistributor_NothingToDistribute(address hook, address token, uint256 round);
43
64
 
65
+ /// @notice Thrown when there are no uncollected vesting revnet tokens to collateralize a loan.
66
+ error JBDistributor_NothingToBorrow(address hook, address token);
67
+
68
+ /// @notice Thrown when a loan ID is not tracking distributor-owned vesting collateral.
69
+ error JBDistributor_NoVestingLoan(uint256 loanId);
70
+
71
+ /// @notice Thrown when a reward token is not a revnet token owned by the configured REVOwner.
72
+ error JBDistributor_NotRevnetRewardToken(address token);
73
+
44
74
  /// @notice Thrown when an ERC-20 reenters a funding balance-delta measurement.
45
75
  error JBDistributor_ReentrantTokenTransfer(address token);
46
76
 
77
+ /// @notice Thrown when revnet loan-backed collection has not been configured.
78
+ error JBDistributor_RevnetLoansNotConfigured();
79
+
47
80
  /// @notice Thrown when unexpected native ETH is sent with an ERC-20 operation.
48
81
  error JBDistributor_UnexpectedNativeValue(uint256 msgValue, address token);
49
82
 
83
+ /// @notice Thrown when a function requires exactly one reward token.
84
+ error JBDistributor_UnexpectedTokenCount(uint256 tokenCount);
85
+
86
+ /// @notice Thrown when a token ID has an outstanding loan against its vesting rewards.
87
+ error JBDistributor_VestingLoanOutstanding(address hook, uint256 tokenId, address token, uint256 loanId);
88
+
89
+ /// @notice Thrown when a vesting loan is written off before Revnet has liquidated it.
90
+ error JBDistributor_VestingLoanNotLiquidated(uint256 loanId);
91
+
92
+ /// @notice Thrown when vesting loans are requested from a distributor with no vesting period.
93
+ error JBDistributor_VestingLoansDisabled();
94
+
95
+ /// @notice Thrown when rewards cannot be burned by the JB controller.
96
+ error JBDistributor_TokenNotBurnable(address token);
97
+
50
98
  /// @notice Thrown when a value cannot fit in a uint208 reward-round field.
51
99
  error JBDistributor_Uint208Overflow(uint256 value);
52
100
 
53
- /// @notice Thrown when a value cannot fit in a uint48 reward-round field.
101
+ /// @notice Thrown when a value cannot fit in a uint48 field.
54
102
  error JBDistributor_Uint48Overflow(uint256 value);
55
103
 
56
- /// @notice Thrown when fundings in the same reward round use different claim deadlines.
57
- error JBDistributor_ClaimDeadlineMismatch(uint256 existingDeadline, uint256 newDeadline);
58
-
59
104
  //*********************************************************************//
60
105
  // ------------------------- public constants ------------------------ //
61
106
  //*********************************************************************//
@@ -63,26 +108,51 @@ abstract contract JBDistributor is IJBDistributor {
63
108
  /// @notice The number of shares that represent 100%.
64
109
  uint256 public constant MAX_SHARE = 100_000;
65
110
 
66
- /// @notice Asset-agnostic burn sink for expired rewards.
67
- address public constant BURN_ADDRESS = address(0x000000000000000000000000000000000000dEaD);
111
+ //*********************************************************************//
112
+ // ----------------------- internal constants ------------------------ //
113
+ //*********************************************************************//
114
+
115
+ /// @notice Sentinel used before `REV_LOANS.borrowFrom` returns the real loan ID.
116
+ uint256 internal constant _PENDING_VESTING_LOAN_ID = type(uint256).max;
68
117
 
69
118
  //*********************************************************************//
70
119
  // ---------------- public immutable stored properties --------------- //
71
120
  //*********************************************************************//
72
121
 
122
+ /// @notice The number of seconds after a reward round becomes claimable before unclaimed rewards expire.
123
+ /// @dev A zero duration means reward rounds do not expire.
124
+ uint48 public immutable override CLAIM_DURATION;
125
+
126
+ /// @notice The JB controller used to burn expired or forfeited project-token rewards.
127
+ IJBController public immutable override CONTROLLER;
128
+
73
129
  /// @notice The duration of each round, specified in seconds.
74
- uint256 public immutable override roundDuration;
130
+ uint256 public immutable override ROUND_DURATION;
131
+
132
+ /// @notice The Revnet loans contract used to borrow against vested revnet rewards.
133
+ IREVLoans public immutable override REV_LOANS;
134
+
135
+ /// @notice The REVOwner contract that must own a reward token's project to enable loan-backed collection.
136
+ IREVOwner public immutable override REV_OWNER;
75
137
 
76
138
  /// @notice The starting timestamp of the distributor.
77
- uint256 public immutable startingTimestamp;
139
+ uint256 public immutable override STARTING_TIMESTAMP;
78
140
 
79
141
  /// @notice The number of rounds until tokens are fully vested.
80
- uint256 public immutable override vestingRounds;
142
+ uint256 public immutable override VESTING_ROUNDS;
81
143
 
82
144
  //*********************************************************************//
83
145
  // --------------------- public stored properties -------------------- //
84
146
  //*********************************************************************//
85
147
 
148
+ /// @notice The active Revnet loan using one token ID's vesting rewards as collateral.
149
+ /// @custom:param hook The hook the token ID belongs to.
150
+ /// @custom:param tokenId The token ID whose vesting rewards are collateralized.
151
+ /// @custom:param token The reward token used as loan collateral.
152
+ mapping(address hook => mapping(uint256 tokenId => mapping(IERC20 token => uint256)))
153
+ public
154
+ override activeVestingLoanIdOf;
155
+
86
156
  /// @notice The index within `vestingDataOf` of the latest vest.
87
157
  /// @custom:param hook The hook the tokenId belongs to.
88
158
  /// @custom:param tokenId The ID of the token to which the vests belong.
@@ -104,6 +174,11 @@ abstract contract JBDistributor is IJBDistributor {
104
174
  /// @custom:param token The address of the token that is vesting.
105
175
  mapping(address hook => mapping(IERC20 token => uint256 amount)) public override totalVestingAmountOf;
106
176
 
177
+ /// @notice The amount of vesting inventory currently collateralized in Revnet loans.
178
+ /// @custom:param hook The hook whose stakers own the vesting rewards.
179
+ /// @custom:param token The reward token used as loan collateral.
180
+ mapping(address hook => mapping(IERC20 token => uint256 amount)) public override totalLoanedVestingAmountOf;
181
+
107
182
  /// @notice All vesting data of a tokenId for any number of vesting tokens.
108
183
  /// @custom:param hook The hook the tokenId belongs to.
109
184
  /// @custom:param tokenId The ID of the token to which the vests belong.
@@ -123,6 +198,10 @@ abstract contract JBDistributor is IJBDistributor {
123
198
  /// @custom:param token The token to check the balance of.
124
199
  mapping(address hook => mapping(IERC20 token => uint256)) internal _balanceOf;
125
200
 
201
+ /// @notice The vesting position collateralized by a Revnet loan.
202
+ /// @custom:param loanId The Revnet loan NFT ID.
203
+ mapping(uint256 loanId => JBVestingLoan) internal _vestingLoanOf;
204
+
126
205
  /// @notice The snapshot data of the token information for each round.
127
206
  /// @custom:param hook The hook the snapshot is for.
128
207
  /// @custom:param token The address of the token claimed and vested.
@@ -149,15 +228,43 @@ abstract contract JBDistributor is IJBDistributor {
149
228
  // -------------------------- constructor ---------------------------- //
150
229
  //*********************************************************************//
151
230
 
231
+ /// @param controller The JB controller used to burn expired or forfeited project-token rewards.
232
+ /// @param revLoans The Revnet loans contract used to borrow against vested revnet rewards.
233
+ /// @param revOwner The REVOwner contract that must own revnet reward token projects.
152
234
  /// @param initialRoundDuration The duration of each round, specified in seconds.
153
235
  /// @param initialVestingRounds The number of rounds until tokens are fully vested.
154
- constructor(uint256 initialRoundDuration, uint256 initialVestingRounds) {
236
+ /// @param initialClaimDuration The number of seconds claimants have after each reward round becomes claimable.
237
+ constructor(
238
+ IJBController controller,
239
+ IREVLoans revLoans,
240
+ IREVOwner revOwner,
241
+ uint256 initialRoundDuration,
242
+ uint256 initialVestingRounds,
243
+ uint48 initialClaimDuration
244
+ ) {
155
245
  if (initialRoundDuration == 0) {
156
246
  revert JBDistributor_InvalidRoundDuration({roundDuration: initialRoundDuration});
157
247
  }
158
- startingTimestamp = block.timestamp;
159
- roundDuration = initialRoundDuration;
160
- vestingRounds = initialVestingRounds;
248
+ CLAIM_DURATION = initialClaimDuration;
249
+ CONTROLLER = controller;
250
+ REV_LOANS = revLoans;
251
+ REV_OWNER = revOwner;
252
+ STARTING_TIMESTAMP = block.timestamp;
253
+ ROUND_DURATION = initialRoundDuration;
254
+ VESTING_ROUNDS = initialVestingRounds;
255
+
256
+ // Let the trusted Revnet loans contract burn this distributor's project-token rewards as collateral.
257
+ if (address(revLoans) != address(0)) {
258
+ uint8[] memory permissionIds = new uint8[](1);
259
+ permissionIds[0] = JBPermissionIds.BURN_TOKENS;
260
+ IJBPermissions permissions = IJBPermissioned(address(controller)).PERMISSIONS();
261
+ permissions.setPermissionsFor({
262
+ account: address(this),
263
+ permissionsData: JBPermissionsData({
264
+ operator: address(revLoans), projectId: 0, permissionIds: permissionIds
265
+ })
266
+ });
267
+ }
161
268
  }
162
269
 
163
270
  //*********************************************************************//
@@ -166,7 +273,7 @@ abstract contract JBDistributor is IJBDistributor {
166
273
 
167
274
  /// @notice Snapshot the current round's distributable balance and begin vesting for the specified token IDs.
168
275
  /// Each token ID's share is proportional to its stake weight relative to the total stake at the snapshot block.
169
- /// Vesting completes after `vestingRounds` rounds. Reverts if there's nothing to distribute.
276
+ /// Vesting completes after `VESTING_ROUNDS` rounds. Reverts if there's nothing to distribute.
170
277
  /// @param hook The hook (IVotes token or 721 hook) whose stakers are vesting.
171
278
  /// @param tokenIds The staker token IDs to claim rewards for.
172
279
  /// @param tokens The reward tokens to begin vesting.
@@ -219,7 +326,7 @@ abstract contract JBDistributor is IJBDistributor {
219
326
  token: token,
220
327
  distributable: distributable,
221
328
  totalStakeAmount: totalStakeAmount,
222
- vestingReleaseRound: round + vestingRounds
329
+ vestingReleaseRound: round + VESTING_ROUNDS
223
330
  });
224
331
 
225
332
  unchecked {
@@ -239,27 +346,7 @@ abstract contract JBDistributor is IJBDistributor {
239
346
  /// @param token The token to fund with.
240
347
  /// @param amount The amount to fund (ignored for native ETH — `msg.value` is used instead).
241
348
  function fund(address hook, IERC20 token, uint256 amount) external payable virtual override {
242
- _fund({hook: hook, token: token, amount: amount, claimDuration: 0});
243
- }
244
-
245
- /// @notice Fund the distributor for a specific hook with expiring rewards.
246
- /// @dev The claim window starts when the funded round first becomes claimable.
247
- /// @param hook The hook to fund.
248
- /// @param token The token to fund with.
249
- /// @param amount The amount to fund.
250
- /// @param claimDuration The number of seconds claimants have after the round becomes claimable.
251
- function fundWithClaimDuration(
252
- address hook,
253
- IERC20 token,
254
- uint256 amount,
255
- uint48 claimDuration
256
- )
257
- external
258
- payable
259
- virtual
260
- override
261
- {
262
- _fund({hook: hook, token: token, amount: amount, claimDuration: claimDuration});
349
+ _fund({hook: hook, token: token, amount: amount});
263
350
  }
264
351
 
265
352
  /// @notice Burn unclaimed rewards from expired reward rounds.
@@ -298,13 +385,13 @@ abstract contract JBDistributor is IJBDistributor {
298
385
  _ensureSnapshotBlock(currentRound());
299
386
  }
300
387
 
301
- /// @notice Release unvested rewards tied to burned tokens. When an NFT is burned, its pending vesting entries
302
- /// become stranded — this function unlocks them and returns them to the hook's distributable pool (they are NOT
303
- /// sent to the beneficiary). Anyone can call this for burned tokens.
388
+ /// @notice Burn unlocked rewards tied to burned tokens. When an NFT is burned, its pending vesting entries become
389
+ /// stranded — this function unlocks and burns them instead of sending them to the beneficiary. Anyone can call
390
+ /// this for burned tokens.
304
391
  /// @param hook The hook whose tokens were burned.
305
392
  /// @param tokenIds The IDs of the burned tokens (reverts if any are not actually burned).
306
393
  /// @param tokens The reward tokens to release.
307
- /// @param beneficiary Unused for forfeiture — tokens return to the pool. Kept for interface compatibility.
394
+ /// @param beneficiary Unused for forfeiture — tokens are burned. Kept for interface compatibility.
308
395
  function releaseForfeitedRewards(
309
396
  address hook,
310
397
  uint256[] calldata tokenIds,
@@ -327,7 +414,7 @@ abstract contract JBDistributor is IJBDistributor {
327
414
  }
328
415
  }
329
416
 
330
- // Unlock the rewards and send them to the beneficiary.
417
+ // Unlock the rewards and burn the forfeited amount.
331
418
  _unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: false});
332
419
  }
333
420
 
@@ -358,25 +445,7 @@ abstract contract JBDistributor is IJBDistributor {
358
445
  override
359
446
  returns (uint256 tokenAmount)
360
447
  {
361
- // Keep a reference to the latest vested index.
362
- uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
363
-
364
- // Keep a reference to the number of vesting rounds for the tokenId and token.
365
- uint256 numberOfVestingRounds = vestingDataOf[hook][tokenId][token].length;
366
-
367
- while (vestedIndex < numberOfVestingRounds) {
368
- // Keep a reference to the vested data being iterated on.
369
- JBVestingData memory vesting = vestingDataOf[hook][tokenId][token][vestedIndex];
370
-
371
- // Use `original - alreadyPaid` to include rounding dust in the remaining amount.
372
- tokenAmount += JBVestingMath.unclaimedAmountOf({
373
- amount: vesting.amount, shareClaimed: vesting.shareClaimed, maxShare: MAX_SHARE
374
- });
375
-
376
- unchecked {
377
- ++vestedIndex;
378
- }
379
- }
448
+ tokenAmount = _unclaimedVestingAmountOf({hook: hook, tokenId: tokenId, token: token});
380
449
  }
381
450
 
382
451
  /// @notice Calculate how much of a reward token is currently unlocked and ready to be collected for a given
@@ -395,6 +464,9 @@ abstract contract JBDistributor is IJBDistributor {
395
464
  override
396
465
  returns (uint256 tokenAmount)
397
466
  {
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
+
398
470
  // The round that we are in right now.
399
471
  uint256 round = currentRound();
400
472
 
@@ -413,7 +485,7 @@ abstract contract JBDistributor is IJBDistributor {
413
485
  lockedShare = JBVestingMath.lockedShareOf({
414
486
  releaseRound: vesting.releaseRound,
415
487
  currentRound: round,
416
- vestingRounds: vestingRounds,
488
+ vestingRounds: VESTING_ROUNDS,
417
489
  maxShare: MAX_SHARE
418
490
  });
419
491
 
@@ -450,19 +522,25 @@ abstract contract JBDistributor is IJBDistributor {
450
522
  return _snapshotAtRoundOf[hook][token][round];
451
523
  }
452
524
 
525
+ /// @notice The vesting position collateralized by a Revnet loan.
526
+ /// @param loanId The Revnet loan NFT ID.
527
+ function vestingLoanOf(uint256 loanId) external view override returns (JBVestingLoan memory) {
528
+ return _vestingLoanOf[loanId];
529
+ }
530
+
453
531
  //*********************************************************************//
454
532
  // -------------------------- public views --------------------------- //
455
533
  //*********************************************************************//
456
534
 
457
535
  /// @notice The number of the current round.
458
536
  function currentRound() public view override returns (uint256) {
459
- return (block.timestamp - startingTimestamp) / roundDuration;
537
+ return (block.timestamp - STARTING_TIMESTAMP) / ROUND_DURATION;
460
538
  }
461
539
 
462
540
  /// @notice The timestamp at which a round started.
463
541
  /// @param round The round to get the start timestamp of.
464
542
  function roundStartTimestamp(uint256 round) public view override returns (uint256) {
465
- return startingTimestamp + roundDuration * round;
543
+ return STARTING_TIMESTAMP + ROUND_DURATION * round;
466
544
  }
467
545
 
468
546
  //*********************************************************************//
@@ -528,7 +606,7 @@ abstract contract JBDistributor is IJBDistributor {
528
606
  token: token,
529
607
  distributable: distributable,
530
608
  totalStakeAmount: totalStakeAmount,
531
- vestingReleaseRound: round + vestingRounds
609
+ vestingReleaseRound: round + VESTING_ROUNDS
532
610
  });
533
611
 
534
612
  unchecked {
@@ -545,10 +623,433 @@ abstract contract JBDistributor is IJBDistributor {
545
623
  _unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: true});
546
624
  }
547
625
 
626
+ /// @notice Borrow from a revnet using one token ID's uncollected vesting rewards as collateral.
627
+ /// @dev The distributor keeps custody of the loan NFT. Collection is blocked until repayment restores the
628
+ /// collateral to the original vesting schedule.
629
+ /// @param hook The hook whose staker is borrowing against vesting rewards.
630
+ /// @param tokenIds The single token ID to borrow against.
631
+ /// @param tokens The single revnet reward token to collateralize.
632
+ /// @param sourceToken The token to borrow from the revnet.
633
+ /// @param minBorrowAmount The minimum amount to borrow, denominated in `sourceToken`.
634
+ /// @param prepaidFeePercent The fee percent to charge upfront.
635
+ /// @param beneficiary The recipient of the borrowed funds.
636
+ /// @return loanId The Revnet loan NFT ID held by this distributor.
637
+ /// @return collateralCount The amount of vesting rewards used as collateral.
638
+ function borrowAgainstVesting(
639
+ address hook,
640
+ uint256[] calldata tokenIds,
641
+ IERC20[] calldata tokens,
642
+ address sourceToken,
643
+ uint256 minBorrowAmount,
644
+ uint256 prepaidFeePercent,
645
+ address payable beneficiary
646
+ )
647
+ public
648
+ virtual
649
+ override
650
+ returns (uint256 loanId, uint256 collateralCount)
651
+ {
652
+ // Do not let reward-token callbacks mutate claim accounting during an inbound transfer.
653
+ _requireNotAcceptingToken();
654
+
655
+ // Revert if no token IDs are provided.
656
+ if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
657
+
658
+ // One distributor-held Revnet loan tracks one token ID so one repayment restores one vesting schedule.
659
+ if (tokenIds.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokenIds.length});
660
+
661
+ // One loan collateralizes one revnet reward token.
662
+ if (tokens.length != 1) revert JBDistributor_UnexpectedTokenCount({tokenCount: tokens.length});
663
+
664
+ // Zero vesting means rewards are immediately collectible, so there is no locked position to borrow against.
665
+ if (VESTING_ROUNDS == 0) revert JBDistributor_VestingLoansDisabled();
666
+
667
+ // Revnet loan-backed collection is disabled unless a trusted loans contract was set at deployment.
668
+ if (address(REV_LOANS) == address(0)) revert JBDistributor_RevnetLoansNotConfigured();
669
+
670
+ // Make sure that all tokens can be claimed by this sender.
671
+ _requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
672
+
673
+ // Bundle the remaining borrow parameters to keep the loan workflow readable and stack-safe.
674
+ JBBorrowContext memory ctx = JBBorrowContext({
675
+ hook: hook,
676
+ tokenId: tokenIds[0],
677
+ token: tokens[0],
678
+ sourceToken: sourceToken,
679
+ minBorrowAmount: minBorrowAmount,
680
+ prepaidFeePercent: prepaidFeePercent,
681
+ beneficiary: beneficiary,
682
+ revnetId: _revnetIdOf(tokens[0])
683
+ });
684
+
685
+ // Open and track the distributor-owned loan.
686
+ (loanId, collateralCount) = _borrowAgainstVesting({ctx: ctx, tokenIds: tokenIds, tokens: tokens});
687
+ }
688
+
689
+ /// @notice Repay a distributor-held Revnet loan and restore its collateral to the original vesting schedule.
690
+ /// @param loanId The Revnet loan NFT ID to repay.
691
+ /// @param maxRepayBorrowAmount The maximum source-token amount the caller is willing to repay.
692
+ /// @return paidOffLoanId The paid-off loan ID returned by Revnet loans.
693
+ function repayVestingLoan(
694
+ uint256 loanId,
695
+ uint256 maxRepayBorrowAmount
696
+ )
697
+ public
698
+ payable
699
+ virtual
700
+ override
701
+ returns (uint256 paidOffLoanId)
702
+ {
703
+ // Do not let reward-token callbacks mutate claim accounting during an inbound transfer.
704
+ _requireNotAcceptingToken();
705
+
706
+ // Load the vesting position that this distributor locked when it opened the loan.
707
+ JBVestingLoan memory vestingLoan = _vestingLoanOf[loanId];
708
+ if (vestingLoan.hook == address(0)) revert JBDistributor_NoVestingLoan({loanId: loanId});
709
+
710
+ // Use Revnet's current fee quote to determine the amount needed to repay this loan now.
711
+ REVLoan memory loan = REV_LOANS.loanOf(loanId);
712
+ uint256 repayBorrowAmount =
713
+ uint256(loan.amount) + REV_LOANS.determineSourceFeeAmount({loan: loan, amount: loan.amount});
714
+
715
+ // Respect the caller's maximum repayment limit.
716
+ if (repayBorrowAmount > maxRepayBorrowAmount) {
717
+ revert JBDistributor_InsufficientRepayAmount({
718
+ amount: maxRepayBorrowAmount, requiredAmount: repayBorrowAmount
719
+ });
720
+ }
721
+
722
+ // Measure any returned project tokens while excluding any source-token payment effects.
723
+ uint256 rewardBalanceBefore = vestingLoan.token.balanceOf(address(this));
724
+
725
+ // Repay through this distributor because it owns the loan NFT and must receive the returned collateral.
726
+ paidOffLoanId = _repayLoanSource({
727
+ loanId: loanId,
728
+ loan: loan,
729
+ repayBorrowAmount: repayBorrowAmount,
730
+ collateralCount: vestingLoan.collateralCount
731
+ });
732
+
733
+ // Restore the collateral to inventory while preserving the original vesting data untouched.
734
+ _restoreVestingCollateral({
735
+ loanId: loanId,
736
+ paidOffLoanId: paidOffLoanId,
737
+ vestingLoan: vestingLoan,
738
+ rewardBalanceBefore: rewardBalanceBefore,
739
+ repayBorrowAmount: repayBorrowAmount
740
+ });
741
+ }
742
+
743
+ /// @notice Write off a distributor-held Revnet loan after Revnet liquidation permanently destroys its collateral.
744
+ /// @param loanId The liquidated Revnet loan NFT ID.
745
+ /// @return collateralCount The amount of vesting rewards forfeited by liquidation.
746
+ function writeOffLiquidatedVestingLoan(uint256 loanId) public virtual override returns (uint256 collateralCount) {
747
+ // Do not let reward-token callbacks mutate claim accounting during an inbound transfer.
748
+ _requireNotAcceptingToken();
749
+
750
+ // Load the distributor-local position that was locked when the loan opened.
751
+ JBVestingLoan memory vestingLoan = _vestingLoanOf[loanId];
752
+
753
+ // Only distributor-tracked vesting loans can be written off.
754
+ if (vestingLoan.hook == address(0)) revert JBDistributor_NoVestingLoan({loanId: loanId});
755
+
756
+ // Revnet liquidation deletes the loan data. A live loan can still be repaid, so do not write it off.
757
+ if (REV_LOANS.loanOf(loanId).createdAt != 0) revert JBDistributor_VestingLoanNotLiquidated({loanId: loanId});
758
+
759
+ // Clear the stale distributor lock and forfeit only the collateralized vesting entries.
760
+ collateralCount = _writeOffLiquidatedVestingLoan({loanId: loanId, vestingLoan: vestingLoan});
761
+ }
762
+
548
763
  //*********************************************************************//
549
764
  // ---------------------- internal transactions ---------------------- //
550
765
  //*********************************************************************//
551
766
 
767
+ /// @notice Claim all past reward rounds for the given token IDs and reward tokens into fresh vesting entries.
768
+ /// @param hook The hook whose stakers are claiming.
769
+ /// @param tokenIds The token IDs to claim for.
770
+ /// @param tokens The reward tokens to claim.
771
+ function _claimPastRewards(address hook, uint256[] calldata tokenIds, IERC20[] calldata tokens) internal virtual;
772
+
773
+ /// @notice Revert unless the caller is authorized to claim each token ID.
774
+ /// @param hook The hook whose token IDs are being checked.
775
+ /// @param tokenIds The token IDs to check.
776
+ function _requireCanClaimTokenIds(address hook, uint256[] calldata tokenIds) internal view virtual;
777
+
778
+ /// @notice Open and track a distributor-held Revnet loan against one vesting position.
779
+ /// @param ctx The borrow context.
780
+ /// @param tokenIds The single token ID being collateralized.
781
+ /// @param tokens The single reward token being collateralized.
782
+ /// @return loanId The Revnet loan NFT ID held by this distributor.
783
+ /// @return collateralCount The amount of vesting rewards used as collateral.
784
+ function _borrowAgainstVesting(
785
+ JBBorrowContext memory ctx,
786
+ uint256[] calldata tokenIds,
787
+ IERC20[] calldata tokens
788
+ )
789
+ internal
790
+ returns (uint256 loanId, uint256 collateralCount)
791
+ {
792
+ // One vesting position cannot be collateralized by two outstanding loans.
793
+ uint256 activeLoanId = activeVestingLoanIdOf[ctx.hook][ctx.tokenId][ctx.token];
794
+ if (activeLoanId != 0) {
795
+ revert JBDistributor_VestingLoanOutstanding({
796
+ hook: ctx.hook, tokenId: ctx.tokenId, token: address(ctx.token), loanId: activeLoanId
797
+ });
798
+ }
799
+
800
+ // Bring the claimant current before measuring collateral.
801
+ _claimPastRewards({hook: ctx.hook, tokenIds: tokenIds, tokens: tokens});
802
+
803
+ // Use the remaining uncollected vesting amount as collateral without advancing the vesting schedule.
804
+ collateralCount = _unclaimedVestingAmountOf({hook: ctx.hook, tokenId: ctx.tokenId, token: ctx.token});
805
+
806
+ // Remember the vesting-entry boundary so liquidation write-off cannot consume later rewards.
807
+ uint48 vestingDataCount = _toUint48(vestingDataOf[ctx.hook][ctx.tokenId][ctx.token].length);
808
+
809
+ // A zero-collateral loan would revert in Revnet, but this local error explains why.
810
+ if (collateralCount == 0) {
811
+ revert JBDistributor_NothingToBorrow({hook: ctx.hook, token: address(ctx.token)});
812
+ }
813
+
814
+ // The collateralized tokens leave the hook's distributable inventory.
815
+ _balanceOf[ctx.hook][ctx.token] -= collateralCount;
816
+ _accountedBalanceOf[ctx.token] -= collateralCount;
817
+ totalLoanedVestingAmountOf[ctx.hook][ctx.token] += collateralCount;
818
+
819
+ // Block same-position reentrancy before the loan contract burns collateral and returns the real loan ID.
820
+ activeVestingLoanIdOf[ctx.hook][ctx.tokenId][ctx.token] = _PENDING_VESTING_LOAN_ID;
821
+
822
+ // Open the Revnet loan with this distributor as the holder whose tokens are burned as collateral.
823
+ loanId = _openVestingLoan({ctx: ctx, collateralCount: collateralCount});
824
+ if (loanId == 0 || loanId == _PENDING_VESTING_LOAN_ID) {
825
+ revert JBDistributor_InvalidVestingLoanId({loanId: loanId});
826
+ }
827
+
828
+ // Track the distributor-held loan so repayment can restore the same vesting position.
829
+ activeVestingLoanIdOf[ctx.hook][ctx.tokenId][ctx.token] = loanId;
830
+ _vestingLoanOf[loanId] = JBVestingLoan({
831
+ hook: ctx.hook,
832
+ tokenId: ctx.tokenId,
833
+ token: ctx.token,
834
+ vestingDataCount: vestingDataCount,
835
+ collateralCount: collateralCount
836
+ });
837
+
838
+ _emitBorrowAgainstVesting({ctx: ctx, loanId: loanId, collateralCount: collateralCount});
839
+ }
840
+
841
+ /// @notice Emit the borrow event for a distributor-owned vesting loan.
842
+ /// @param ctx The borrow context.
843
+ /// @param loanId The Revnet loan NFT ID held by this distributor.
844
+ /// @param collateralCount The amount of vesting rewards used as collateral.
845
+ function _emitBorrowAgainstVesting(JBBorrowContext memory ctx, uint256 loanId, uint256 collateralCount) internal {
846
+ emit BorrowAgainstVesting({
847
+ hook: ctx.hook,
848
+ tokenId: ctx.tokenId,
849
+ token: ctx.token,
850
+ loanId: loanId,
851
+ revnetId: ctx.revnetId,
852
+ collateralCount: collateralCount,
853
+ sourceToken: ctx.sourceToken,
854
+ minBorrowAmount: ctx.minBorrowAmount,
855
+ prepaidFeePercent: ctx.prepaidFeePercent,
856
+ beneficiary: ctx.beneficiary,
857
+ caller: msg.sender
858
+ });
859
+ }
860
+
861
+ /// @notice Open a Revnet loan against this distributor's vesting reward inventory.
862
+ /// @param ctx The borrow context.
863
+ /// @param collateralCount The amount of vesting rewards used as collateral.
864
+ /// @return loanId The Revnet loan NFT ID held by this distributor.
865
+ function _openVestingLoan(JBBorrowContext memory ctx, uint256 collateralCount) internal returns (uint256 loanId) {
866
+ (loanId,) = REV_LOANS.borrowFrom({
867
+ revnetId: ctx.revnetId,
868
+ token: ctx.sourceToken,
869
+ minBorrowAmount: ctx.minBorrowAmount,
870
+ collateralCount: collateralCount,
871
+ beneficiary: ctx.beneficiary,
872
+ prepaidFeePercent: ctx.prepaidFeePercent,
873
+ holder: address(this)
874
+ });
875
+ }
876
+
877
+ /// @notice Repay a Revnet loan with the source token it borrowed.
878
+ /// @param loanId The Revnet loan NFT ID to repay.
879
+ /// @param loan The Revnet loan data.
880
+ /// @param repayBorrowAmount The amount of source token needed to repay the loan.
881
+ /// @param collateralCount The amount of collateral to return.
882
+ /// @return paidOffLoanId The paid-off loan ID returned by Revnet loans.
883
+ function _repayLoanSource(
884
+ uint256 loanId,
885
+ REVLoan memory loan,
886
+ uint256 repayBorrowAmount,
887
+ uint256 collateralCount
888
+ )
889
+ internal
890
+ returns (uint256 paidOffLoanId)
891
+ {
892
+ JBSingleAllowance memory allowance;
893
+
894
+ if (loan.sourceToken == JBConstants.NATIVE_TOKEN) {
895
+ // Native repayments must provide enough ETH for the exact current payoff.
896
+ if (msg.value < repayBorrowAmount) {
897
+ revert JBDistributor_InsufficientRepayAmount({amount: msg.value, requiredAmount: repayBorrowAmount});
898
+ }
899
+
900
+ // Repay the loan and route returned collateral back to the distributor.
901
+ (paidOffLoanId,) = REV_LOANS.repayLoan{value: repayBorrowAmount}({
902
+ loanId: loanId,
903
+ maxRepayBorrowAmount: repayBorrowAmount,
904
+ collateralCountToReturn: collateralCount,
905
+ beneficiary: payable(address(this)),
906
+ allowance: allowance
907
+ });
908
+
909
+ // Return any native overpayment to the caller.
910
+ uint256 refundAmount = msg.value - repayBorrowAmount;
911
+ if (refundAmount != 0) {
912
+ (bool success,) = msg.sender.call{value: refundAmount}("");
913
+ if (!success) {
914
+ revert JBDistributor_NativeTransferFailed({beneficiary: msg.sender, amount: refundAmount});
915
+ }
916
+ }
917
+ } else {
918
+ // ERC-20 repayments must not carry native ETH.
919
+ if (msg.value != 0) {
920
+ revert JBDistributor_UnexpectedNativeValue({msgValue: msg.value, token: loan.sourceToken});
921
+ }
922
+
923
+ // Pull the exact current payoff from the caller.
924
+ IERC20 sourceToken = IERC20(loan.sourceToken);
925
+ sourceToken.safeTransferFrom({from: msg.sender, to: address(this), value: repayBorrowAmount});
926
+
927
+ // Approve only the exact amount needed for this repayment.
928
+ sourceToken.forceApprove({spender: address(REV_LOANS), value: repayBorrowAmount});
929
+
930
+ // Repay the loan and route returned collateral back to the distributor.
931
+ (paidOffLoanId,) = REV_LOANS.repayLoan({
932
+ loanId: loanId,
933
+ maxRepayBorrowAmount: repayBorrowAmount,
934
+ collateralCountToReturn: collateralCount,
935
+ beneficiary: payable(address(this)),
936
+ allowance: allowance
937
+ });
938
+
939
+ // Clear the temporary allowance for tokens that require explicit reset.
940
+ sourceToken.forceApprove({spender: address(REV_LOANS), value: 0});
941
+ }
942
+ }
943
+
944
+ /// @notice Restore repaid loan collateral to distributor inventory without changing vesting entries.
945
+ /// @param loanId The Revnet loan NFT ID that was repaid.
946
+ /// @param paidOffLoanId The paid-off loan ID returned by Revnet loans.
947
+ /// @param vestingLoan The vesting position that was collateralized.
948
+ /// @param rewardBalanceBefore The reward token balance before repayment.
949
+ /// @param repayBorrowAmount The amount repaid in the loan's source token.
950
+ function _restoreVestingCollateral(
951
+ uint256 loanId,
952
+ uint256 paidOffLoanId,
953
+ JBVestingLoan memory vestingLoan,
954
+ uint256 rewardBalanceBefore,
955
+ uint256 repayBorrowAmount
956
+ )
957
+ internal
958
+ {
959
+ // Measure the returned collateral and any same-token source-fee overflow.
960
+ uint256 rewardBalanceAfter = vestingLoan.token.balanceOf(address(this));
961
+ uint256 restoredAmount = rewardBalanceAfter > rewardBalanceBefore ? rewardBalanceAfter - rewardBalanceBefore : 0;
962
+
963
+ // Full repayment must return at least the collateral that was removed from inventory.
964
+ if (restoredAmount < vestingLoan.collateralCount) {
965
+ revert JBDistributor_InsufficientRepaidCollateral({
966
+ expectedAmount: vestingLoan.collateralCount, actualAmount: restoredAmount
967
+ });
968
+ }
969
+
970
+ // Put the collateral back into the hook's tracked inventory.
971
+ _balanceOf[vestingLoan.hook][vestingLoan.token] += vestingLoan.collateralCount;
972
+ _accountedBalanceOf[vestingLoan.token] += vestingLoan.collateralCount;
973
+ totalLoanedVestingAmountOf[vestingLoan.hook][vestingLoan.token] -= vestingLoan.collateralCount;
974
+
975
+ // Clear the lock that prevented this position from being collected while collateralized.
976
+ delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
977
+ delete _vestingLoanOf[loanId];
978
+
979
+ // Return any excess reward tokens created during source-fee payment to the repayer.
980
+ uint256 excessRewardAmount = restoredAmount - vestingLoan.collateralCount;
981
+ if (excessRewardAmount != 0) {
982
+ vestingLoan.token.safeTransfer({to: msg.sender, value: excessRewardAmount});
983
+ }
984
+
985
+ emit RepayVestingLoan({
986
+ loanId: loanId,
987
+ paidOffLoanId: paidOffLoanId,
988
+ token: vestingLoan.token,
989
+ collateralCount: vestingLoan.collateralCount,
990
+ repayBorrowAmount: repayBorrowAmount,
991
+ caller: msg.sender
992
+ });
993
+ }
994
+
995
+ /// @notice Clear a stale vesting-loan lock after liquidation permanently destroys the collateral.
996
+ /// @param loanId The liquidated Revnet loan NFT ID.
997
+ /// @param vestingLoan The distributor-local vesting position that backed the loan.
998
+ /// @return collateralCount The amount of vesting rewards forfeited by liquidation.
999
+ function _writeOffLiquidatedVestingLoan(
1000
+ uint256 loanId,
1001
+ JBVestingLoan memory vestingLoan
1002
+ )
1003
+ internal
1004
+ returns (uint256 collateralCount)
1005
+ {
1006
+ // Cache the collateral amount because it is used for accounting and the event.
1007
+ collateralCount = vestingLoan.collateralCount;
1008
+
1009
+ // Load the vesting entries for the token ID whose rewards were collateralized.
1010
+ JBVestingData[] storage vestings = vestingDataOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
1011
+
1012
+ // Start at the first unexhausted vesting entry.
1013
+ uint256 vestedIndex = latestVestedIndexOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
1014
+
1015
+ // Stop at the boundary recorded when the loan opened, preserving newer vesting entries.
1016
+ uint256 vestingDataCount = vestingLoan.vestingDataCount;
1017
+
1018
+ // Mark each collateralized entry fully claimed because Revnet liquidation destroyed its backing tokens.
1019
+ while (vestedIndex < vestingDataCount) {
1020
+ vestings[vestedIndex].shareClaimed = MAX_SHARE;
1021
+
1022
+ unchecked {
1023
+ // Safe because the loop is bounded by the recorded vesting-entry count.
1024
+ ++vestedIndex;
1025
+ }
1026
+ }
1027
+
1028
+ // Skip over the written-off vesting entries without ever moving the cursor backwards.
1029
+ latestVestedIndexOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token] = vestedIndex;
1030
+
1031
+ // Remove the liquidated collateral from the amount still considered vesting.
1032
+ totalVestingAmountOf[vestingLoan.hook][vestingLoan.token] -= collateralCount;
1033
+
1034
+ // Remove the liquidated collateral from the loaned-vesting inventory.
1035
+ totalLoanedVestingAmountOf[vestingLoan.hook][vestingLoan.token] -= collateralCount;
1036
+
1037
+ // Clear the active loan lock for this token ID and reward token.
1038
+ delete activeVestingLoanIdOf[vestingLoan.hook][vestingLoan.tokenId][vestingLoan.token];
1039
+
1040
+ // Clear the loan metadata so it cannot be written off or repaid again.
1041
+ delete _vestingLoanOf[loanId];
1042
+
1043
+ emit LiquidatedVestingLoanWrittenOff({
1044
+ hook: vestingLoan.hook,
1045
+ tokenId: vestingLoan.tokenId,
1046
+ token: vestingLoan.token,
1047
+ loanId: loanId,
1048
+ collateralCount: collateralCount,
1049
+ caller: msg.sender
1050
+ });
1051
+ }
1052
+
552
1053
  /// @notice Accepts an ERC-20 funding transfer and returns the actual balance delta.
553
1054
  /// @param token The ERC-20 token to accept.
554
1055
  /// @param from The address to pull tokens from.
@@ -587,8 +1088,7 @@ abstract contract JBDistributor is IJBDistributor {
587
1088
  /// @param hook The stake source whose stakers receive the rewards.
588
1089
  /// @param token The reward token being funded.
589
1090
  /// @param amount The nominal amount to fund.
590
- /// @param claimDuration The number of seconds claimants have once the round becomes claimable.
591
- function _fund(address hook, IERC20 token, uint256 amount, uint48 claimDuration) internal {
1091
+ function _fund(address hook, IERC20 token, uint256 amount) internal {
592
1092
  // Native funding is measured by msg.value, not the caller-provided amount.
593
1093
  if (address(token) == JBConstants.NATIVE_TOKEN) {
594
1094
  amount = msg.value;
@@ -603,28 +1103,43 @@ abstract contract JBDistributor is IJBDistributor {
603
1103
  }
604
1104
 
605
1105
  // Store the accepted amount in this round's historical reward ledger.
606
- _recordRewardFunding({hook: hook, token: token, amount: amount, claimDuration: claimDuration});
1106
+ _recordRewardFunding({hook: hook, token: token, amount: amount});
607
1107
  }
608
1108
 
609
1109
  /// @notice Record accepted funding as the current round's reward pot.
610
1110
  /// @param hook The stake source whose stakers receive the rewards.
611
1111
  /// @param token The reward token.
612
1112
  /// @param amount The accepted funding amount.
613
- /// @param claimDuration The number of seconds claimants have once the round becomes claimable.
614
- function _recordRewardFunding(address hook, IERC20 token, uint256 amount, uint48 claimDuration) internal {
1113
+ function _recordRewardFunding(address hook, IERC20 token, uint256 amount) internal {
615
1114
  // Zero-value transfers do not create reward rounds or alter tracked balances.
616
1115
  if (amount == 0) return;
617
1116
 
618
- // Funding belongs to the round in progress when the distributor receives the rewards.
1117
+ // Add the accepted amount to the current reward ledger.
1118
+ _recordRewardRound({hook: hook, token: token, amount: amount});
1119
+
1120
+ // Keep the base distributor's balance accounting in sync for collection and conservation checks.
1121
+ _balanceOf[hook][token] += amount;
1122
+ _accountedBalanceOf[token] += amount;
1123
+ }
1124
+
1125
+ /// @notice Record rewards as the current round's claimable historical reward pot.
1126
+ /// @param hook The stake source whose stakers receive the rewards.
1127
+ /// @param token The reward token.
1128
+ /// @param amount The amount to add to the current reward round.
1129
+ function _recordRewardRound(address hook, IERC20 token, uint256 amount) internal {
1130
+ // Zero-value rewards do not create reward rounds.
1131
+ if (amount == 0) return;
1132
+
1133
+ // Rewards belong to the round in progress when they enter the ledger.
619
1134
  uint256 round = currentRound();
620
1135
 
621
1136
  // Load the current round's ledger entry for this hook and reward token.
622
1137
  JBRewardRoundData storage rewardRound = rewardRoundOf[hook][token][round];
623
1138
 
624
- // A zero deadline means no expiration; otherwise the clock starts once this round becomes claimable.
625
- uint48 claimDeadline = _claimDeadlineFor({round: round, claimDuration: claimDuration});
1139
+ // Every reward round in this contract uses the same immutable claim duration.
1140
+ uint48 claimDeadline = _claimDeadlineFor(round);
626
1141
 
627
- // First funding in a round locks that round's snapshot block and total stake for all later claims.
1142
+ // First value in a round locks that round's snapshot block and total stake.
628
1143
  if (rewardRound.amount == 0) {
629
1144
  // Record the exact historical block used for all stake lookups in this round.
630
1145
  uint256 snapshotBlock = _ensureSnapshotBlockFor(round);
@@ -632,24 +1147,15 @@ abstract contract JBDistributor is IJBDistributor {
632
1147
  // Store the snapshot block in the packed uint48 field.
633
1148
  rewardRound.snapshotBlock = _toUint48(snapshotBlock);
634
1149
 
635
- // Store the packed claim deadline chosen by the rewarder.
1150
+ // Store the packed claim deadline fixed for this distributor.
636
1151
  rewardRound.claimDeadline = claimDeadline;
637
1152
 
638
1153
  // Store the packed total stake that shares this round's reward pot.
639
1154
  rewardRound.totalStake = _toUint208(_totalStake({hook: hook, blockNumber: snapshotBlock}));
640
- } else if (rewardRound.claimDeadline != claimDeadline) {
641
- // All fundings merged into the same round must have one deadline for deterministic expiry.
642
- revert JBDistributor_ClaimDeadlineMismatch({
643
- existingDeadline: rewardRound.claimDeadline, newDeadline: claimDeadline
644
- });
645
1155
  }
646
1156
 
647
- // Multiple fundings in the same round share the same snapshot and accumulate into one reward pot.
1157
+ // Multiple additions in the same round share the same snapshot and reward pot.
648
1158
  rewardRound.amount = _toUint208(uint256(rewardRound.amount) + amount);
649
-
650
- // Keep the base distributor's balance accounting in sync for collection and conservation checks.
651
- _balanceOf[hook][token] += amount;
652
- _accountedBalanceOf[token] += amount;
653
1159
  }
654
1160
 
655
1161
  /// @notice Burn one expired reward round's unclaimed inventory.
@@ -673,14 +1179,14 @@ abstract contract JBDistributor is IJBDistributor {
673
1179
  // Mark the whole round settled before transferring to close reentrancy-sensitive accounting.
674
1180
  rewardRound.claimedAmount = rewardRound.amount;
675
1181
 
676
- // Remove the expired remainder from distributor inventory and send it to the burn sink.
1182
+ // Remove the expired remainder from distributor inventory and burn it through the JB controller.
677
1183
  _burnRewardTokens({hook: hook, token: token, amount: burnAmount});
678
1184
 
679
1185
  // Surface the permissionless burn for off-chain accounting.
680
1186
  emit ExpiredRewardsBurned({hook: hook, round: round, token: token, amount: burnAmount, caller: msg.sender});
681
1187
  }
682
1188
 
683
- /// @notice Burn reward inventory by transferring it to the burn sink.
1189
+ /// @notice Burn reward inventory using the JB controller.
684
1190
  /// @param hook The hook whose tracked balance is being burned.
685
1191
  /// @param token The reward token to burn.
686
1192
  /// @param amount The amount to burn.
@@ -688,22 +1194,35 @@ abstract contract JBDistributor is IJBDistributor {
688
1194
  // No-op zero burns so callers can batch empty or already-settled rounds safely.
689
1195
  if (amount == 0) return;
690
1196
 
1197
+ // A missing controller means there is no burn authority for any reward token.
1198
+ if (address(CONTROLLER) == address(0)) revert JBDistributor_TokenNotBurnable({token: address(token)});
1199
+
1200
+ // Only JB project tokens can be burned through `JBController.burnTokensOf`.
1201
+ uint256 projectId = CONTROLLER.TOKENS().projectIdOf({token: IJBToken(address(token))});
1202
+
1203
+ // Revert instead of sending unsupported rewards to a burn address.
1204
+ if (projectId == 0) revert JBDistributor_TokenNotBurnable({token: address(token)});
1205
+
691
1206
  // Remove the burned amount from the hook's reward inventory.
692
1207
  _balanceOf[hook][token] -= amount;
693
1208
 
694
1209
  // Remove the same amount from the global inventory tracked for this token.
695
1210
  _accountedBalanceOf[token] -= amount;
696
1211
 
697
- // Native rewards cannot be ERC-20-burned, so send them to the shared burn sink.
698
- if (address(token) == JBConstants.NATIVE_TOKEN) {
699
- // Forward the exact expired native amount to the burn sink.
700
- (bool success,) = BURN_ADDRESS.call{value: amount}("");
1212
+ // Burn from this distributor's project-token balance or token credits.
1213
+ CONTROLLER.burnTokensOf({holder: address(this), projectId: projectId, tokenCount: amount, memo: ""});
1214
+ }
701
1215
 
702
- // Revert if the native sink transfer fails, preserving accounting by reverting the whole burn.
703
- if (!success) revert JBDistributor_NativeTransferFailed({beneficiary: BURN_ADDRESS, amount: amount});
704
- } else {
705
- // ERC-20 rewards are removed from usable inventory by sending them to the same burn sink.
706
- token.safeTransfer({to: BURN_ADDRESS, value: amount});
1216
+ /// @notice Resolve the revnet project ID for a reward token.
1217
+ /// @param token The reward token to resolve.
1218
+ /// @return revnetId The token's revnet project ID.
1219
+ function _revnetIdOf(IERC20 token) internal view returns (uint256 revnetId) {
1220
+ // The reward token must be registered as a JB project token.
1221
+ revnetId = CONTROLLER.TOKENS().projectIdOf({token: IJBToken(address(token))});
1222
+
1223
+ // The project must be owned by the configured REVOwner.
1224
+ if (revnetId == 0 || CONTROLLER.PROJECTS().ownerOf(revnetId) != address(REV_OWNER)) {
1225
+ revert JBDistributor_NotRevnetRewardToken({token: address(token)});
707
1226
  }
708
1227
  }
709
1228
 
@@ -716,7 +1235,7 @@ abstract contract JBDistributor is IJBDistributor {
716
1235
  castValue = uint208(value);
717
1236
  }
718
1237
 
719
- /// @notice Cast a reward-round value to uint48.
1238
+ /// @notice Cast a value to uint48.
720
1239
  /// @param value The value to cast.
721
1240
  /// @return castValue The cast value.
722
1241
  function _toUint48(uint256 value) internal pure returns (uint48 castValue) {
@@ -763,9 +1282,11 @@ abstract contract JBDistributor is IJBDistributor {
763
1282
  return _snapshotAtRoundOf[hook][token][round];
764
1283
  }
765
1284
 
1285
+ // Exclude collateralized vesting inventory because those tokens have been burned into distributor-held loans.
1286
+ uint256 vestingAmount = totalVestingAmountOf[hook][token] - totalLoanedVestingAmountOf[hook][token];
1287
+
766
1288
  // Take a snapshot using the hook's tracked balance.
767
- snapshot =
768
- JBTokenSnapshotData({balance: _balanceOf[hook][token], vestingAmount: totalVestingAmountOf[hook][token]});
1289
+ snapshot = JBTokenSnapshotData({balance: _balanceOf[hook][token], vestingAmount: vestingAmount});
769
1290
 
770
1291
  // Store the snapshot and mark it initialized.
771
1292
  _snapshotAtRoundOf[hook][token][round] = snapshot;
@@ -781,16 +1302,15 @@ abstract contract JBDistributor is IJBDistributor {
781
1302
  });
782
1303
  }
783
1304
 
784
- /// @notice The deadline for a reward round with the given claim duration.
1305
+ /// @notice The deadline for a reward round using this distributor's immutable claim duration.
785
1306
  /// @param round The reward round.
786
- /// @param claimDuration The claim duration once the round becomes claimable.
787
1307
  /// @return claimDeadline The deadline timestamp. Zero means no expiration.
788
- function _claimDeadlineFor(uint256 round, uint48 claimDuration) internal view returns (uint48 claimDeadline) {
1308
+ function _claimDeadlineFor(uint256 round) internal view returns (uint48 claimDeadline) {
789
1309
  // Zero duration keeps the round non-expiring and backward compatible with existing fund paths.
790
- if (claimDuration == 0) return 0;
1310
+ if (CLAIM_DURATION == 0) return 0;
791
1311
 
792
1312
  // Start the window at the next round boundary, when the funded round first becomes claimable.
793
- claimDeadline = _toUint48(roundStartTimestamp(round + 1) + claimDuration);
1313
+ claimDeadline = _toUint48(roundStartTimestamp(round + 1) + CLAIM_DURATION);
794
1314
  }
795
1315
 
796
1316
  /// @notice Whether a reward round has passed its claim deadline.
@@ -852,9 +1372,10 @@ abstract contract JBDistributor is IJBDistributor {
852
1372
  } else {
853
1373
  token.safeTransfer({to: beneficiary, value: totalTokenAmount});
854
1374
  }
1375
+ } else {
1376
+ // If forfeiture: remove the unlocked amount from inventory and burn it through the JB controller.
1377
+ _burnRewardTokens({hook: hook, token: token, amount: totalTokenAmount});
855
1378
  }
856
- // If forfeiture: _balanceOf is NOT decremented so the forfeited tokens
857
- // return to the hook's distributable pool for future rounds.
858
1379
  }
859
1380
 
860
1381
  unchecked {
@@ -881,6 +1402,9 @@ abstract contract JBDistributor is IJBDistributor {
881
1402
  for (uint256 j; j < tokenIds.length;) {
882
1403
  uint256 tokenId = tokenIds[j];
883
1404
 
1405
+ // Loan collateral stays locked until repayment restores it to this distributor.
1406
+ _requireNoActiveVestingLoan({hook: hook, tokenId: tokenId, token: token});
1407
+
884
1408
  // Keep a reference to the latest vested index.
885
1409
  uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
886
1410
 
@@ -898,7 +1422,7 @@ abstract contract JBDistributor is IJBDistributor {
898
1422
  uint256 lockedShare = JBVestingMath.lockedShareOf({
899
1423
  releaseRound: vesting.releaseRound,
900
1424
  currentRound: round,
901
- vestingRounds: vestingRounds,
1425
+ vestingRounds: VESTING_ROUNDS,
902
1426
  maxShare: MAX_SHARE
903
1427
  });
904
1428
 
@@ -1029,6 +1553,41 @@ abstract contract JBDistributor is IJBDistributor {
1029
1553
  // ----------------------- internal views ---------------------------- //
1030
1554
  //*********************************************************************//
1031
1555
 
1556
+ /// @notice The remaining uncollected vesting amount for one token ID and reward token.
1557
+ /// @param hook The hook the token ID belongs to.
1558
+ /// @param tokenId The token ID to check.
1559
+ /// @param token The reward token to check.
1560
+ /// @return tokenAmount The amount still locked or unlocked-but-uncollected.
1561
+ function _unclaimedVestingAmountOf(
1562
+ address hook,
1563
+ uint256 tokenId,
1564
+ IERC20 token
1565
+ )
1566
+ internal
1567
+ view
1568
+ returns (uint256 tokenAmount)
1569
+ {
1570
+ // Keep a reference to the latest fully vested index.
1571
+ uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
1572
+
1573
+ // Keep a reference to the number of vesting entries for the token ID and token.
1574
+ uint256 numberOfVestingRounds = vestingDataOf[hook][tokenId][token].length;
1575
+
1576
+ while (vestedIndex < numberOfVestingRounds) {
1577
+ // Keep a reference to the vested data being iterated on.
1578
+ JBVestingData memory vesting = vestingDataOf[hook][tokenId][token][vestedIndex];
1579
+
1580
+ // Use `original - alreadyPaid` to include rounding dust in the remaining amount.
1581
+ tokenAmount += JBVestingMath.unclaimedAmountOf({
1582
+ amount: vesting.amount, shareClaimed: vesting.shareClaimed, maxShare: MAX_SHARE
1583
+ });
1584
+
1585
+ unchecked {
1586
+ ++vestedIndex;
1587
+ }
1588
+ }
1589
+ }
1590
+
1032
1591
  /// @notice Check whether an account is authorized to collect vested rewards for the given token ID. For 721
1033
1592
  /// distributors this is ownership; for token distributors this is address-encoding match.
1034
1593
  /// @param hook The hook the token belongs to.
@@ -1045,6 +1604,19 @@ abstract contract JBDistributor is IJBDistributor {
1045
1604
  if (token != address(0)) revert JBDistributor_ReentrantTokenTransfer(token);
1046
1605
  }
1047
1606
 
1607
+ /// @notice Revert if a token ID's vesting rewards are locked in a distributor-owned loan.
1608
+ /// @param hook The hook the token ID belongs to.
1609
+ /// @param tokenId The token ID to check.
1610
+ /// @param token The reward token to check.
1611
+ function _requireNoActiveVestingLoan(address hook, uint256 tokenId, IERC20 token) internal view {
1612
+ uint256 loanId = activeVestingLoanIdOf[hook][tokenId][token];
1613
+ if (loanId != 0) {
1614
+ revert JBDistributor_VestingLoanOutstanding({
1615
+ hook: hook, tokenId: tokenId, token: address(token), loanId: loanId
1616
+ });
1617
+ }
1618
+ }
1619
+
1048
1620
  /// @notice Check whether a staker token has been burned. Burned tokens are excluded from stake calculations
1049
1621
  /// and their unvested rewards can be released via `releaseForfeitedRewards`.
1050
1622
  /// @param hook The hook the token belongs to.