@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.
- package/README.md +40 -30
- package/package.json +6 -6
- package/references/operations.md +4 -4
- package/references/runtime.md +1 -1
- package/src/JB721Distributor.sol +198 -166
- package/src/JBDistributor.sol +115 -55
- package/src/JBTokenDistributor.sol +135 -82
- package/src/interfaces/IJB721Distributor.sol +27 -21
- package/src/interfaces/IJBDistributor.sol +60 -34
- package/src/interfaces/IJBTokenDistributor.sol +4 -3
- package/src/libraries/JBVestingMath.sol +3 -2
- package/src/structs/JBClaimContext.sol +1 -0
- package/src/structs/JBRewardRoundData.sol +2 -2
- package/src/structs/JBVestContext.sol +1 -0
package/src/JB721Distributor.sol
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
88
|
-
|
|
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
|
|
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 =
|
|
245
|
+
amount = _recycleExpiredRewards({hook: hook, groupId: _groupIdFor(tierIds), token: token, rounds: rounds});
|
|
237
246
|
}
|
|
238
247
|
|
|
239
|
-
/// @notice Recycle
|
|
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
|
|
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
|
-
//
|
|
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][
|
|
571
|
+
if (nextClaimRoundOf[ctx.hook][ctx.groupId][tokenIds[j]][ctx.token] <= ctx.rewardRound) {
|
|
587
572
|
(uint256 tokenAmount, uint256 newUniqueCount) = _claimRewardRoundForTokenId({
|
|
588
|
-
ctx: ctx,
|
|
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
|
|
591
|
+
// Persist consumed active tier units to storage to prevent cap resets across separate claim calls.
|
|
602
592
|
for (uint256 k; k < uniqueCount;) {
|
|
603
|
-
|
|
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
|
|
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
|
|
615
|
-
/// @param
|
|
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
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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] =
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
727
|
-
/// @param hook The 721 hook whose NFT owners are
|
|
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
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
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
|
|
808
|
-
if (
|
|
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
|
|
811
|
-
|
|
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 `
|
|
817
|
-
///
|
|
818
|
-
///
|
|
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
|
|
848
|
+
return checkpoints.getPastTotalActiveVotes(blockNumber);
|
|
838
849
|
}
|
|
839
850
|
|
|
840
|
-
// Tier-scoped group: sum
|
|
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.
|
|
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
|
}
|