@bananapus/distributor-v6 0.0.34 → 0.0.36

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
@@ -77,6 +77,13 @@ This repo does not explain why an allocation exists. It only defines how funded
77
77
  collectible instead of locked in a vesting position
78
78
  - `releaseForfeitedRewards` matters for 721 distributions; token-vote distributions do not have the same burned-token
79
79
  forfeiture path
80
+ - reward, vesting, and loan accounting carries a `groupId`: `0` is the all-tiers group (the default pool), a non-zero
81
+ group is `keccak256(abi.encode(tierIds))`. The tier overloads live on `JB721Distributor`; the base is tier-agnostic.
82
+ Split funding via `processSplitWith` always lands in group 0 — a split cannot carry a tier set; tier-scoped pots
83
+ require the explicit `fund(hook, tierIds, token, amount)` overload, and claims/collections must pass the same
84
+ `tierIds` to hit that group
85
+ - tier-scoped 721 pots weigh each eligible NFT by its tier's `votingUnits` against a summed
86
+ `getPastTierVotingUnits` denominator, which requires `@bananapus/721-hook-v6 >= 0.0.63` for that checkpoints API
80
87
  - snapshot timing is part of the trusted surface
81
88
  - this repo settles distributions, but it does not prove the upstream entitlement math was correct
82
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/distributor-v6",
3
- "version": "0.0.34",
3
+ "version": "0.0.36",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,7 +24,7 @@
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.59",
27
+ "@bananapus/721-hook-v6": "^0.0.63",
28
28
  "@bananapus/core-v6": "^0.0.72",
29
29
  "@bananapus/permission-ids-v6": "^0.0.27",
30
30
  "@openzeppelin/contracts": "5.6.1",
@@ -7,7 +7,8 @@
7
7
  | `JBDistributor` vesting math | Claim totals, `totalVestingAmountOf`, and pool balances still reconcile across rounds |
8
8
  | `JBTokenDistributor` checkpoint logic | `getPastVotes` and `getPastTotalSupply` are read at the intended round-start block |
9
9
  | `JB721Distributor` stake math | Minted, remaining, and burned supply still produce the intended tier-weighted total stake |
10
- | `processSplitWith` | Allowance-based `transferFrom` flow preserves actual received balances |
10
+ | `processSplitWith` | Allowance-based `transferFrom` flow preserves actual received balances; split funding still records under `groupId == 0` only (a split cannot carry a tier set) |
11
+ | `groupId` threading | The `groupId` dimension stays consistent across the reward (`rewardRoundOf`), vesting (`vestingDataOf`, `latestVestedIndexOf`, `nextClaimRoundOf`), and loan (`activeVestingLoanIdOf`, `JBVestingLoan`) maps; the `tierIds` overloads (`fund`/`beginVesting`/`collectVestedRewards`/`borrowAgainstVesting`/`burnExpiredRewards`/`releaseForfeitedRewards`) live on `JB721Distributor` and derive the group ID via `_groupIdFor`; the base is tier-agnostic and group 0 is the default all-tiers pool |
11
12
  | Deployment inputs | `DIRECTORY_ADDRESS`, `ROUND_DURATION`, and `VESTING_ROUNDS` match the intended chain and operator plan |
12
13
 
13
14
  ## Common failure modes
@@ -41,6 +41,10 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
41
41
  /// @notice Thrown when native ETH does not match the split hook context amount.
42
42
  error JB721Distributor_NativeAmountMismatch(uint256 msgValue, uint256 contextAmount);
43
43
 
44
+ /// @notice Thrown when a tier-scoped call's tier IDs are not strictly increasing (so a canonical group ID
45
+ /// cannot be derived).
46
+ error JB721Distributor_TierIdsNotIncreasing(uint256 previousTierId, uint256 tierId);
47
+
44
48
  /// @notice Thrown when claim batch NFT token IDs are not strictly increasing.
45
49
  error JB721Distributor_TokenIdsNotIncreasing(uint256 previousTokenId, uint256 tokenId);
46
50
 
@@ -63,9 +67,12 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
63
67
 
64
68
  /// @notice The next reward round an NFT token ID has not yet claimed.
65
69
  /// @custom:param hook The 721 hook whose NFTs are claiming.
70
+ /// @custom:param groupId The reward group (0 = all tiers).
66
71
  /// @custom:param tokenId The NFT token ID.
67
72
  /// @custom:param token The reward token being claimed.
68
- mapping(address hook => mapping(uint256 tokenId => mapping(IERC20 token => uint256))) public nextClaimRoundOf;
73
+ mapping(
74
+ address hook => mapping(uint256 groupId => mapping(uint256 tokenId => mapping(IERC20 token => uint256)))
75
+ ) public nextClaimRoundOf;
69
76
 
70
77
  //*********************************************************************//
71
78
  // -------------------- internal stored properties ------------------- //
@@ -80,6 +87,12 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
80
87
  address hook => mapping(IERC20 token => mapping(uint256 rewardRound => mapping(address owner => uint256)))
81
88
  ) internal _consumedVotesOf;
82
89
 
90
+ /// @notice The tier set that defines a reward group, recorded the first time the group is funded.
91
+ /// @dev Empty for the default group (0 = all tiers). Read by the stake math to scope the tier-set denominator.
92
+ /// @custom:param hook The hook the group belongs to.
93
+ /// @custom:param groupId The reward group.
94
+ mapping(address hook => mapping(uint256 groupId => uint256[])) internal _tierIdsOfGroup;
95
+
83
96
  //*********************************************************************//
84
97
  // -------------------------- constructor ---------------------------- //
85
98
  //*********************************************************************//
@@ -139,8 +152,8 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
139
152
  }
140
153
 
141
154
  if (msg.value != 0) {
142
- // Assign native split proceeds to the current reward round for this 721 hook.
143
- _recordRewardFunding({hook: hook, token: IERC20(context.token), amount: msg.value});
155
+ // Split-funded pots go to the all-tiers group (0); a split cannot carry a tier set.
156
+ _recordRewardFunding({hook: hook, groupId: 0, token: IERC20(context.token), amount: msg.value});
144
157
  }
145
158
  } else {
146
159
  // Validate that native ETH is not cross-booked under an ERC-20 token.
@@ -157,15 +170,210 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
157
170
  uint256 delta =
158
171
  _acceptErc20FundsFrom({token: IERC20(context.token), from: msg.sender, amount: context.amount});
159
172
 
160
- // Assign only the amount actually received to this round's reward pot.
161
- _recordRewardFunding({hook: hook, token: IERC20(context.token), amount: delta});
173
+ // Assign only the amount actually received to this round's reward pot (all-tiers group, 0).
174
+ _recordRewardFunding({hook: hook, groupId: 0, token: IERC20(context.token), amount: delta});
162
175
  }
163
176
  }
164
177
 
165
- // `beginVesting` and `collectVestedRewards` are provided by `JBDistributor`. Both distributors share the exact
166
- // same flow (authorize -> materialize past rounds via `_claimPastRewards` -> optionally release unlocked), so the
167
- // round-claim logic lives once in the base and dispatches to this contract's `_claimPastRewards` /
168
- // `_requireCanClaimTokenIds` overrides below.
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
+ /// @notice Recycle unclaimed rewards from expired tier-scoped reward rounds into the current reward round.
221
+ /// @param hook The 721 hook whose expired rewards should be recycled.
222
+ /// @param tierIds The strictly-increasing tier set defining the group.
223
+ /// @param token The reward token to recycle.
224
+ /// @param rounds The reward rounds to recycle.
225
+ /// @return amount The total amount recycled.
226
+ function burnExpiredRewards(
227
+ address hook,
228
+ uint256[] calldata tierIds,
229
+ IERC20 token,
230
+ uint256[] calldata rounds
231
+ )
232
+ external
233
+ override
234
+ returns (uint256 amount)
235
+ {
236
+ amount = _burnExpiredRewards({hook: hook, groupId: _groupIdFor(tierIds), token: token, rounds: rounds});
237
+ }
238
+
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.
241
+ /// @param hook The 721 hook whose NFTs were burned.
242
+ /// @param tierIds The strictly-increasing tier set defining the group.
243
+ /// @param tokenIds The IDs of the burned NFTs (reverts if any are not actually burned).
244
+ /// @param tokens The reward tokens to recycle.
245
+ /// @param beneficiary Unused for forfeiture. Kept for interface compatibility.
246
+ function releaseForfeitedRewards(
247
+ address hook,
248
+ uint256[] calldata tierIds,
249
+ uint256[] calldata tokenIds,
250
+ IERC20[] calldata tokens,
251
+ address beneficiary
252
+ )
253
+ external
254
+ override
255
+ {
256
+ _releaseForfeitedRewards({
257
+ hook: hook, groupId: _groupIdFor(tierIds), tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary
258
+ });
259
+ }
260
+
261
+ //*********************************************************************//
262
+ // ----------------------- external views ---------------------------- //
263
+ //*********************************************************************//
264
+
265
+ /// @notice Calculate the total uncollected (vesting + vested-but-uncollected) amount for an NFT token ID in a
266
+ /// tier-scoped group.
267
+ /// @param hook The 721 hook the tokenId belongs to.
268
+ /// @param tierIds The strictly-increasing tier set defining the group.
269
+ /// @param tokenId The ID of the NFT token to calculate for.
270
+ /// @param token The reward token to check.
271
+ /// @return tokenAmount The total uncollected amount (vesting + vested-but-uncollected).
272
+ function claimedFor(
273
+ address hook,
274
+ uint256[] calldata tierIds,
275
+ uint256 tokenId,
276
+ IERC20 token
277
+ )
278
+ external
279
+ view
280
+ override
281
+ returns (uint256 tokenAmount)
282
+ {
283
+ tokenAmount =
284
+ _unclaimedVestingAmountOf({hook: hook, groupId: _groupIdFor(tierIds), tokenId: tokenId, token: token});
285
+ }
286
+
287
+ /// @notice Calculate how much of a reward token is currently unlocked and ready to be collected for a given NFT
288
+ /// token ID in a tier-scoped group.
289
+ /// @param hook The 721 hook the tokenId belongs to.
290
+ /// @param tierIds The strictly-increasing tier set defining the group.
291
+ /// @param tokenId The ID of the NFT token to calculate for.
292
+ /// @param token The reward token to check.
293
+ /// @return tokenAmount The amount of tokens that can be collected right now via `collectVestedRewards`.
294
+ function collectableFor(
295
+ address hook,
296
+ uint256[] calldata tierIds,
297
+ uint256 tokenId,
298
+ IERC20 token
299
+ )
300
+ external
301
+ view
302
+ override
303
+ returns (uint256 tokenAmount)
304
+ {
305
+ tokenAmount = _collectableFor({hook: hook, groupId: _groupIdFor(tierIds), tokenId: tokenId, token: token});
306
+ }
307
+
308
+ /// @notice The tier set that defines a reward group, recorded when the group is first funded.
309
+ /// @param hook The 721 hook the group belongs to.
310
+ /// @param groupId The reward group.
311
+ /// @return tierIds The strictly-increasing tier set defining the group (empty for the all-tiers group, 0).
312
+ function tierIdsOf(address hook, uint256 groupId) external view override returns (uint256[] memory tierIds) {
313
+ tierIds = _tierIdsOfGroup[hook][groupId];
314
+ }
315
+
316
+ //*********************************************************************//
317
+ // ----------------------- public transactions ----------------------- //
318
+ //*********************************************************************//
319
+
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
+ /// @notice Borrow against one NFT token ID's uncollected vesting rewards in a tier-scoped group.
342
+ /// @param hook The 721 hook whose NFT owner is borrowing against vesting rewards.
343
+ /// @param tierIds The strictly-increasing tier set defining the group.
344
+ /// @param tokenIds The single NFT token ID to borrow against.
345
+ /// @param tokens The single revnet reward token to collateralize.
346
+ /// @param sourceToken The token to borrow from the revnet.
347
+ /// @param minBorrowAmount The minimum amount to borrow, denominated in `sourceToken`.
348
+ /// @param prepaidFeePercent The fee percent to charge upfront.
349
+ /// @param beneficiary The recipient of the borrowed funds.
350
+ /// @return loanId The Revnet loan NFT ID held by this distributor.
351
+ /// @return collateralCount The amount of vesting rewards used as collateral.
352
+ function borrowAgainstVesting(
353
+ address hook,
354
+ uint256[] calldata tierIds,
355
+ uint256[] calldata tokenIds,
356
+ IERC20[] calldata tokens,
357
+ address sourceToken,
358
+ uint256 minBorrowAmount,
359
+ uint256 prepaidFeePercent,
360
+ address payable beneficiary
361
+ )
362
+ external
363
+ override
364
+ returns (uint256 loanId, uint256 collateralCount)
365
+ {
366
+ (loanId, collateralCount) = _borrowAgainstVestingFor({
367
+ hook: hook,
368
+ groupId: _groupIdFor(tierIds),
369
+ tokenIds: tokenIds,
370
+ tokens: tokens,
371
+ sourceToken: sourceToken,
372
+ minBorrowAmount: minBorrowAmount,
373
+ prepaidFeePercent: prepaidFeePercent,
374
+ beneficiary: beneficiary
375
+ });
376
+ }
169
377
 
170
378
  //*********************************************************************//
171
379
  // -------------------------- public views --------------------------- //
@@ -185,16 +393,31 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
185
393
 
186
394
  /// @notice Claim all past reward rounds for the given NFT token IDs and reward tokens into fresh vesting entries.
187
395
  /// @param hook The 721 hook whose NFT owners are claiming.
396
+ /// @param groupId The reward group being claimed (0 = all tiers).
188
397
  /// @param tokenIds The NFT token IDs to claim for.
189
398
  /// @param tokens The reward tokens to claim.
190
- function _claimPastRewards(address hook, uint256[] calldata tokenIds, IERC20[] calldata tokens) internal override {
399
+ function _claimPastRewards(
400
+ address hook,
401
+ uint256 groupId,
402
+ uint256[] calldata tokenIds,
403
+ IERC20[] calldata tokens
404
+ )
405
+ internal
406
+ override
407
+ {
191
408
  // Round 0 has no completed reward rounds behind it, so nothing can be claimed yet.
192
409
  uint256 round = currentRound();
193
410
  if (round == 0) return;
194
411
 
195
- // Current-round funding is excluded. It becomes claimable only after a later round starts.
196
- JBClaimContext memory ctx =
197
- JBClaimContext({hook: hook, lastClaimableRound: round - 1, vestingReleaseRound: round + VESTING_ROUNDS});
412
+ // Current-round funding is excluded. It becomes claimable only after a later round starts. For a tier-scoped
413
+ // group, load the tier set once so per-token eligibility can be checked without repeated storage reads.
414
+ JBClaimContext memory ctx = JBClaimContext({
415
+ hook: hook,
416
+ groupId: groupId,
417
+ tierIds: groupId == 0 ? new uint256[](0) : _tierIdsOfGroup[hook][groupId],
418
+ lastClaimableRound: round - 1,
419
+ vestingReleaseRound: round + VESTING_ROUNDS
420
+ });
198
421
 
199
422
  // Process each reward token independently because each token has its own round funding and claim cursor.
200
423
  for (uint256 i; i < tokens.length;) {
@@ -228,7 +451,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
228
451
 
229
452
  // Find the earliest cursor in the batch, skipping token IDs that are already current.
230
453
  for (uint256 i; i < tokenIds.length;) {
231
- uint256 nextClaimRound = nextClaimRoundOf[ctx.hook][tokenIds[i]][token];
454
+ uint256 nextClaimRound = nextClaimRoundOf[ctx.hook][ctx.groupId][tokenIds[i]][token];
232
455
  if (nextClaimRound <= ctx.lastClaimableRound && nextClaimRound < firstClaimRound) {
233
456
  firstClaimRound = nextClaimRound;
234
457
  }
@@ -244,17 +467,21 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
244
467
  // Walk every unclaimed historical round needed by at least one token ID.
245
468
  for (uint256 rewardRoundNumber = firstClaimRound; rewardRoundNumber <= ctx.lastClaimableRound;) {
246
469
  // Load this reward round's funding, snapshot, claim counter, and deadline.
247
- JBRewardRoundData storage rewardRound = rewardRoundOf[ctx.hook][token][rewardRoundNumber];
470
+ JBRewardRoundData storage rewardRound = rewardRoundOf[ctx.hook][ctx.groupId][token][rewardRoundNumber];
248
471
 
249
472
  // Skip rounds that never received funding.
250
473
  if (rewardRound.amount != 0) {
251
474
  // Expired rounds can no longer be claimed as-is; recycle their unclaimed remainder instead.
252
475
  if (_rewardRoundExpired(rewardRound)) {
253
- _recycleExpiredRewardRound({hook: ctx.hook, token: token, round: rewardRoundNumber});
476
+ _recycleExpiredRewardRound({
477
+ hook: ctx.hook, groupId: ctx.groupId, token: token, round: rewardRoundNumber
478
+ });
254
479
  } else if (rewardRound.totalStake != 0) {
255
480
  // Bundle the fixed round data used by every NFT in the batch.
256
481
  JBVestContext memory vestCtx = JBVestContext({
257
482
  hook: ctx.hook,
483
+ groupId: ctx.groupId,
484
+ tierIds: ctx.tierIds,
258
485
  token: token,
259
486
  distributable: rewardRound.amount,
260
487
  totalStakeAmount: rewardRound.totalStake,
@@ -286,17 +513,18 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
286
513
  // Advance cursors even when a token ID earned zero, so empty or zero-stake rounds are not rescanned forever.
287
514
  for (uint256 i; i < tokenIds.length;) {
288
515
  uint256 tokenId = tokenIds[i];
289
- nextClaimRoundOf[ctx.hook][tokenId][token] = ctx.lastClaimableRound + 1;
516
+ nextClaimRoundOf[ctx.hook][ctx.groupId][tokenId][token] = ctx.lastClaimableRound + 1;
290
517
 
291
518
  // All accumulated past rewards for this NFT start a single fresh vesting schedule at the claim round.
292
519
  if (tokenAmounts[i] != 0) {
293
- vestingDataOf[ctx.hook][tokenId][token].push(
520
+ vestingDataOf[ctx.hook][ctx.groupId][tokenId][token].push(
294
521
  JBVestingData({releaseRound: ctx.vestingReleaseRound, amount: tokenAmounts[i], shareClaimed: 0})
295
522
  );
296
523
 
297
524
  emit Claimed({
298
525
  hook: ctx.hook,
299
526
  tokenId: tokenId,
527
+ groupId: ctx.groupId,
300
528
  token: token,
301
529
  amount: tokenAmounts[i],
302
530
  vestingReleaseRound: ctx.vestingReleaseRound,
@@ -323,6 +551,31 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
323
551
  internal
324
552
  returns (uint256 totalVestingAmount)
325
553
  {
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.
326
579
  // Allocate scratch arrays sized to the maximum possible number of distinct snapshot owners.
327
580
  address[] memory owners = new address[](tokenIds.length);
328
581
  uint256[] memory consumed = new uint256[](tokenIds.length);
@@ -330,7 +583,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
330
583
 
331
584
  // Claim each token ID that has not yet advanced past this reward round.
332
585
  for (uint256 j; j < tokenIds.length;) {
333
- if (nextClaimRoundOf[ctx.hook][tokenIds[j]][ctx.token] <= ctx.rewardRound) {
586
+ if (nextClaimRoundOf[ctx.hook][0][tokenIds[j]][ctx.token] <= ctx.rewardRound) {
334
587
  (uint256 tokenAmount, uint256 newUniqueCount) = _claimRewardRoundForTokenId({
335
588
  ctx: ctx, tokenId: tokenIds[j], owners: owners, consumed: consumed, uniqueCount: uniqueCount
336
589
  });
@@ -439,6 +692,37 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
439
692
  canClaim = IERC721(hook).ownerOf(tokenId) == account;
440
693
  }
441
694
 
695
+ /// @notice Derive the canonical group ID for a tier set. The empty set is the all-tiers group (0).
696
+ /// @param tierIds Strictly-increasing tier IDs; empty for the all-tiers group.
697
+ /// @return groupId 0 for the all-tiers group, else `keccak256(abi.encode(tierIds))`.
698
+ function _groupIdFor(uint256[] calldata tierIds) internal pure returns (uint256 groupId) {
699
+ if (tierIds.length == 0) return 0;
700
+ for (uint256 i = 1; i < tierIds.length;) {
701
+ if (tierIds[i] <= tierIds[i - 1]) {
702
+ revert JB721Distributor_TierIdsNotIncreasing({previousTierId: tierIds[i - 1], tierId: tierIds[i]});
703
+ }
704
+ unchecked {
705
+ ++i;
706
+ }
707
+ }
708
+ groupId = uint256(keccak256(abi.encode(tierIds)));
709
+ }
710
+
711
+ /// @notice Whether a tier ID is present in a strictly-increasing tier set.
712
+ /// @param tierId The tier ID to look for.
713
+ /// @param tierIds The strictly-increasing tier set to search.
714
+ /// @return found True if `tierId` is in `tierIds`.
715
+ function _isTierInSet(uint256 tierId, uint256[] memory tierIds) internal pure returns (bool found) {
716
+ for (uint256 i; i < tierIds.length;) {
717
+ if (tierIds[i] == tierId) return true;
718
+ // The set is strictly increasing, so once an entry exceeds the target it cannot appear later.
719
+ if (tierIds[i] > tierId) return false;
720
+ unchecked {
721
+ ++i;
722
+ }
723
+ }
724
+ }
725
+
442
726
  /// @notice Revert unless the caller is authorized to claim each NFT token ID.
443
727
  /// @param hook The 721 hook whose NFT owners are claiming.
444
728
  /// @param tokenIds The NFT token IDs to check.
@@ -461,6 +745,30 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
461
745
  }
462
746
  }
463
747
 
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
+
464
772
  /// @notice Checks if the given token was burned.
465
773
  /// @param hook The hook the token belongs to.
466
774
  /// @param tokenId The tokenId to check.
@@ -504,16 +812,39 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
504
812
  tokenStakeAmount = votingUnits < pastVotes ? votingUnits : pastVotes;
505
813
  }
506
814
 
507
- /// @notice The total stake at a specific block, using the hook's checkpoints module for historical accuracy.
508
- /// @dev Uses `IVotes.getPastTotalSupply` from the hook's checkpoints module. This ensures that only NFTs
509
- /// that existed (and were delegated) at `blockNumber` are counted, preventing late mints from diluting or
510
- /// capturing rewards within the current round.
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.
511
819
  /// @param hook The hook to get the total stake for.
820
+ /// @param groupId The reward group (0 = all tiers).
512
821
  /// @param blockNumber The block number to get the total staked amount at.
513
- /// @return total The total checkpointed voting units at the given block.
514
- function _totalStake(address hook, uint256 blockNumber) internal view override returns (uint256 total) {
822
+ /// @return total The total stake at the given block.
823
+ function _totalStake(
824
+ address hook,
825
+ uint256 groupId,
826
+ uint256 blockNumber
827
+ )
828
+ internal
829
+ view
830
+ override
831
+ returns (uint256 total)
832
+ {
515
833
  IJB721Checkpoints checkpoints = IJB721TiersHook(hook).checkpoints();
516
- total = IVotes(address(checkpoints)).getPastTotalSupply(blockNumber);
834
+
835
+ // All-tiers group (0): the global checkpointed voting supply.
836
+ if (groupId == 0) {
837
+ return IVotes(address(checkpoints)).getPastTotalSupply(blockNumber);
838
+ }
839
+
840
+ // Tier-scoped group: sum the eligible voting units of each tier in the set at the snapshot block.
841
+ uint256[] memory tierIds = _tierIdsOfGroup[hook][groupId];
842
+ for (uint256 i; i < tierIds.length;) {
843
+ total += checkpoints.getPastTierVotingUnits({tierId: tierIds[i], blockNumber: blockNumber});
844
+ unchecked {
845
+ ++i;
846
+ }
847
+ }
517
848
  }
518
849
 
519
850
  //*********************************************************************//