@bananapus/distributor-v6 0.0.34 → 0.0.35
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 +7 -0
- package/package.json +2 -2
- package/references/operations.md +2 -1
- package/src/JB721Distributor.sol +357 -26
- package/src/JBDistributor.sol +348 -171
- package/src/JBTokenDistributor.sol +49 -17
- package/src/interfaces/IJB721Distributor.sol +139 -0
- package/src/interfaces/IJBDistributor.sol +22 -8
- package/src/structs/JBBorrowContext.sol +2 -0
- package/src/structs/JBClaimContext.sol +5 -0
- package/src/structs/JBVestContext.sol +5 -0
- package/src/structs/JBVestingLoan.sol +2 -0
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.
|
|
3
|
+
"version": "0.0.35",
|
|
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.
|
|
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",
|
package/references/operations.md
CHANGED
|
@@ -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
|
package/src/JB721Distributor.sol
CHANGED
|
@@ -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(
|
|
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
|
-
//
|
|
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
|
|
166
|
-
// same flow (authorize -> materialize past rounds via `_claimPastRewards` ->
|
|
167
|
-
// round-claim logic lives once in the base and dispatches to this contract's
|
|
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(
|
|
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
|
-
|
|
197
|
-
|
|
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({
|
|
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
|
|
508
|
-
/// @dev
|
|
509
|
-
///
|
|
510
|
-
///
|
|
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
|
|
514
|
-
function _totalStake(
|
|
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
|
-
|
|
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
|
//*********************************************************************//
|