@bananapus/distributor-v6 0.0.30 → 0.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,10 +39,10 @@ If the issue is "where did the project's value come from?" start in `nana-core-v
39
39
  2. accepted funding is assigned to the current reward round for the chosen token or 721 stake source
40
40
  3. the distributor's immutable claim duration decides whether funded reward rounds expire
41
41
  4. the encoded token staker or current NFT owner later claims completed past reward rounds into a fresh vesting entry
42
- 5. anyone can burn expired unclaimed reward rounds after their deadline
42
+ 5. anyone can recycle expired unclaimed reward rounds after their deadline
43
43
  6. recipients collect their vested share as the configured vesting schedule unlocks
44
44
  7. eligible claimants can borrow against vesting revnet rewards without bypassing the vesting schedule
45
- 8. some unclaimable value can be burned through explicit cleanup paths, depending on the distributor type
45
+ 8. some unclaimable value can be recycled through explicit cleanup paths, depending on the distributor type
46
46
 
47
47
  This repo does not explain why an allocation exists. It only defines how funded inventory is handed out.
48
48
 
@@ -60,10 +60,9 @@ This repo does not explain why an allocation exists. It only defines how funded
60
60
  token rewards are claimed by the encoded staker address, while 721 rewards are claimed by the current NFT owner
61
61
  - `CLAIM_DURATION` is fixed at deployment; `0` means reward rounds do not expire, otherwise all funding paths use the
62
62
  same deadline measured from when the funded round first becomes claimable
63
- - `burnExpiredRewards` is permissionless and only burns the unclaimed remainder; already-materialized vesting entries
63
+ - `burnExpiredRewards` is permissionless and only recycles the unclaimed remainder; already-materialized vesting entries
64
64
  remain claimable on their normal vesting curve
65
- - expired and forfeited rewards are burned with `JBController.burnTokensOf`; rewards that are not registered project
66
- tokens in the configured controller cannot use those burn paths
65
+ - expired and forfeited rewards stay in distributor inventory and are recycled into the current reward round
67
66
  - revnet loan-backed vesting is opt-in at deployment; the reward token must be a REVOwner-owned revnet token, the
68
67
  distributor keeps the loan NFT, and repayment restores the original vesting schedule instead of releasing all
69
68
  collateral immediately
@@ -131,7 +130,7 @@ script/
131
130
  - operational mistakes often come from funding the wrong asset or underfunding the distributor
132
131
  - teams should review claim timing and snapshot assumptions with the same care they review the payout source
133
132
  - deployers that set a nonzero claim duration should choose a window long enough for expected claimants, because
134
- expired unclaimed rewards can be burned by anyone
133
+ expired unclaimed rewards can be recycled by anyone
135
134
 
136
135
  ## For AI Agents
137
136
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@bananapus/distributor-v6",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/Bananapus/nana-distributor-v6"
7
+ "url": "git+https://github.com/Bananapus/nana-distributor-v6.git"
8
8
  },
9
9
  "files": [
10
10
  "foundry.toml",
@@ -24,8 +24,8 @@
24
24
  "deploy:testnets": "source ./.env && npx sphinx propose ./script/Deploy.s.sol --networks testnets"
25
25
  },
26
26
  "dependencies": {
27
- "@bananapus/721-hook-v6": "^0.0.57",
28
- "@bananapus/core-v6": "^0.0.68",
27
+ "@bananapus/721-hook-v6": "^0.0.59",
28
+ "@bananapus/core-v6": "^0.0.72",
29
29
  "@bananapus/permission-ids-v6": "^0.0.27",
30
30
  "@openzeppelin/contracts": "5.6.1",
31
31
  "@prb/math": "4.1.1",
@@ -29,7 +29,7 @@ import {JBVestingData} from "./structs/JBVestingData.sol";
29
29
  /// @dev Any project can use this distributor by configuring a payout split with
30
30
  /// `hook = this contract` and `beneficiary = address(their 721 hook)`.
31
31
  /// @dev The stake weight of each NFT is its tier's `votingUnits`. Burned NFTs are excluded from the total stake
32
- /// calculation and their unlocked forfeited rewards can be burned via `releaseForfeitedRewards`.
32
+ /// calculation and their unlocked forfeited rewards can be recycled via `releaseForfeitedRewards`.
33
33
  /// @dev Funded rewards are assigned to the funding round. NFT owners claim historical rounds lazily; all unclaimed
34
34
  /// past rewards begin vesting when the current NFT owner claims, not when the rewards were funded.
35
35
  /// @dev Implements `IJBSplitHook` so it can receive tokens directly from Juicebox project payout splits.
@@ -85,7 +85,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
85
85
  //*********************************************************************//
86
86
 
87
87
  /// @param directory The JB directory used to verify terminal/controller callers.
88
- /// @param controller The JB controller used to burn expired or forfeited project-token rewards.
88
+ /// @param controller The JB controller used for token registry lookups and revnet loan permissions.
89
89
  /// @param revLoans The Revnet loans contract used to borrow against vested revnet rewards.
90
90
  /// @param revOwner The REVOwner contract that must own revnet reward token projects.
91
91
  /// @param initialRoundDuration The duration of each round, specified in seconds.
@@ -295,9 +295,9 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
295
295
 
296
296
  // Skip rounds that never received funding.
297
297
  if (rewardRound.amount != 0) {
298
- // Expired rounds can no longer be claimed; burn their unclaimed remainder instead.
298
+ // Expired rounds can no longer be claimed as-is; recycle their unclaimed remainder instead.
299
299
  if (_rewardRoundExpired(rewardRound)) {
300
- _burnExpiredRewardRound({hook: ctx.hook, token: token, round: rewardRoundNumber});
300
+ _recycleExpiredRewardRound({hook: ctx.hook, token: token, round: rewardRoundNumber});
301
301
  } else if (rewardRound.totalStake != 0) {
302
302
  // Bundle the fixed round data used by every NFT in the batch.
303
303
  JBVestContext memory vestCtx = JBVestContext({
@@ -314,7 +314,8 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
314
314
  uint256 roundVestingAmount =
315
315
  _claimRewardRoundForTokenIds({ctx: vestCtx, tokenIds: tokenIds, tokenAmounts: tokenAmounts});
316
316
 
317
- // Track only the amount that actually started vesting, leaving zero-vote and dust amounts burnable.
317
+ // Track only the amount that actually started vesting, leaving zero-vote and dust amounts
318
+ // recyclable.
318
319
  if (roundVestingAmount != 0) {
319
320
  rewardRound.claimedAmount = _toUint208(uint256(rewardRound.claimedAmount) + roundVestingAmount);
320
321
 
@@ -696,14 +697,11 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
696
697
  // Skip already-vested tokenIds — check if the last vesting entry targets the same release round.
697
698
  {
698
699
  // Load the number of existing vesting entries for this token.
699
- uint256 numVesting = vestingDataOf[ctx.hook][tokenId][ctx.token].length;
700
+ JBVestingData[] storage vestings = vestingDataOf[ctx.hook][tokenId][ctx.token];
701
+ uint256 numVesting = vestings.length;
700
702
 
701
703
  // If at least one entry exists and its release round matches, this token was already vested this round.
702
- if (
703
- numVesting != 0
704
- && vestingDataOf[ctx.hook][tokenId][ctx.token][numVesting - 1].releaseRound
705
- == ctx.vestingReleaseRound
706
- ) {
704
+ if (numVesting != 0 && vestings[numVesting - 1].releaseRound == ctx.vestingReleaseRound) {
707
705
  return (0, newUniqueCount);
708
706
  }
709
707
  }
@@ -80,6 +80,9 @@ abstract contract JBDistributor is IJBDistributor {
80
80
  /// @notice Thrown when unexpected native ETH is sent with an ERC-20 operation.
81
81
  error JBDistributor_UnexpectedNativeValue(uint256 msgValue, address token);
82
82
 
83
+ /// @notice Thrown when an ERC-20 repayment does not credit the exact amount pulled from the caller.
84
+ error JBDistributor_UnexpectedRepayAmount(uint256 amount, uint256 expectedAmount);
85
+
83
86
  /// @notice Thrown when a function requires exactly one reward token.
84
87
  error JBDistributor_UnexpectedTokenCount(uint256 tokenCount);
85
88
 
@@ -92,9 +95,6 @@ abstract contract JBDistributor is IJBDistributor {
92
95
  /// @notice Thrown when vesting loans are requested from a distributor with no vesting period.
93
96
  error JBDistributor_VestingLoansDisabled();
94
97
 
95
- /// @notice Thrown when rewards cannot be burned by the JB controller.
96
- error JBDistributor_TokenNotBurnable(address token);
97
-
98
98
  /// @notice Thrown when a value cannot fit in a uint208 reward-round field.
99
99
  error JBDistributor_Uint208Overflow(uint256 value);
100
100
 
@@ -123,7 +123,7 @@ abstract contract JBDistributor is IJBDistributor {
123
123
  /// @dev A zero duration means reward rounds do not expire.
124
124
  uint48 public immutable override CLAIM_DURATION;
125
125
 
126
- /// @notice The JB controller used to burn expired or forfeited project-token rewards.
126
+ /// @notice The JB controller used for token registry lookups and revnet loan permissions.
127
127
  IJBController public immutable override CONTROLLER;
128
128
 
129
129
  /// @notice The duration of each round, specified in seconds.
@@ -228,7 +228,7 @@ abstract contract JBDistributor is IJBDistributor {
228
228
  // -------------------------- constructor ---------------------------- //
229
229
  //*********************************************************************//
230
230
 
231
- /// @param controller The JB controller used to burn expired or forfeited project-token rewards.
231
+ /// @param controller The JB controller used for token registry lookups and revnet loan permissions.
232
232
  /// @param revLoans The Revnet loans contract used to borrow against vested revnet rewards.
233
233
  /// @param revOwner The REVOwner contract that must own revnet reward token projects.
234
234
  /// @param initialRoundDuration The duration of each round, specified in seconds.
@@ -349,11 +349,12 @@ abstract contract JBDistributor is IJBDistributor {
349
349
  _fund({hook: hook, token: token, amount: amount});
350
350
  }
351
351
 
352
- /// @notice Burn unclaimed rewards from expired reward rounds.
353
- /// @param hook The hook whose expired rewards should be burned.
354
- /// @param token The reward token to burn.
355
- /// @param rounds The reward rounds to burn.
356
- /// @return amount The total amount burned.
352
+ /// @notice Recycle unclaimed rewards from expired reward rounds into the current reward round.
353
+ /// @dev The selector name is kept for compatibility with existing keeper integrations.
354
+ /// @param hook The hook whose expired rewards should be recycled.
355
+ /// @param token The reward token to recycle.
356
+ /// @param rounds The reward rounds to recycle.
357
+ /// @return amount The total amount recycled.
357
358
  function burnExpiredRewards(
358
359
  address hook,
359
360
  IERC20 token,
@@ -364,13 +365,13 @@ abstract contract JBDistributor is IJBDistributor {
364
365
  override
365
366
  returns (uint256 amount)
366
367
  {
367
- // Do not let reward-token callbacks burn inventory during an inbound balance-delta measurement.
368
+ // Do not let reward-token callbacks recycle inventory during an inbound balance-delta measurement.
368
369
  _requireNotAcceptingToken();
369
370
 
370
371
  // Process every requested round independently so callers can batch keeper work.
371
372
  for (uint256 i; i < rounds.length;) {
372
373
  // Add this round's expired remainder to the batch total.
373
- amount += _burnExpiredRewardRound({hook: hook, token: token, round: rounds[i]});
374
+ amount += _recycleExpiredRewardRound({hook: hook, token: token, round: rounds[i]});
374
375
 
375
376
  unchecked {
376
377
  // Safe because the loop is bounded by calldata length.
@@ -385,13 +386,12 @@ abstract contract JBDistributor is IJBDistributor {
385
386
  _ensureSnapshotBlock(currentRound());
386
387
  }
387
388
 
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.
389
+ /// @notice Recycle unlocked rewards tied to burned tokens into the current reward round.
390
+ /// @dev Anyone can call this for burned tokens.
391
391
  /// @param hook The hook whose tokens were burned.
392
392
  /// @param tokenIds The IDs of the burned tokens (reverts if any are not actually burned).
393
- /// @param tokens The reward tokens to release.
394
- /// @param beneficiary Unused for forfeiture — tokens are burned. Kept for interface compatibility.
393
+ /// @param tokens The reward tokens to recycle.
394
+ /// @param beneficiary Unused for forfeiture. Kept for interface compatibility.
395
395
  function releaseForfeitedRewards(
396
396
  address hook,
397
397
  uint256[] calldata tokenIds,
@@ -404,7 +404,7 @@ abstract contract JBDistributor is IJBDistributor {
404
404
  // Do not let reward-token callbacks mutate vesting state during inbound balance-delta accounting.
405
405
  _requireNotAcceptingToken();
406
406
 
407
- // Make sure that all tokens are burned.
407
+ // Make sure that all staker token IDs are burned.
408
408
  for (uint256 i; i < tokenIds.length;) {
409
409
  if (!_tokenBurned({hook: hook, tokenId: tokenIds[i]})) {
410
410
  revert JBDistributor_NoAccess({hook: hook, tokenId: tokenIds[i], account: msg.sender});
@@ -414,7 +414,7 @@ abstract contract JBDistributor is IJBDistributor {
414
414
  }
415
415
  }
416
416
 
417
- // Unlock the rewards and burn the forfeited amount.
417
+ // Unlock the rewards and recycle the forfeited amount.
418
418
  _unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: false});
419
419
  }
420
420
 
@@ -473,14 +473,15 @@ abstract contract JBDistributor is IJBDistributor {
473
473
  // Keep a reference to the latest vested index.
474
474
  uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
475
475
 
476
- // Keep a reference to the number of vesting rounds for the tokenId and token.
477
- uint256 numberOfVestingRounds = vestingDataOf[hook][tokenId][token].length;
476
+ // Keep a reference to the vesting data array.
477
+ JBVestingData[] storage vestings = vestingDataOf[hook][tokenId][token];
478
+ uint256 numberOfVestingRounds = vestings.length;
478
479
 
479
480
  while (vestedIndex < numberOfVestingRounds) {
480
481
  uint256 lockedShare;
481
482
 
482
483
  // Keep a reference to the vested data being iterated on.
483
- JBVestingData memory vesting = vestingDataOf[hook][tokenId][token][vestedIndex];
484
+ JBVestingData memory vesting = vestings[vestedIndex];
484
485
 
485
486
  lockedShare = JBVestingMath.lockedShareOf({
486
487
  releaseRound: vesting.releaseRound,
@@ -920,9 +921,14 @@ abstract contract JBDistributor is IJBDistributor {
920
921
  revert JBDistributor_UnexpectedNativeValue({msgValue: msg.value, token: loan.sourceToken});
921
922
  }
922
923
 
923
- // Pull the exact current payoff from the caller.
924
+ // Pull the exact current payoff from the caller. Existing distributor inventory must not cover a shortfall.
924
925
  IERC20 sourceToken = IERC20(loan.sourceToken);
926
+ uint256 sourceBalanceBefore = sourceToken.balanceOf(address(this));
925
927
  sourceToken.safeTransferFrom({from: msg.sender, to: address(this), value: repayBorrowAmount});
928
+ uint256 receivedAmount = sourceToken.balanceOf(address(this)) - sourceBalanceBefore;
929
+ if (receivedAmount != repayBorrowAmount) {
930
+ revert JBDistributor_UnexpectedRepayAmount({amount: receivedAmount, expectedAmount: repayBorrowAmount});
931
+ }
926
932
 
927
933
  // Approve only the exact amount needed for this repayment.
928
934
  sourceToken.forceApprove({spender: address(REV_LOANS), value: repayBorrowAmount});
@@ -1158,59 +1164,47 @@ abstract contract JBDistributor is IJBDistributor {
1158
1164
  rewardRound.amount = _toUint208(uint256(rewardRound.amount) + amount);
1159
1165
  }
1160
1166
 
1161
- /// @notice Burn one expired reward round's unclaimed inventory.
1162
- /// @param hook The hook whose expired rewards should be burned.
1163
- /// @param token The reward token to burn.
1164
- /// @param round The reward round to burn.
1165
- /// @return burnAmount The amount burned.
1166
- function _burnExpiredRewardRound(address hook, IERC20 token, uint256 round) internal returns (uint256 burnAmount) {
1167
+ /// @notice Recycle one expired reward round's unclaimed inventory into the current reward round.
1168
+ /// @param hook The hook whose expired rewards should be recycled.
1169
+ /// @param token The reward token to recycle.
1170
+ /// @param round The reward round to recycle.
1171
+ /// @return recycleAmount The amount recycled.
1172
+ function _recycleExpiredRewardRound(
1173
+ address hook,
1174
+ IERC20 token,
1175
+ uint256 round
1176
+ )
1177
+ internal
1178
+ returns (uint256 recycleAmount)
1179
+ {
1167
1180
  // Load the reward round once so expiry, claimed amount, and funded amount stay in sync.
1168
1181
  JBRewardRoundData storage rewardRound = rewardRoundOf[hook][token][round];
1169
1182
 
1170
1183
  // Ignore rounds that either never expire or have not reached their deadline yet.
1171
1184
  if (!_rewardRoundExpired(rewardRound)) return 0;
1172
1185
 
1173
- // If prior claims have already materialized the whole round, there is nothing left to burn.
1186
+ // If prior claims have already materialized the whole round, there is nothing left to recycle.
1174
1187
  if (rewardRound.claimedAmount >= rewardRound.amount) return 0;
1175
1188
 
1176
- // Burn only the unclaimed remainder, preserving amounts that already started vesting.
1177
- burnAmount = uint256(rewardRound.amount) - uint256(rewardRound.claimedAmount);
1189
+ // Recycle only the unclaimed remainder, preserving amounts that already started vesting.
1190
+ recycleAmount = uint256(rewardRound.amount) - uint256(rewardRound.claimedAmount);
1178
1191
 
1179
- // Mark the whole round settled before transferring to close reentrancy-sensitive accounting.
1192
+ // Mark the whole round settled before writing the recycled amount into a fresh round.
1180
1193
  rewardRound.claimedAmount = rewardRound.amount;
1181
1194
 
1182
- // Remove the expired remainder from distributor inventory and burn it through the JB controller.
1183
- _burnRewardTokens({hook: hook, token: token, amount: burnAmount});
1195
+ // Keep the inventory in the distributor and give the current staker set a new claimable round.
1196
+ uint256 recycledToRound = currentRound();
1197
+ _recordRewardRound({hook: hook, token: token, amount: recycleAmount});
1184
1198
 
1185
- // Surface the permissionless burn for off-chain accounting.
1186
- emit ExpiredRewardsBurned({hook: hook, round: round, token: token, amount: burnAmount, caller: msg.sender});
1187
- }
1188
-
1189
- /// @notice Burn reward inventory using the JB controller.
1190
- /// @param hook The hook whose tracked balance is being burned.
1191
- /// @param token The reward token to burn.
1192
- /// @param amount The amount to burn.
1193
- function _burnRewardTokens(address hook, IERC20 token, uint256 amount) internal {
1194
- // No-op zero burns so callers can batch empty or already-settled rounds safely.
1195
- if (amount == 0) return;
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
-
1206
- // Remove the burned amount from the hook's reward inventory.
1207
- _balanceOf[hook][token] -= amount;
1208
-
1209
- // Remove the same amount from the global inventory tracked for this token.
1210
- _accountedBalanceOf[token] -= amount;
1211
-
1212
- // Burn from this distributor's project-token balance or token credits.
1213
- CONTROLLER.burnTokensOf({holder: address(this), projectId: projectId, tokenCount: amount, memo: ""});
1199
+ // Surface the permissionless recycle for off-chain accounting.
1200
+ emit ExpiredRewardsRecycled({
1201
+ hook: hook,
1202
+ fromRound: round,
1203
+ toRound: recycledToRound,
1204
+ token: token,
1205
+ amount: recycleAmount,
1206
+ caller: msg.sender
1207
+ });
1214
1208
  }
1215
1209
 
1216
1210
  /// @notice Resolve the revnet project ID for a reward token.
@@ -1315,7 +1309,7 @@ abstract contract JBDistributor is IJBDistributor {
1315
1309
 
1316
1310
  /// @notice Whether a reward round has passed its claim deadline.
1317
1311
  /// @param rewardRound The reward round data.
1318
- /// @return expired True if unclaimed rewards can be burned.
1312
+ /// @return expired True if unclaimed rewards can be recycled.
1319
1313
  function _rewardRoundExpired(JBRewardRoundData storage rewardRound) internal view returns (bool expired) {
1320
1314
  // Copy the packed deadline into memory so the zero check and timestamp compare use the same value.
1321
1315
  uint48 claimDeadline = rewardRound.claimDeadline;
@@ -1373,8 +1367,11 @@ abstract contract JBDistributor is IJBDistributor {
1373
1367
  token.safeTransfer({to: beneficiary, value: totalTokenAmount});
1374
1368
  }
1375
1369
  } 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});
1370
+ // If forfeiture: keep inventory in the distributor and give the current staker set a fresh round.
1371
+ _recordRewardRound({hook: hook, token: token, amount: totalTokenAmount});
1372
+ emit ForfeitedRewardsRecycled({
1373
+ hook: hook, round: round, token: token, amount: totalTokenAmount, caller: msg.sender
1374
+ });
1378
1375
  }
1379
1376
  }
1380
1377
 
@@ -1570,12 +1567,13 @@ abstract contract JBDistributor is IJBDistributor {
1570
1567
  // Keep a reference to the latest fully vested index.
1571
1568
  uint256 vestedIndex = latestVestedIndexOf[hook][tokenId][token];
1572
1569
 
1573
- // Keep a reference to the number of vesting entries for the token ID and token.
1574
- uint256 numberOfVestingRounds = vestingDataOf[hook][tokenId][token].length;
1570
+ // Keep a reference to the vesting data array.
1571
+ JBVestingData[] storage vestings = vestingDataOf[hook][tokenId][token];
1572
+ uint256 numberOfVestingRounds = vestings.length;
1575
1573
 
1576
1574
  while (vestedIndex < numberOfVestingRounds) {
1577
1575
  // Keep a reference to the vested data being iterated on.
1578
- JBVestingData memory vesting = vestingDataOf[hook][tokenId][token][vestedIndex];
1576
+ JBVestingData memory vesting = vestings[vestedIndex];
1579
1577
 
1580
1578
  // Use `original - alreadyPaid` to include rounding dust in the remaining amount.
1581
1579
  tokenAmount += JBVestingMath.unclaimedAmountOf({
@@ -1617,8 +1615,8 @@ abstract contract JBDistributor is IJBDistributor {
1617
1615
  }
1618
1616
  }
1619
1617
 
1620
- /// @notice Check whether a staker token has been burned. Burned tokens are excluded from stake calculations
1621
- /// and their unvested rewards can be released via `releaseForfeitedRewards`.
1618
+ /// @notice Check whether a staker token has been burned. Burned tokens are excluded from stake calculations,
1619
+ /// and their unlocked forfeited rewards can be recycled via `releaseForfeitedRewards`.
1622
1620
  /// @param hook The hook the token belongs to.
1623
1621
  /// @param tokenId The token ID to check.
1624
1622
  /// @return tokenWasBurned True if the token has been burned.
@@ -69,7 +69,7 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
69
69
  //*********************************************************************//
70
70
 
71
71
  /// @param directory The JB directory used to verify terminal/controller callers.
72
- /// @param controller The JB controller used to burn expired or forfeited project-token rewards.
72
+ /// @param controller The JB controller used for token registry lookups and revnet loan permissions.
73
73
  /// @param revLoans The Revnet loans contract used to borrow against vested revnet rewards.
74
74
  /// @param revOwner The REVOwner contract that must own revnet reward token projects.
75
75
  /// @param initialRoundDuration The duration of each round, specified in seconds.
@@ -328,9 +328,9 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
328
328
 
329
329
  // Skip rounds that never received funding.
330
330
  if (rewardRound.amount != 0) {
331
- // Expired rounds can no longer be claimed; burn their unclaimed remainder instead.
331
+ // Expired rounds can no longer be claimed as-is; recycle their unclaimed remainder instead.
332
332
  if (_rewardRoundExpired(rewardRound)) {
333
- _burnExpiredRewardRound({hook: hook, token: token, round: rewardRoundNumber});
333
+ _recycleExpiredRewardRound({hook: hook, token: token, round: rewardRoundNumber});
334
334
  } else if (rewardRound.totalStake != 0) {
335
335
  // Use the funding round's snapshot block, not the block at which the staker finally claims.
336
336
  uint256 tokenStakeAmount =
@@ -344,7 +344,7 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
344
344
 
345
345
  // Ignore floor-rounded zero claims to avoid unnecessary storage writes.
346
346
  if (claimAmount != 0) {
347
- // Track the portion that has started vesting so expiry burns only the remainder.
347
+ // Track the portion that has started vesting so expiry recycles only the remainder.
348
348
  rewardRound.claimedAmount = _toUint208(uint256(rewardRound.claimedAmount) + claimAmount);
349
349
 
350
350
  // Add this round's vested amount to the staker's cumulative claim.
@@ -81,13 +81,29 @@ interface IJBDistributor {
81
81
  /// @param caller The address that triggered the snapshot recording.
82
82
  event RoundSnapshotRecorded(uint256 indexed round, uint256 snapshotBlock, address caller);
83
83
 
84
- /// @notice Emitted when an expired reward round's unclaimed amount is burned.
85
- /// @param hook The hook whose expired rewards were burned.
86
- /// @param round The expired reward round.
87
- /// @param token The reward token that was burned.
88
- /// @param amount The unclaimed reward amount burned.
89
- /// @param caller The address that triggered the burn.
90
- event ExpiredRewardsBurned(
84
+ /// @notice Emitted when an expired reward round's unclaimed amount is recycled into a later reward round.
85
+ /// @param hook The hook whose expired rewards were recycled.
86
+ /// @param fromRound The expired reward round.
87
+ /// @param toRound The reward round receiving the recycled rewards.
88
+ /// @param token The reward token that was recycled.
89
+ /// @param amount The unclaimed reward amount recycled.
90
+ /// @param caller The address that triggered the recycle.
91
+ event ExpiredRewardsRecycled(
92
+ address indexed hook,
93
+ uint256 indexed fromRound,
94
+ uint256 indexed toRound,
95
+ IERC20 token,
96
+ uint256 amount,
97
+ address caller
98
+ );
99
+
100
+ /// @notice Emitted when unlocked rewards from burned tokens are recycled into the current reward round.
101
+ /// @param hook The hook whose forfeited rewards were recycled.
102
+ /// @param round The reward round receiving the recycled rewards.
103
+ /// @param token The reward token that was recycled.
104
+ /// @param amount The forfeited reward amount recycled.
105
+ /// @param caller The address that triggered the recycle.
106
+ event ForfeitedRewardsRecycled(
91
107
  address indexed hook, uint256 indexed round, IERC20 indexed token, uint256 amount, address caller
92
108
  );
93
109
 
@@ -147,7 +163,7 @@ interface IJBDistributor {
147
163
  /// @dev A zero duration means reward rounds do not expire.
148
164
  function CLAIM_DURATION() external view returns (uint48);
149
165
 
150
- /// @notice The JB controller used to burn expired or forfeited project-token rewards.
166
+ /// @notice The JB controller used for token registry lookups and revnet loan permissions.
151
167
  function CONTROLLER() external view returns (IJBController);
152
168
 
153
169
  /// @notice The duration of each round, specified in seconds.
@@ -279,11 +295,11 @@ interface IJBDistributor {
279
295
  /// @param amount The amount to fund.
280
296
  function fund(address hook, IERC20 token, uint256 amount) external payable;
281
297
 
282
- /// @notice Burn unclaimed rewards from expired reward rounds.
283
- /// @param hook The hook whose expired reward rounds should be burned.
284
- /// @param token The reward token to burn.
285
- /// @param rounds The reward rounds to burn.
286
- /// @return amount The total amount burned.
298
+ /// @notice Recycle unclaimed rewards from expired reward rounds into the current reward round.
299
+ /// @param hook The hook whose expired reward rounds should be recycled.
300
+ /// @param token The reward token to recycle.
301
+ /// @param rounds The reward rounds to recycle.
302
+ /// @return amount The total amount recycled.
287
303
  function burnExpiredRewards(address hook, IERC20 token, uint256[] calldata rounds) external returns (uint256 amount);
288
304
 
289
305
  /// @notice Record the snapshot block for the current round. Callable by anyone (keepers, frontends).
@@ -301,10 +317,10 @@ interface IJBDistributor {
301
317
  payable
302
318
  returns (uint256 paidOffLoanId);
303
319
 
304
- /// @notice Burn unlocked rewards for burned tokens.
320
+ /// @notice Recycle unlocked rewards from burned tokens into the current reward round.
305
321
  /// @param hook The hook whose tokens were burned.
306
322
  /// @param tokenIds The IDs of the burned tokens.
307
- /// @param tokens The addresses of the tokens to burn.
323
+ /// @param tokens The reward tokens to recycle.
308
324
  /// @param beneficiary Unused for forfeiture.
309
325
  function releaseForfeitedRewards(
310
326
  address hook,
@@ -5,7 +5,7 @@ pragma solidity ^0.8.0;
5
5
  /// @custom:member amount The reward amount assigned to the round.
6
6
  /// @custom:member snapshotBlock The block used for per-account historical stake lookups.
7
7
  /// @custom:member claimedAmount The reward amount already materialized into vesting.
8
- /// @custom:member claimDeadline The timestamp at which unclaimed rewards can be burned. Zero means no expiration.
8
+ /// @custom:member claimDeadline The timestamp at which unclaimed rewards can be recycled. Zero means no expiration.
9
9
  /// @custom:member totalStake The aggregate stake at the round's snapshot block.
10
10
  struct JBRewardRoundData {
11
11
  uint208 amount;