@bananapus/distributor-v6 0.0.37 → 0.0.42

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.
@@ -9,7 +9,6 @@ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
9
9
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
10
10
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
11
11
  import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
12
- import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
13
12
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
14
13
  import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
15
14
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
@@ -19,7 +18,6 @@ import {IREVOwner} from "@rev-net/core-v6/src/interfaces/IREVOwner.sol";
19
18
 
20
19
  import {JBDistributor} from "./JBDistributor.sol";
21
20
  import {IJB721Distributor} from "./interfaces/IJB721Distributor.sol";
22
- import {IJBDistributor} from "./interfaces/IJBDistributor.sol";
23
21
  import {JBClaimContext} from "./structs/JBClaimContext.sol";
24
22
  import {JBRewardRoundData} from "./structs/JBRewardRoundData.sol";
25
23
  import {JBVestContext} from "./structs/JBVestContext.sol";
@@ -29,7 +27,7 @@ import {JBVestingData} from "./structs/JBVestingData.sol";
29
27
  /// @dev Any project can use this distributor by configuring a payout split with
30
28
  /// `hook = this contract` and `beneficiary = address(their 721 hook)`.
31
29
  /// @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 recycled via `releaseForfeitedRewards`.
30
+ /// calculation, and their historical rewards can be materialized and recycled via `releaseForfeitedRewards`.
33
31
  /// @dev Funded rewards are assigned to the funding round. NFT owners claim historical rounds lazily; all unclaimed
34
32
  /// past rewards begin vesting when the current NFT owner claims, not when the rewards were funded.
35
33
  /// @dev Implements `IJBSplitHook` so it can receive tokens directly from Juicebox project payout splits.
@@ -78,14 +76,24 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
78
76
  // -------------------- internal stored properties ------------------- //
79
77
  //*********************************************************************//
80
78
 
81
- /// @notice Tracks voting power consumed per hook/token/reward round/owner to prevent cap resets across calls.
79
+ /// @notice Tracks account-tier active voting units consumed by each 721 reward group and round.
80
+ /// @dev The cap is group-scoped so all-tiers rewards and tier-scoped rewards can be claimed independently.
82
81
  /// @custom:param hook The hook address.
82
+ /// @custom:param groupId The reward group.
83
83
  /// @custom:param token The reward token.
84
84
  /// @custom:param rewardRound The reward round.
85
- /// @custom:param owner The NFT owner.
85
+ /// @custom:param owner The snapshot NFT owner.
86
+ /// @custom:param tierId The tier whose active units are consumed.
86
87
  mapping(
87
- address hook => mapping(IERC20 token => mapping(uint256 rewardRound => mapping(address owner => uint256)))
88
- ) internal _consumedVotesOf;
88
+ address hook
89
+ => mapping(
90
+ uint256 groupId
91
+ => mapping(
92
+ IERC20 token
93
+ => mapping(uint256 rewardRound => mapping(address owner => mapping(uint256 tierId => uint256)))
94
+ )
95
+ )
96
+ ) internal _consumedTierVotesOf;
89
97
 
90
98
  /// @notice The tier set that defines a reward group, recorded the first time the group is funded.
91
99
  /// @dev Empty for the default group (0 = all tiers). Read by the stake math to scope the tier-set denominator.
@@ -97,6 +105,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
97
105
  // -------------------------- constructor ---------------------------- //
98
106
  //*********************************************************************//
99
107
 
108
+ /// @notice Initializes the 721 distributor.
100
109
  /// @param directory The JB directory used to verify terminal/controller callers.
101
110
  /// @param controller The JB controller used for token registry lookups and revnet loan permissions.
102
111
  /// @param revLoans The Revnet loans contract used to borrow against vested revnet rewards.
@@ -129,6 +138,48 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
129
138
  // ---------------------- external transactions ---------------------- //
130
139
  //*********************************************************************//
131
140
 
141
+ // The group-0 (all-tiers) `beginVesting` and `collectVestedRewards` are provided by `JBDistributor`. Both
142
+ // distributors share the exact same flow (validate -> materialize past rounds via `_claimPastRewards` ->
143
+ // optionally release unlocked), so the round-claim logic lives once in the base and dispatches to this contract's
144
+ // `_claimPastRewards` / `_validateTokenIds` overrides below. The tier-scoped overloads below derive a canonical
145
+ // group ID from the tier set and call the same base helpers.
146
+
147
+ /// @notice Begin vesting all unclaimed past reward rounds for the specified NFT token IDs in a tier-scoped group.
148
+ /// @param hook The 721 hook whose NFT owners are vesting.
149
+ /// @param tierIds The strictly-increasing tier set defining the group.
150
+ /// @param tokenIds The NFT token IDs to claim rewards for.
151
+ /// @param tokens The reward tokens to begin vesting.
152
+ function beginVesting(
153
+ address hook,
154
+ uint256[] calldata tierIds,
155
+ uint256[] calldata tokenIds,
156
+ IERC20[] calldata tokens
157
+ )
158
+ external
159
+ override
160
+ {
161
+ _beginVesting({hook: hook, groupId: _groupIdFor(tierIds), tokenIds: tokenIds, tokens: tokens});
162
+ }
163
+
164
+ /// @notice Fund a tier-scoped reward group: only holders of the given tiers can claim this pot.
165
+ /// @dev For native ETH, send `msg.value` and pass `IERC20(JBConstants.NATIVE_TOKEN)` as the token. Uses balance
166
+ /// delta to handle fee-on-transfer tokens correctly. The tier set is recorded on the group's first funding.
167
+ /// @param hook The 721 hook to fund (determines which staker pool receives the tokens).
168
+ /// @param tierIds The strictly-increasing tier set defining the group.
169
+ /// @param token The token to fund with.
170
+ /// @param amount The amount to fund (ignored for native ETH — `msg.value` is used instead).
171
+ function fund(address hook, uint256[] calldata tierIds, IERC20 token, uint256 amount) external payable override {
172
+ // Derive the canonical group ID for the tier set.
173
+ uint256 groupId = _groupIdFor(tierIds);
174
+
175
+ // Record the tier set the first time a tier-scoped group is funded, so the stake math can scope it later.
176
+ if (groupId != 0 && _tierIdsOfGroup[hook][groupId].length == 0) {
177
+ _tierIdsOfGroup[hook][groupId] = tierIds;
178
+ }
179
+
180
+ _fund({hook: hook, groupId: groupId, token: token, amount: amount});
181
+ }
182
+
132
183
  /// @notice Receives tokens from a Juicebox payout split.
133
184
  /// @dev Only callable by a terminal or controller for the project in the context.
134
185
  /// @dev The hook address is read from `context.split.beneficiary`.
@@ -175,55 +226,13 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
175
226
  }
176
227
  }
177
228
 
178
- // The group-0 (all-tiers) `beginVesting` and `collectVestedRewards` are provided by `JBDistributor`. Both
179
- // distributors share the exact same flow (authorize -> materialize past rounds via `_claimPastRewards` ->
180
- // optionally release unlocked), so the round-claim logic lives once in the base and dispatches to this contract's
181
- // `_claimPastRewards` / `_requireCanClaimTokenIds` overrides below. The tier-scoped overloads below derive a
182
- // canonical group ID from the tier set and call the same base helpers.
183
-
184
- /// @notice Begin vesting all unclaimed past reward rounds for the specified NFT token IDs in a tier-scoped group.
185
- /// @param hook The 721 hook whose NFT owners are vesting.
186
- /// @param tierIds The strictly-increasing tier set defining the group.
187
- /// @param tokenIds The NFT token IDs to claim rewards for.
188
- /// @param tokens The reward tokens to begin vesting.
189
- function beginVesting(
190
- address hook,
191
- uint256[] calldata tierIds,
192
- uint256[] calldata tokenIds,
193
- IERC20[] calldata tokens
194
- )
195
- external
196
- override
197
- {
198
- _beginVesting({hook: hook, groupId: _groupIdFor(tierIds), tokenIds: tokenIds, tokens: tokens});
199
- }
200
-
201
- /// @notice Fund a tier-scoped reward group: only holders of the given tiers can claim this pot.
202
- /// @dev For native ETH, send `msg.value` and pass `IERC20(JBConstants.NATIVE_TOKEN)` as the token. Uses balance
203
- /// delta to handle fee-on-transfer tokens correctly. The tier set is recorded on the group's first funding.
204
- /// @param hook The 721 hook to fund (determines which staker pool receives the tokens).
205
- /// @param tierIds The strictly-increasing tier set defining the group.
206
- /// @param token The token to fund with.
207
- /// @param amount The amount to fund (ignored for native ETH — `msg.value` is used instead).
208
- function fund(address hook, uint256[] calldata tierIds, IERC20 token, uint256 amount) external payable override {
209
- // Derive the canonical group ID for the tier set.
210
- uint256 groupId = _groupIdFor(tierIds);
211
-
212
- // Record the tier set the first time a tier-scoped group is funded, so the stake math can scope it later.
213
- if (groupId != 0 && _tierIdsOfGroup[hook][groupId].length == 0) {
214
- _tierIdsOfGroup[hook][groupId] = tierIds;
215
- }
216
-
217
- _fund({hook: hook, groupId: groupId, token: token, amount: amount});
218
- }
219
-
220
229
  /// @notice Recycle unclaimed rewards from expired tier-scoped reward rounds into the current reward round.
221
230
  /// @param hook The 721 hook whose expired rewards should be recycled.
222
231
  /// @param tierIds The strictly-increasing tier set defining the group.
223
232
  /// @param token The reward token to recycle.
224
233
  /// @param rounds The reward rounds to recycle.
225
234
  /// @return amount The total amount recycled.
226
- function burnExpiredRewards(
235
+ function recycleExpiredRewards(
227
236
  address hook,
228
237
  uint256[] calldata tierIds,
229
238
  IERC20 token,
@@ -233,11 +242,12 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
233
242
  override
234
243
  returns (uint256 amount)
235
244
  {
236
- amount = _burnExpiredRewards({hook: hook, groupId: _groupIdFor(tierIds), token: token, rounds: rounds});
245
+ amount = _recycleExpiredRewards({hook: hook, groupId: _groupIdFor(tierIds), token: token, rounds: rounds});
237
246
  }
238
247
 
239
- /// @notice Recycle unlocked rewards tied to burned NFTs in a tier-scoped group into the current reward round.
240
- /// @dev Anyone can call this for burned tokens.
248
+ /// @notice Recycle rewards tied to burned NFTs in a tier-scoped group into the current reward round as they unlock.
249
+ /// @dev Anyone can call this for burned tokens. Unclaimed historical shares are materialized before unlocked
250
+ /// forfeited amounts are recycled.
241
251
  /// @param hook The 721 hook whose NFTs were burned.
242
252
  /// @param tierIds The strictly-increasing tier set defining the group.
243
253
  /// @param tokenIds The IDs of the burned NFTs (reverts if any are not actually burned).
@@ -262,8 +272,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
262
272
  // ----------------------- external views ---------------------------- //
263
273
  //*********************************************************************//
264
274
 
265
- /// @notice Calculate the total uncollected (vesting + vested-but-uncollected) amount for an NFT token ID in a
266
- /// tier-scoped group.
275
+ /// @notice Calculate the total uncollected amount for an NFT token ID in a tier-scoped group.
267
276
  /// @param hook The 721 hook the tokenId belongs to.
268
277
  /// @param tierIds The strictly-increasing tier set defining the group.
269
278
  /// @param tokenId The ID of the NFT token to calculate for.
@@ -317,27 +326,6 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
317
326
  // ----------------------- public transactions ----------------------- //
318
327
  //*********************************************************************//
319
328
 
320
- /// @notice Begin vesting then collect everything unlocked for a tier-scoped reward group.
321
- /// @param hook The 721 hook whose NFT owners are collecting.
322
- /// @param tierIds The strictly-increasing tier set defining the group.
323
- /// @param tokenIds The IDs of the NFTs to collect for (caller must be authorized for all of them).
324
- /// @param tokens The reward tokens to collect vested amounts of.
325
- /// @param beneficiary The recipient of the collected tokens.
326
- function collectVestedRewards(
327
- address hook,
328
- uint256[] calldata tierIds,
329
- uint256[] calldata tokenIds,
330
- IERC20[] calldata tokens,
331
- address beneficiary
332
- )
333
- external
334
- override
335
- {
336
- _collectVestedRewards({
337
- hook: hook, groupId: _groupIdFor(tierIds), tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary
338
- });
339
- }
340
-
341
329
  /// @notice Borrow against one NFT token ID's uncollected vesting rewards in a tier-scoped group.
342
330
  /// @param hook The 721 hook whose NFT owner is borrowing against vesting rewards.
343
331
  /// @param tierIds The strictly-increasing tier set defining the group.
@@ -375,6 +363,27 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
375
363
  });
376
364
  }
377
365
 
366
+ /// @notice Begin vesting then collect everything unlocked for a tier-scoped reward group.
367
+ /// @param hook The 721 hook whose NFT owners are collecting.
368
+ /// @param tierIds The strictly-increasing tier set defining the group.
369
+ /// @param tokenIds The IDs of the NFTs to collect for.
370
+ /// @param tokens The reward tokens to collect vested amounts of.
371
+ /// @param beneficiary The recipient of the collected tokens.
372
+ function collectVestedRewards(
373
+ address hook,
374
+ uint256[] calldata tierIds,
375
+ uint256[] calldata tokenIds,
376
+ IERC20[] calldata tokens,
377
+ address beneficiary
378
+ )
379
+ external
380
+ override
381
+ {
382
+ _collectVestedRewards({
383
+ hook: hook, groupId: _groupIdFor(tierIds), tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary
384
+ });
385
+ }
386
+
378
387
  //*********************************************************************//
379
388
  // -------------------------- public views --------------------------- //
380
389
  //*********************************************************************//
@@ -551,41 +560,22 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
551
560
  internal
552
561
  returns (uint256 totalVestingAmount)
553
562
  {
554
- // Tier-scoped groups distribute a tier's pot among that tier's eligible NFTs. Each NFT contributes its tier's
555
- // voting units, with no per-owner cap: the denominator (summed `getPastTierVotingUnits`) counts exactly those
556
- // eligible NFTs, so the shares reconcile without the all-tiers delegation-cap machinery.
557
- if (ctx.groupId != 0) {
558
- for (uint256 j; j < tokenIds.length;) {
559
- if (nextClaimRoundOf[ctx.hook][ctx.groupId][tokenIds[j]][ctx.token] <= ctx.rewardRound) {
560
- uint256 stake = _tierScopedStake({ctx: ctx, tokenId: tokenIds[j]});
561
- if (stake != 0) {
562
- uint256 tokenAmount =
563
- mulDiv({x: ctx.distributable, y: stake, denominator: ctx.totalStakeAmount});
564
- tokenAmounts[j] += tokenAmount;
565
- totalVestingAmount += tokenAmount;
566
- }
567
- }
568
-
569
- unchecked {
570
- ++j;
571
- }
572
- }
573
-
574
- return totalVestingAmount;
575
- }
576
-
577
- // All-tiers group (0): split the pot pro-rata across delegated voting power, capping each owner so multiple
578
- // NFTs cannot over-claim beyond the owner's checkpointed votes.
579
- // Allocate scratch arrays sized to the maximum possible number of distinct snapshot owners.
563
+ // Allocate scratch arrays sized to the maximum possible number of distinct owner-tier caps in this batch.
580
564
  address[] memory owners = new address[](tokenIds.length);
565
+ uint256[] memory tierIds = new uint256[](tokenIds.length);
581
566
  uint256[] memory consumed = new uint256[](tokenIds.length);
582
567
  uint256 uniqueCount;
583
568
 
584
569
  // Claim each token ID that has not yet advanced past this reward round.
585
570
  for (uint256 j; j < tokenIds.length;) {
586
- if (nextClaimRoundOf[ctx.hook][0][tokenIds[j]][ctx.token] <= ctx.rewardRound) {
571
+ if (nextClaimRoundOf[ctx.hook][ctx.groupId][tokenIds[j]][ctx.token] <= ctx.rewardRound) {
587
572
  (uint256 tokenAmount, uint256 newUniqueCount) = _claimRewardRoundForTokenId({
588
- ctx: ctx, tokenId: tokenIds[j], owners: owners, consumed: consumed, uniqueCount: uniqueCount
573
+ ctx: ctx,
574
+ tokenId: tokenIds[j],
575
+ owners: owners,
576
+ tierIds: tierIds,
577
+ consumed: consumed,
578
+ uniqueCount: uniqueCount
589
579
  });
590
580
 
591
581
  uniqueCount = newUniqueCount;
@@ -598,27 +588,29 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
598
588
  }
599
589
  }
600
590
 
601
- // Persist consumed voting power to storage to prevent cap resets across separate claim calls.
591
+ // Persist consumed active tier units to storage to prevent cap resets across separate claim calls.
602
592
  for (uint256 k; k < uniqueCount;) {
603
- _consumedVotesOf[ctx.hook][ctx.token][ctx.rewardRound][owners[k]] = consumed[k];
593
+ _consumedTierVotesOf[ctx.hook][ctx.groupId][ctx.token][ctx.rewardRound][owners[k]][tierIds[k]] = consumed[k];
604
594
  unchecked {
605
595
  ++k;
606
596
  }
607
597
  }
608
598
  }
609
599
 
610
- /// @notice Claim one NFT token ID for one historical reward round, enforcing the snapshot owner's vote cap.
600
+ /// @notice Claim one NFT token ID for one historical reward round, enforcing the snapshot owner's tier cap.
611
601
  /// @param ctx The reward-round context.
612
602
  /// @param tokenId The NFT token ID to claim for.
613
603
  /// @param owners A scratch array mapping slot indices to snapshot owners for deduplication.
614
- /// @param consumed A scratch array tracking consumed voting power by owner slot.
615
- /// @param uniqueCount The number of distinct snapshot owners seen so far in this reward-round batch.
604
+ /// @param tierIds A scratch array mapping slot indices to tier IDs for deduplication.
605
+ /// @param consumed A scratch array tracking consumed active tier voting units by owner-tier slot.
606
+ /// @param uniqueCount The number of distinct snapshot owner-tier pairs seen so far in this reward-round batch.
616
607
  /// @return tokenAmount The reward amount vested for this token ID.
617
- /// @return newUniqueCount The updated count of distinct snapshot owners after processing this token ID.
608
+ /// @return newUniqueCount The updated count of distinct snapshot owner-tier pairs after processing this token ID.
618
609
  function _claimRewardRoundForTokenId(
619
610
  JBVestContext memory ctx,
620
611
  uint256 tokenId,
621
612
  address[] memory owners,
613
+ uint256[] memory tierIds,
622
614
  uint256[] memory consumed,
623
615
  uint256 uniqueCount
624
616
  )
@@ -628,25 +620,36 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
628
620
  {
629
621
  newUniqueCount = uniqueCount;
630
622
 
631
- uint256 votingUnits =
632
- IJB721TiersHook(ctx.hook)
633
- .STORE()
634
- .tierOfTokenId({hook: ctx.hook, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
623
+ // Read the token's tier so all-tiers and tier-scoped groups use the same active tier cap.
624
+ uint256 tierId = IJB721TiersHook(ctx.hook).STORE().tierIdOfToken(tokenId);
625
+
626
+ // Tier-scoped groups only include NFTs whose tier is part of the funded tier set.
627
+ if (ctx.groupId != 0 && !_isTierInSet({tierId: tierId, tierIds: ctx.tierIds})) {
628
+ return (0, newUniqueCount);
629
+ }
635
630
 
636
631
  uint256 ownerIndex;
637
- uint256 pastVotes;
632
+ uint256 activeTierVotes;
633
+ uint256 votingUnits;
638
634
  {
635
+ // Read the token's tier voting units once; this is the per-token maximum claim numerator.
636
+ votingUnits =
637
+ IJB721TiersHook(ctx.hook)
638
+ .STORE()
639
+ .tierOfTokenId({hook: ctx.hook, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
640
+
639
641
  // Use the funding round's snapshot block, not the block at which the NFT owner finally claims.
640
642
  address owner = _snapshotOwnerOf({hook: ctx.hook, tokenId: tokenId, snapshotBlock: ctx.snapshotBlock});
641
643
  if (owner == address(0)) return (0, newUniqueCount);
642
644
 
643
- pastVotes = IVotes(address(IJB721TiersHook(ctx.hook).checkpoints()))
644
- .getPastVotes({account: owner, timepoint: ctx.snapshotBlock});
645
- if (pastVotes == 0) return (0, newUniqueCount);
645
+ // Cap this NFT against the snapshot owner's active units for this exact tier.
646
+ activeTierVotes = IJB721TiersHook(ctx.hook).checkpoints()
647
+ .getPastAccountTierActiveVotes({account: owner, tierId: tierId, blockNumber: ctx.snapshotBlock});
648
+ if (activeTierVotes == 0) return (0, newUniqueCount);
646
649
 
647
650
  bool found;
648
651
  for (uint256 k; k < newUniqueCount;) {
649
- if (owners[k] == owner) {
652
+ if (owners[k] == owner && tierIds[k] == tierId) {
650
653
  ownerIndex = k;
651
654
  found = true;
652
655
  break;
@@ -659,15 +662,17 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
659
662
  if (!found) {
660
663
  ownerIndex = newUniqueCount;
661
664
  owners[newUniqueCount] = owner;
665
+ tierIds[newUniqueCount] = tierId;
662
666
  // Initialize from persistent storage to prevent cap resets across separate claim calls.
663
- consumed[newUniqueCount] = _consumedVotesOf[ctx.hook][ctx.token][ctx.rewardRound][owner];
667
+ consumed[newUniqueCount] =
668
+ _consumedTierVotesOf[ctx.hook][ctx.groupId][ctx.token][ctx.rewardRound][owner][tierId];
664
669
  unchecked {
665
670
  ++newUniqueCount;
666
671
  }
667
672
  }
668
673
  }
669
674
 
670
- uint256 remaining = pastVotes > consumed[ownerIndex] ? pastVotes - consumed[ownerIndex] : 0;
675
+ uint256 remaining = activeTierVotes > consumed[ownerIndex] ? activeTierVotes - consumed[ownerIndex] : 0;
671
676
  uint256 stake = votingUnits < remaining ? votingUnits : remaining;
672
677
  if (stake == 0) return (0, newUniqueCount);
673
678
 
@@ -675,7 +680,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
675
680
  tokenAmount = mulDiv({x: ctx.distributable, y: stake, denominator: ctx.totalStakeAmount});
676
681
  if (tokenAmount == 0) return (0, newUniqueCount);
677
682
 
678
- // Only non-zero reward claims consume the snapshot owner's voting budget.
683
+ // Only non-zero reward claims consume the snapshot owner's active tier budget.
679
684
  consumed[ownerIndex] += stake;
680
685
  }
681
686
 
@@ -689,7 +694,16 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
689
694
  /// @param account The account to check ownership for.
690
695
  /// @return canClaim True if the account owns the token.
691
696
  function _canClaim(address hook, uint256 tokenId, address account) internal view override returns (bool canClaim) {
692
- canClaim = IERC721(hook).ownerOf(tokenId) == account;
697
+ canClaim = _claimBeneficiaryOf({hook: hook, tokenId: tokenId}) == account;
698
+ }
699
+
700
+ /// @notice The current NFT owner that receives permissionless collections.
701
+ /// @param hook The 721 hook the NFT belongs to.
702
+ /// @param tokenId The NFT token ID to get the owner of.
703
+ /// @return beneficiary The current NFT owner.
704
+ function _claimBeneficiaryOf(address hook, uint256 tokenId) internal view override returns (address beneficiary) {
705
+ // The hook's `ownerOf` reverts for burned or nonexistent NFTs, so only live NFTs can collect.
706
+ beneficiary = IERC721(hook).ownerOf(tokenId);
693
707
  }
694
708
 
695
709
  /// @notice Derive the canonical group ID for a tier set. The empty set is the all-tiers group (0).
@@ -723,8 +737,8 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
723
737
  }
724
738
  }
725
739
 
726
- /// @notice Revert unless the caller is authorized to claim each NFT token ID.
727
- /// @param hook The 721 hook whose NFT owners are claiming.
740
+ /// @notice Revert unless the caller is authorized to redirect or borrow against each NFT token ID.
741
+ /// @param hook The 721 hook whose NFT owners are being checked.
728
742
  /// @param tokenIds The NFT token IDs to check.
729
743
  function _requireCanClaimTokenIds(address hook, uint256[] calldata tokenIds) internal view override {
730
744
  // Each requested NFT must currently belong to msg.sender and appear in strictly increasing order.
@@ -745,30 +759,6 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
745
759
  }
746
760
  }
747
761
 
748
- /// @notice The tier-scoped stake of a single NFT in a reward round: its tier's voting units if the NFT's tier is
749
- /// in the group's set and the NFT existed at the round snapshot, else zero.
750
- /// @dev No per-owner cap is applied. Eligibility (`ownerOfAt != 0`) plus tier membership matches exactly the set
751
- /// counted by the `getPastTierVotingUnits` denominator, so per-NFT shares reconcile against the pot.
752
- /// @param ctx The reward-round context (carries the group's tier set and snapshot block).
753
- /// @param tokenId The NFT token ID to weigh.
754
- /// @return stake The NFT's tier voting units, or 0 if ineligible.
755
- function _tierScopedStake(JBVestContext memory ctx, uint256 tokenId) internal view returns (uint256 stake) {
756
- // The NFT's tier must be one of the funded tiers.
757
- uint256 tierId = IJB721TiersHook(ctx.hook).STORE().tierIdOfToken(tokenId);
758
- if (!_isTierInSet({tierId: tierId, tierIds: ctx.tierIds})) return 0;
759
-
760
- // The NFT must have existed at the round snapshot block (proven via the checkpoint owner history).
761
- if (_snapshotOwnerOf({hook: ctx.hook, tokenId: tokenId, snapshotBlock: ctx.snapshotBlock}) == address(0)) {
762
- return 0;
763
- }
764
-
765
- // Eligible: weigh the NFT by its tier's voting units.
766
- stake =
767
- IJB721TiersHook(ctx.hook)
768
- .STORE()
769
- .tierOfTokenId({hook: ctx.hook, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
770
- }
771
-
772
762
  /// @notice Checks if the given token was burned.
773
763
  /// @param hook The hook the token belongs to.
774
764
  /// @param tokenId The tokenId to check.
@@ -781,6 +771,28 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
781
771
  }
782
772
  }
783
773
 
774
+ /// @notice Revert unless burned NFT token IDs are strictly increasing.
775
+ /// @dev Forfeiture materializes unclaimed historical shares before recycling vested amounts, so burned token IDs
776
+ /// need the same no-duplicate ordering guarantee as live claim batches.
777
+ /// @param hook Unused for ordering validation.
778
+ /// @param tokenIds The burned NFT token IDs to validate.
779
+ function _validateForfeitedTokenIds(address hook, uint256[] calldata tokenIds) internal pure override {
780
+ hook;
781
+
782
+ // Permissionless forfeiture callers must submit each burned NFT once in canonical order.
783
+ for (uint256 i; i < tokenIds.length;) {
784
+ uint256 tokenId = tokenIds[i];
785
+
786
+ if (i != 0 && tokenId <= tokenIds[i - 1]) {
787
+ revert JB721Distributor_TokenIdsNotIncreasing({previousTokenId: tokenIds[i - 1], tokenId: tokenId});
788
+ }
789
+
790
+ unchecked {
791
+ ++i;
792
+ }
793
+ }
794
+ }
795
+
784
796
  /// @notice The stake weight of a given NFT token ID based on its tier's voting units, validated against historical
785
797
  /// state.
786
798
  /// @dev Returns 0 if the token was not owned at the round's snapshot block or if its snapshot owner had no
@@ -789,6 +801,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
789
801
  /// @param tokenId The ID of the token to get the stake weight of.
790
802
  /// @return tokenStakeAmount The voting units of the token's tier (or 0 if ineligible).
791
803
  function _tokenStake(address hook, uint256 tokenId) internal view override returns (uint256 tokenStakeAmount) {
804
+ uint256 tierId = IJB721TiersHook(hook).STORE().tierIdOfToken(tokenId);
792
805
  uint256 votingUnits =
793
806
  IJB721TiersHook(hook)
794
807
  .STORE()
@@ -799,23 +812,21 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
799
812
  address owner = _snapshotOwnerOf({hook: hook, tokenId: tokenId, snapshotBlock: snapshotBlock});
800
813
  if (owner == address(0)) return 0;
801
814
 
802
- // Use the checkpoints module to verify the token's snapshot owner had voting power at the round's snapshot
803
- // block. If the token did not exist then, ownerOfAt returns zero above and the token is not eligible.
804
- uint256 pastVotes = IVotes(address(IJB721TiersHook(hook).checkpoints()))
805
- .getPastVotes({account: owner, timepoint: snapshotBlock});
815
+ // Read the owner's active units for this token's tier at the snapshot block.
816
+ uint256 activeTierVotes = IJB721TiersHook(hook).checkpoints()
817
+ .getPastAccountTierActiveVotes({account: owner, tierId: tierId, blockNumber: snapshotBlock});
806
818
 
807
- // If the owner had no voting power at the snapshot block, the token is ineligible.
808
- if (pastVotes == 0) return 0;
819
+ // If the owner had no active units in this tier at the snapshot block, the token is ineligible.
820
+ if (activeTierVotes == 0) return 0;
809
821
 
810
- // Cap at the token's tier voting units the owner's past votes may cover multiple tokens,
811
- // but each individual token's stake is at most its tier's voting units.
812
- tokenStakeAmount = votingUnits < pastVotes ? votingUnits : pastVotes;
822
+ // Cap at the token's tier voting units; owner-level consumption is applied when a round is claimed.
823
+ tokenStakeAmount = votingUnits < activeTierVotes ? votingUnits : activeTierVotes;
813
824
  }
814
825
 
815
- /// @notice The total stake sharing a group's round rewards at a specific block.
816
- /// @dev For the all-tiers group (0) this is `getPastTotalSupply` from the hook's checkpoints module (all NFTs that
817
- /// existed and were delegated at `blockNumber`). For a tier-scoped group it is the summed
818
- /// `getPastTierVotingUnits` over the group's tier set — the eligible voting units of those tiers at the snapshot.
826
+ /// @notice The total active stake sharing a group's round rewards at a specific block.
827
+ /// @dev For the all-tiers group (0) this is `getPastTotalActiveVotes` from the hook's checkpoints module. For a
828
+ /// tier-scoped group it is the summed `getPastTotalTierActiveVotes` over the group's tier set. `CLAIM_DURATION`
829
+ /// only controls expiry.
819
830
  /// @param hook The hook to get the total stake for.
820
831
  /// @param groupId The reward group (0 = all tiers).
821
832
  /// @param blockNumber The block number to get the total staked amount at.
@@ -832,15 +843,36 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
832
843
  {
833
844
  IJB721Checkpoints checkpoints = IJB721TiersHook(hook).checkpoints();
834
845
 
835
- // All-tiers group (0): the global checkpointed voting supply.
846
+ // All-tiers group (0): the global checkpointed active voting supply.
836
847
  if (groupId == 0) {
837
- return IVotes(address(checkpoints)).getPastTotalSupply(blockNumber);
848
+ return checkpoints.getPastTotalActiveVotes(blockNumber);
838
849
  }
839
850
 
840
- // Tier-scoped group: sum the eligible voting units of each tier in the set at the snapshot block.
851
+ // Tier-scoped group: sum each funded tier's active voting units at the snapshot block.
841
852
  uint256[] memory tierIds = _tierIdsOfGroup[hook][groupId];
842
853
  for (uint256 i; i < tierIds.length;) {
843
- total += checkpoints.getPastTierVotingUnits({tierId: tierIds[i], blockNumber: blockNumber});
854
+ total += checkpoints.getPastTotalTierActiveVotes({tierId: tierIds[i], blockNumber: blockNumber});
855
+ unchecked {
856
+ ++i;
857
+ }
858
+ }
859
+ }
860
+
861
+ /// @notice Revert unless every NFT token ID is live and strictly increasing.
862
+ /// @param hook The 721 hook whose NFT token IDs are being validated.
863
+ /// @param tokenIds The NFT token IDs to validate.
864
+ function _validateTokenIds(address hook, uint256[] calldata tokenIds) internal view override {
865
+ // Permissionless helpers can start vesting only for live NFTs in a canonical order.
866
+ for (uint256 i; i < tokenIds.length;) {
867
+ uint256 tokenId = tokenIds[i];
868
+
869
+ if (i != 0 && tokenId <= tokenIds[i - 1]) {
870
+ revert JB721Distributor_TokenIdsNotIncreasing({previousTokenId: tokenIds[i - 1], tokenId: tokenId});
871
+ }
872
+
873
+ // `ownerOf` reverts for burned or nonexistent NFTs, keeping forfeiture-only IDs out of vesting claims.
874
+ _claimBeneficiaryOf({hook: hook, tokenId: tokenId});
875
+
844
876
  unchecked {
845
877
  ++i;
846
878
  }