@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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
28
|
-
"@bananapus/core-v6": "^0.0.
|
|
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",
|
package/src/JB721Distributor.sol
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
|
298
|
+
// Expired rounds can no longer be claimed as-is; recycle their unclaimed remainder instead.
|
|
299
299
|
if (_rewardRoundExpired(rewardRound)) {
|
|
300
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/JBDistributor.sol
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
353
|
-
/// @
|
|
354
|
-
/// @param
|
|
355
|
-
/// @param
|
|
356
|
-
/// @
|
|
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
|
|
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 +=
|
|
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
|
|
389
|
-
///
|
|
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
|
|
394
|
-
/// @param beneficiary Unused for forfeiture
|
|
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
|
|
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
|
|
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
|
|
477
|
-
|
|
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 =
|
|
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
|
|
1162
|
-
/// @param hook The hook whose expired rewards should be
|
|
1163
|
-
/// @param token The reward token to
|
|
1164
|
-
/// @param round The reward round to
|
|
1165
|
-
/// @return
|
|
1166
|
-
function
|
|
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
|
|
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
|
-
//
|
|
1177
|
-
|
|
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
|
|
1192
|
+
// Mark the whole round settled before writing the recycled amount into a fresh round.
|
|
1180
1193
|
rewardRound.claimedAmount = rewardRound.amount;
|
|
1181
1194
|
|
|
1182
|
-
//
|
|
1183
|
-
|
|
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
|
|
1186
|
-
emit
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
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
|
|
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:
|
|
1377
|
-
|
|
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
|
|
1574
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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;
|
|
331
|
+
// Expired rounds can no longer be claimed as-is; recycle their unclaimed remainder instead.
|
|
332
332
|
if (_rewardRoundExpired(rewardRound)) {
|
|
333
|
-
|
|
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
|
|
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
|
|
85
|
-
/// @param hook The hook whose expired rewards were
|
|
86
|
-
/// @param
|
|
87
|
-
/// @param
|
|
88
|
-
/// @param
|
|
89
|
-
/// @param
|
|
90
|
-
|
|
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
|
|
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
|
|
283
|
-
/// @param hook The hook whose expired reward rounds should be
|
|
284
|
-
/// @param token The reward token to
|
|
285
|
-
/// @param rounds The reward rounds to
|
|
286
|
-
/// @return amount The total amount
|
|
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
|
|
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
|
|
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
|
|
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;
|