@bananapus/distributor-v6 0.0.44 → 0.0.46

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
@@ -75,7 +75,9 @@ This repo does not explain why an allocation exists. It only defines how funded
75
75
  NFT's tier
76
76
  - `recycleExpiredRewards` is permissionless; it recycles the expired round's unmaterialized remainder while preserving
77
77
  amounts that already started vesting
78
- - eligible expired and forfeited rewards stay in distributor inventory and are recycled into the current reward round
78
+ - eligible expired and forfeited rewards stay in distributor inventory and are recycled into the current reward round.
79
+ A reward round never recycles into itself; if the requested round is still current, the call is a no-op, including
80
+ for zero-stake rounds
79
81
  - revnet loan-backed vesting is opt-in at deployment; the reward token must be a REVOwner-owned revnet token, the
80
82
  distributor keeps the loan NFT, and repayment restores the original vesting schedule instead of releasing all
81
83
  collateral immediately
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/distributor-v6",
3
- "version": "0.0.44",
3
+ "version": "0.0.46",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -305,7 +305,8 @@ abstract contract JBDistributor is IJBDistributor {
305
305
  }
306
306
 
307
307
  /// @notice Recycle unclaimed rewards from expired reward rounds into the current reward round.
308
- /// @dev Recycling is permissionless; any keeper or frontend can sweep an expired round.
308
+ /// @dev Recycling is permissionless; any keeper or frontend can sweep an eligible prior round. Passing the current
309
+ /// reward round is a no-op, even when its snapshot stake is zero.
309
310
  /// @param hook The hook whose expired rewards should be recycled.
310
311
  /// @param token The reward token to recycle.
311
312
  /// @param rounds The reward rounds to recycle.
@@ -1185,11 +1186,19 @@ abstract contract JBDistributor is IJBDistributor {
1185
1186
  internal
1186
1187
  returns (uint256 recycleAmount)
1187
1188
  {
1189
+ // Never recycle a round into itself. This keeps raw round accounting stable for zero-stake current rounds; the
1190
+ // same inventory can be swept once a later round is current.
1191
+ uint256 recycledToRound = currentRound();
1192
+ if (round == recycledToRound) return 0;
1193
+
1188
1194
  // Load the reward round once so expiry, claimed amount, and funded amount stay in sync.
1189
1195
  JBRewardRoundData storage rewardRound = rewardRoundOf[hook][groupId][token][round];
1190
1196
 
1191
- // Ignore rounds that either never expire or have not reached their deadline yet.
1192
- if (!_rewardRoundExpired(rewardRound)) return 0;
1197
+ // Ignore rounds that have not reached their deadline yet — UNLESS the round can never be claimed because its
1198
+ // snapshot `totalStake` is zero (e.g. funded before anyone delegated). Such a round's funds are unclaimable;
1199
+ // gating recycle on expiry would strand them forever when `CLAIM_DURATION == 0` (a zero deadline never
1200
+ // expires), so allow recycling a zero-stake round regardless of deadline. There is no claimant to protect.
1201
+ if (!_rewardRoundExpired(rewardRound) && rewardRound.totalStake != 0) return 0;
1193
1202
 
1194
1203
  // If prior claims have already materialized the whole round, there is nothing left to recycle.
1195
1204
  if (rewardRound.claimedAmount >= rewardRound.amount) return 0;
@@ -1201,7 +1210,6 @@ abstract contract JBDistributor is IJBDistributor {
1201
1210
  rewardRound.claimedAmount = rewardRound.amount;
1202
1211
 
1203
1212
  // Keep the inventory in the distributor and give the current staker set a new claimable round.
1204
- uint256 recycledToRound = currentRound();
1205
1213
  _recordRewardRound({hook: hook, groupId: groupId, token: token, amount: recycleAmount});
1206
1214
 
1207
1215
  // Surface the permissionless recycle for off-chain accounting.
@@ -21,10 +21,14 @@ import {JBClaimContext} from "./structs/JBClaimContext.sol";
21
21
  import {JBRewardRoundData} from "./structs/JBRewardRoundData.sol";
22
22
  import {JBVestingData} from "./structs/JBVestingData.sol";
23
23
 
24
- /// @notice A singleton distributor that distributes ERC-20 rewards to IVotes-compatible token holders with delegated
25
- /// voting power and linear vesting.
24
+ /// @notice A singleton distributor that distributes ERC-20 rewards to holders of an `IJBActiveVotes` token with
25
+ /// delegated voting power and linear vesting.
26
26
  /// @dev Any project can use this distributor by configuring a payout split with
27
- /// `hook = this contract` and `beneficiary = address(their IVotes token)`.
27
+ /// `hook = this contract` and `beneficiary = address(their token)`. The token MUST implement `IJBActiveVotes` (e.g. a
28
+ /// Juicebox `JBERC20`); a plain OpenZeppelin `IVotes`/`ERC20Votes` token lacks `getPastTotalActiveVotes` and will
29
+ /// revert inside `_recordRewardRound` at fund time. Because funding arrives via a payout split, that revert is
30
+ /// swallowed by the terminal's split try/catch — the funds soft-land back in the project and nothing distributes,
31
+ /// silently. Wire only an `IJBActiveVotes` token.
28
32
  /// @dev The stake weight of each holder is their delegated voting power at the funded round's snapshot block.
29
33
  /// Holders must delegate (even to themselves) to participate.
30
34
  /// @dev Funded rewards are assigned to the funding round. Holders claim historical rounds lazily; all unclaimed past
@@ -414,7 +418,7 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
414
418
  /// @notice The delegated voting power of a staker at the current round's snapshot block.
415
419
  /// @dev Uses `IVotes.getPastVotes` for checkpointed lookups. The block number is derived from
416
420
  /// `roundSnapshotBlock[currentRound()]`, which is set on first interaction in a round.
417
- /// @param hook The IVotes-compatible token contract.
421
+ /// @param hook The `IJBActiveVotes` token contract.
418
422
  /// @param tokenId The encoded staker address (`uint256(uint160(stakerAddress))`).
419
423
  /// @return tokenStakeAmount The delegated voting power at the round's snapshot block.
420
424
  function _tokenStake(address hook, uint256 tokenId) internal view override returns (uint256 tokenStakeAmount) {
@@ -423,7 +427,7 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
423
427
  }
424
428
 
425
429
  /// @notice The delegated voting power of a staker at an explicit snapshot block.
426
- /// @param hook The IVotes-compatible token contract.
430
+ /// @param hook The `IJBActiveVotes` token contract.
427
431
  /// @param tokenId The encoded staker address.
428
432
  /// @param blockNumber The historical block to query.
429
433
  /// @return tokenStakeAmount The delegated voting power at `blockNumber`.
@@ -450,7 +454,7 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
450
454
  /// @notice The total stake denominator recorded when a token reward round is first funded.
451
455
  /// @dev Always uses `IJBActiveVotes.getPastTotalActiveVotes`, so undelegated balances such as AMM-held tokens do
452
456
  /// not share rewards. `CLAIM_DURATION` only controls whether unmaterialized allocations can expire.
453
- /// @param hook The IVotes-compatible token contract.
457
+ /// @param hook The `IJBActiveVotes` token contract.
454
458
  /// @param groupId The reward group (unused for token distributors — kept for base-hook conformance).
455
459
  /// @param blockNumber The block number to get the active total at.
456
460
  /// @return totalStakedAmount The stake denominator to record for the funded round.
@@ -301,7 +301,8 @@ interface IJBDistributor {
301
301
  /// @notice Record the snapshot block for the current round. Callable by anyone (keepers, frontends).
302
302
  function poke() external;
303
303
 
304
- /// @notice Recycle unclaimed rewards from expired default-group reward rounds into the current reward round.
304
+ /// @notice Recycle unclaimed rewards from eligible prior default-group reward rounds into the current reward round.
305
+ /// @dev Passing the current round is a no-op, including for zero-stake rounds.
305
306
  /// @param hook The hook whose expired reward rounds should be recycled.
306
307
  /// @param token The reward token to recycle.
307
308
  /// @param rounds The reward rounds to recycle.