@bananapus/distributor-v6 0.0.24 → 0.0.27
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 +17 -7
- package/package.json +1 -1
- package/src/JB721Distributor.sol +362 -32
- package/src/JBDistributor.sol +270 -21
- package/src/JBTokenDistributor.sol +268 -12
- package/src/interfaces/IJBDistributor.sol +51 -6
- package/src/structs/JBClaimContext.sol +11 -0
- package/src/structs/JBRewardRoundData.sol +16 -0
- package/src/structs/JBVestContext.sol +21 -0
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ The package separates distribution mechanics by asset type:
|
|
|
17
17
|
|
|
18
18
|
- `JBDistributor` coordinates shared round and vesting logic
|
|
19
19
|
- `JBTokenDistributor` distributes ERC-20 balances using `IVotes` checkpointed voting power
|
|
20
|
-
- `JB721Distributor` distributes value to 721 holders using checkpointed voting power, ensuring only holders at round
|
|
20
|
+
- `JB721Distributor` distributes value to 721 holders using checkpointed voting power, ensuring only holders at the funded round's snapshot block are eligible
|
|
21
21
|
|
|
22
22
|
Both concrete distributors implement `IJBSplitHook`, which makes them usable directly from Juicebox payout splits.
|
|
23
23
|
|
|
@@ -31,14 +31,17 @@ If the issue is "where did the project's value come from?" start in `nana-core-v
|
|
|
31
31
|
| --- | --- |
|
|
32
32
|
| `JBDistributor` | Shared round-based vesting, claiming, and accounting logic. |
|
|
33
33
|
| `JBTokenDistributor` | ERC-20 distributor keyed to `IVotes` checkpointed voting power. |
|
|
34
|
-
| `JB721Distributor` | NFT-aware distributor keyed to checkpointed voting power from the hook's `CHECKPOINTS()` module. Only NFTs held at round
|
|
34
|
+
| `JB721Distributor` | NFT-aware distributor keyed to checkpointed voting power from the hook's `CHECKPOINTS()` module. Only NFTs held at the funded round's snapshot block are eligible. |
|
|
35
35
|
|
|
36
36
|
## Mental Model
|
|
37
37
|
|
|
38
38
|
1. a project funds the distributor, often through a payout split
|
|
39
|
-
2.
|
|
40
|
-
3.
|
|
41
|
-
4.
|
|
39
|
+
2. accepted funding is assigned to the current reward round for the chosen token or 721 stake source
|
|
40
|
+
3. optional direct funding can set a claim duration; split funding and plain `fund` stay non-expiring
|
|
41
|
+
4. the encoded token staker or current NFT owner later claims completed past reward rounds into a fresh vesting entry
|
|
42
|
+
5. anyone can burn expired unclaimed reward rounds after their deadline
|
|
43
|
+
6. recipients collect their vested share as the configured vesting schedule unlocks
|
|
44
|
+
7. some unclaimable value can be reclaimed through explicit recovery paths, depending on the distributor type
|
|
42
45
|
|
|
43
46
|
This repo does not explain why an allocation exists. It only defines how funded inventory is handed out.
|
|
44
47
|
|
|
@@ -52,7 +55,12 @@ This repo does not explain why an allocation exists. It only defines how funded
|
|
|
52
55
|
## Integration Traps
|
|
53
56
|
|
|
54
57
|
- distribution correctness depends on the distributor actually holding the assets it is expected to vest
|
|
55
|
-
- ERC-20 and ERC-721 distributions share
|
|
58
|
+
- ERC-20 and ERC-721 distributions share historical reward-round accounting, but claim authority differs:
|
|
59
|
+
token rewards are claimed by the encoded staker address, while 721 rewards are claimed by the current NFT owner
|
|
60
|
+
- `fundWithClaimDuration` starts the claim window when the funded round first becomes claimable; incompatible
|
|
61
|
+
same-round deadlines for the same hook and reward token revert
|
|
62
|
+
- `burnExpiredRewards` is permissionless and only burns the unclaimed remainder; already-materialized vesting entries
|
|
63
|
+
remain claimable on their normal vesting curve
|
|
56
64
|
- `releaseForfeitedRewards` matters for 721 distributions; token-vote distributions do not have the same burned-token path
|
|
57
65
|
- snapshot timing is part of the trusted surface
|
|
58
66
|
- this repo settles distributions, but it does not prove the upstream entitlement math was correct
|
|
@@ -60,7 +68,7 @@ This repo does not explain why an allocation exists. It only defines how funded
|
|
|
60
68
|
## Where State Lives
|
|
61
69
|
|
|
62
70
|
- round and vesting state: `JBDistributor`
|
|
63
|
-
-
|
|
71
|
+
- historical reward-round inputs: `JBRewardRoundData`
|
|
64
72
|
- vesting schedule state: `JBVestingData`
|
|
65
73
|
- asset-specific claim behavior: the concrete distributor
|
|
66
74
|
|
|
@@ -110,6 +118,8 @@ script/
|
|
|
110
118
|
- distributors are only as trustworthy as the vesting parameters and funding they receive
|
|
111
119
|
- operational mistakes often come from funding the wrong asset or underfunding the distributor
|
|
112
120
|
- teams should review claim timing and snapshot assumptions with the same care they review the payout source
|
|
121
|
+
- rewarders that set claim durations should choose a window long enough for expected claimants, because expired
|
|
122
|
+
unclaimed rewards can be burned by anyone
|
|
113
123
|
|
|
114
124
|
## For AI Agents
|
|
115
125
|
|
package/package.json
CHANGED
package/src/JB721Distributor.sol
CHANGED
|
@@ -8,15 +8,18 @@ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
|
8
8
|
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
9
9
|
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
10
10
|
import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
|
|
11
|
+
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
|
|
11
12
|
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
12
|
-
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
13
13
|
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
|
|
14
|
-
import {
|
|
15
|
-
|
|
14
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
16
15
|
import {mulDiv} from "@prb/math/src/Common.sol";
|
|
17
16
|
|
|
18
|
-
import {IJB721Distributor} from "./interfaces/IJB721Distributor.sol";
|
|
19
17
|
import {JBDistributor} from "./JBDistributor.sol";
|
|
18
|
+
import {IJB721Distributor} from "./interfaces/IJB721Distributor.sol";
|
|
19
|
+
import {IJBDistributor} from "./interfaces/IJBDistributor.sol";
|
|
20
|
+
import {JBClaimContext} from "./structs/JBClaimContext.sol";
|
|
21
|
+
import {JBRewardRoundData} from "./structs/JBRewardRoundData.sol";
|
|
22
|
+
import {JBVestContext} from "./structs/JBVestContext.sol";
|
|
20
23
|
import {JBVestingData} from "./structs/JBVestingData.sol";
|
|
21
24
|
|
|
22
25
|
/// @notice A singleton distributor that distributes ERC-20 rewards to JB 721 NFT stakers with linear vesting.
|
|
@@ -24,12 +27,17 @@ import {JBVestingData} from "./structs/JBVestingData.sol";
|
|
|
24
27
|
/// `hook = this contract` and `beneficiary = address(their 721 hook)`.
|
|
25
28
|
/// @dev The stake weight of each NFT is its tier's `votingUnits`. Burned NFTs are excluded from the total stake
|
|
26
29
|
/// calculation and their unvested rewards can be reclaimed via `releaseForfeitedRewards`.
|
|
30
|
+
/// @dev Funded rewards are assigned to the funding round. NFT owners claim historical rounds lazily; all unclaimed
|
|
31
|
+
/// past rewards begin vesting when the current NFT owner claims, not when the rewards were funded.
|
|
27
32
|
/// @dev Implements `IJBSplitHook` so it can receive tokens directly from Juicebox project payout splits.
|
|
28
33
|
contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
29
34
|
//*********************************************************************//
|
|
30
35
|
// --------------------------- custom errors ------------------------- //
|
|
31
36
|
//*********************************************************************//
|
|
32
37
|
|
|
38
|
+
/// @notice Thrown when a claim batch repeats an NFT token ID.
|
|
39
|
+
error JB721Distributor_DuplicateTokenId(uint256 tokenId);
|
|
40
|
+
|
|
33
41
|
/// @notice Thrown when native ETH does not match the split hook context amount.
|
|
34
42
|
error JB721Distributor_NativeAmountMismatch(uint256 msgValue, uint256 contextAmount);
|
|
35
43
|
|
|
@@ -40,36 +48,33 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
40
48
|
error JB721Distributor_Unauthorized(uint256 projectId, address caller);
|
|
41
49
|
|
|
42
50
|
//*********************************************************************//
|
|
43
|
-
//
|
|
51
|
+
// ---------------- public immutable stored properties --------------- //
|
|
44
52
|
//*********************************************************************//
|
|
45
53
|
|
|
46
|
-
/// @
|
|
47
|
-
|
|
48
|
-
address hook;
|
|
49
|
-
IERC20 token;
|
|
50
|
-
uint256 distributable;
|
|
51
|
-
uint256 totalStakeAmount;
|
|
52
|
-
uint256 vestingReleaseRound;
|
|
53
|
-
}
|
|
54
|
+
/// @notice The JB directory used to verify terminal/controller callers.
|
|
55
|
+
IJBDirectory public immutable DIRECTORY;
|
|
54
56
|
|
|
55
57
|
//*********************************************************************//
|
|
56
|
-
//
|
|
58
|
+
// --------------------- public stored properties -------------------- //
|
|
57
59
|
//*********************************************************************//
|
|
58
60
|
|
|
59
|
-
/// @notice The
|
|
60
|
-
|
|
61
|
+
/// @notice The next reward round an NFT token ID has not yet claimed.
|
|
62
|
+
/// @custom:param hook The 721 hook whose NFTs are claiming.
|
|
63
|
+
/// @custom:param tokenId The NFT token ID.
|
|
64
|
+
/// @custom:param token The reward token being claimed.
|
|
65
|
+
mapping(address hook => mapping(uint256 tokenId => mapping(IERC20 token => uint256))) public nextClaimRoundOf;
|
|
61
66
|
|
|
62
67
|
//*********************************************************************//
|
|
63
68
|
// -------------------- internal stored properties ------------------- //
|
|
64
69
|
//*********************************************************************//
|
|
65
70
|
|
|
66
|
-
/// @notice Tracks voting power consumed per hook/token/round/owner to prevent cap resets across calls.
|
|
71
|
+
/// @notice Tracks voting power consumed per hook/token/reward round/owner to prevent cap resets across calls.
|
|
67
72
|
/// @custom:param hook The hook address.
|
|
68
73
|
/// @custom:param token The reward token.
|
|
69
|
-
/// @custom:param
|
|
74
|
+
/// @custom:param rewardRound The reward round.
|
|
70
75
|
/// @custom:param owner The NFT owner.
|
|
71
76
|
mapping(
|
|
72
|
-
address hook => mapping(IERC20 token => mapping(uint256
|
|
77
|
+
address hook => mapping(IERC20 token => mapping(uint256 rewardRound => mapping(address owner => uint256)))
|
|
73
78
|
) internal _consumedVotesOf;
|
|
74
79
|
|
|
75
80
|
//*********************************************************************//
|
|
@@ -123,8 +128,8 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
123
128
|
}
|
|
124
129
|
|
|
125
130
|
if (msg.value != 0) {
|
|
126
|
-
|
|
127
|
-
|
|
131
|
+
// Assign native split proceeds to the current reward round for this 721 hook.
|
|
132
|
+
_recordRewardFunding({hook: hook, token: IERC20(context.token), amount: msg.value, claimDuration: 0});
|
|
128
133
|
}
|
|
129
134
|
} else {
|
|
130
135
|
// Validate that native ETH is not cross-booked under an ERC-20 token.
|
|
@@ -140,11 +145,64 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
140
145
|
// allowance before calling. Balance delta handles fee-on-transfer tokens correctly.
|
|
141
146
|
uint256 delta =
|
|
142
147
|
_acceptErc20FundsFrom({token: IERC20(context.token), from: msg.sender, amount: context.amount});
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
|
|
149
|
+
// Assign only the amount actually received to this round's reward pot.
|
|
150
|
+
_recordRewardFunding({hook: hook, token: IERC20(context.token), amount: delta, claimDuration: 0});
|
|
145
151
|
}
|
|
146
152
|
}
|
|
147
153
|
|
|
154
|
+
/// @notice Snapshot this NFT's past reward rounds and start vesting them now.
|
|
155
|
+
/// @dev Current-round funding is excluded. It becomes claimable once a later round starts.
|
|
156
|
+
/// @param hook The 721 hook whose NFTs are vesting.
|
|
157
|
+
/// @param tokenIds The NFT token IDs to claim for.
|
|
158
|
+
/// @param tokens The reward tokens to begin vesting.
|
|
159
|
+
function beginVesting(
|
|
160
|
+
address hook,
|
|
161
|
+
uint256[] calldata tokenIds,
|
|
162
|
+
IERC20[] calldata tokens
|
|
163
|
+
)
|
|
164
|
+
external
|
|
165
|
+
override(JBDistributor, IJBDistributor)
|
|
166
|
+
{
|
|
167
|
+
// Do not let reward-token callbacks mutate claim accounting during an inbound transfer.
|
|
168
|
+
_requireNotAcceptingToken();
|
|
169
|
+
if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
|
|
170
|
+
|
|
171
|
+
// Only the current NFT owner can start vesting for each token ID.
|
|
172
|
+
_requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
|
|
173
|
+
|
|
174
|
+
// Materialize all unclaimed historical rewards into fresh vesting entries that start now.
|
|
175
|
+
_claimPastRewards({hook: hook, tokenIds: tokenIds, tokens: tokens});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/// @notice Collect already-vested rewards and first start vesting any unclaimed past reward rounds.
|
|
179
|
+
/// @param hook The 721 hook whose NFTs are collecting.
|
|
180
|
+
/// @param tokenIds The NFT token IDs to collect for.
|
|
181
|
+
/// @param tokens The reward tokens to collect.
|
|
182
|
+
/// @param beneficiary The recipient of collected vested rewards.
|
|
183
|
+
function collectVestedRewards(
|
|
184
|
+
address hook,
|
|
185
|
+
uint256[] calldata tokenIds,
|
|
186
|
+
IERC20[] calldata tokens,
|
|
187
|
+
address beneficiary
|
|
188
|
+
)
|
|
189
|
+
public
|
|
190
|
+
override(JBDistributor, IJBDistributor)
|
|
191
|
+
{
|
|
192
|
+
// Do not let reward-token callbacks mutate claim accounting during an inbound transfer.
|
|
193
|
+
_requireNotAcceptingToken();
|
|
194
|
+
if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
|
|
195
|
+
|
|
196
|
+
// Only the current NFT owner can materialize and collect rewards for each token ID.
|
|
197
|
+
_requireCanClaimTokenIds({hook: hook, tokenIds: tokenIds});
|
|
198
|
+
|
|
199
|
+
// Before collecting, bring the token IDs current by starting vesting for any past reward rounds.
|
|
200
|
+
_claimPastRewards({hook: hook, tokenIds: tokenIds, tokens: tokens});
|
|
201
|
+
|
|
202
|
+
// Release whatever portion of existing vesting entries has unlocked by this round.
|
|
203
|
+
_unlockRewards({hook: hook, tokenIds: tokenIds, tokens: tokens, beneficiary: beneficiary, ownerClaim: true});
|
|
204
|
+
}
|
|
205
|
+
|
|
148
206
|
//*********************************************************************//
|
|
149
207
|
// -------------------------- public views --------------------------- //
|
|
150
208
|
//*********************************************************************//
|
|
@@ -161,6 +219,248 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
161
219
|
// ---------------------- internal transactions ---------------------- //
|
|
162
220
|
//*********************************************************************//
|
|
163
221
|
|
|
222
|
+
/// @notice Claim all past reward rounds for the given NFT token IDs and reward tokens into fresh vesting entries.
|
|
223
|
+
/// @param hook The 721 hook whose NFT owners are claiming.
|
|
224
|
+
/// @param tokenIds The NFT token IDs to claim for.
|
|
225
|
+
/// @param tokens The reward tokens to claim.
|
|
226
|
+
function _claimPastRewards(address hook, uint256[] calldata tokenIds, IERC20[] calldata tokens) internal {
|
|
227
|
+
// Round 0 has no completed reward rounds behind it, so nothing can be claimed yet.
|
|
228
|
+
uint256 round = currentRound();
|
|
229
|
+
if (round == 0) return;
|
|
230
|
+
|
|
231
|
+
// Current-round funding is excluded. It becomes claimable only after a later round starts.
|
|
232
|
+
JBClaimContext memory ctx =
|
|
233
|
+
JBClaimContext({hook: hook, lastClaimableRound: round - 1, vestingReleaseRound: round + vestingRounds});
|
|
234
|
+
|
|
235
|
+
// Process each reward token independently because each token has its own round funding and claim cursor.
|
|
236
|
+
for (uint256 i; i < tokens.length;) {
|
|
237
|
+
IERC20 token = tokens[i];
|
|
238
|
+
uint256 totalVestingAmount = _claimPastRewardsForToken({ctx: ctx, tokenIds: tokenIds, token: token});
|
|
239
|
+
|
|
240
|
+
// Track the newly claimed amount as vesting, so later collections unlock against it over time.
|
|
241
|
+
if (totalVestingAmount != 0) totalVestingAmountOf[hook][token] += totalVestingAmount;
|
|
242
|
+
|
|
243
|
+
unchecked {
|
|
244
|
+
++i;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/// @notice Claim one reward token across all completed rounds for a batch of NFT token IDs.
|
|
250
|
+
/// @param ctx The claim-round context.
|
|
251
|
+
/// @param tokenIds The NFT token IDs to claim for.
|
|
252
|
+
/// @param token The reward token to claim.
|
|
253
|
+
/// @return totalVestingAmount The amount added to vesting for this reward token.
|
|
254
|
+
function _claimPastRewardsForToken(
|
|
255
|
+
JBClaimContext memory ctx,
|
|
256
|
+
uint256[] calldata tokenIds,
|
|
257
|
+
IERC20 token
|
|
258
|
+
)
|
|
259
|
+
internal
|
|
260
|
+
returns (uint256 totalVestingAmount)
|
|
261
|
+
{
|
|
262
|
+
uint256[] memory tokenAmounts = new uint256[](tokenIds.length);
|
|
263
|
+
uint256 firstClaimRound = ctx.lastClaimableRound + 1;
|
|
264
|
+
|
|
265
|
+
// Find the earliest cursor in the batch, skipping token IDs that are already current.
|
|
266
|
+
for (uint256 i; i < tokenIds.length;) {
|
|
267
|
+
uint256 nextClaimRound = nextClaimRoundOf[ctx.hook][tokenIds[i]][token];
|
|
268
|
+
if (nextClaimRound <= ctx.lastClaimableRound && nextClaimRound < firstClaimRound) {
|
|
269
|
+
firstClaimRound = nextClaimRound;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
unchecked {
|
|
273
|
+
++i;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// If every token ID is already current, there is nothing to materialize.
|
|
278
|
+
if (firstClaimRound > ctx.lastClaimableRound) return 0;
|
|
279
|
+
|
|
280
|
+
// Walk every unclaimed historical round needed by at least one token ID.
|
|
281
|
+
for (uint256 rewardRoundNumber = firstClaimRound; rewardRoundNumber <= ctx.lastClaimableRound;) {
|
|
282
|
+
// Load this reward round's funding, snapshot, claim counter, and deadline.
|
|
283
|
+
JBRewardRoundData storage rewardRound = rewardRoundOf[ctx.hook][token][rewardRoundNumber];
|
|
284
|
+
|
|
285
|
+
// Skip rounds that never received funding.
|
|
286
|
+
if (rewardRound.amount != 0) {
|
|
287
|
+
// Expired rounds can no longer be claimed; burn their unclaimed remainder instead.
|
|
288
|
+
if (_rewardRoundExpired(rewardRound)) {
|
|
289
|
+
_burnExpiredRewardRound({hook: ctx.hook, token: token, round: rewardRoundNumber});
|
|
290
|
+
} else if (rewardRound.totalStake != 0) {
|
|
291
|
+
// Bundle the fixed round data used by every NFT in the batch.
|
|
292
|
+
JBVestContext memory vestCtx = JBVestContext({
|
|
293
|
+
hook: ctx.hook,
|
|
294
|
+
token: token,
|
|
295
|
+
distributable: rewardRound.amount,
|
|
296
|
+
totalStakeAmount: rewardRound.totalStake,
|
|
297
|
+
vestingReleaseRound: ctx.vestingReleaseRound,
|
|
298
|
+
rewardRound: rewardRoundNumber,
|
|
299
|
+
snapshotBlock: rewardRound.snapshotBlock
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Claim this round for every eligible token ID that has not already advanced past it.
|
|
303
|
+
uint256 roundVestingAmount =
|
|
304
|
+
_claimRewardRoundForTokenIds({ctx: vestCtx, tokenIds: tokenIds, tokenAmounts: tokenAmounts});
|
|
305
|
+
|
|
306
|
+
// Track only the amount that actually started vesting, leaving zero-vote and dust amounts burnable.
|
|
307
|
+
if (roundVestingAmount != 0) {
|
|
308
|
+
rewardRound.claimedAmount = _toUint208(uint256(rewardRound.claimedAmount) + roundVestingAmount);
|
|
309
|
+
|
|
310
|
+
// Add this round's vesting amount into the reward token batch total.
|
|
311
|
+
totalVestingAmount += roundVestingAmount;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
unchecked {
|
|
317
|
+
++rewardRoundNumber;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Advance cursors even when a token ID earned zero, so empty or zero-stake rounds are not rescanned forever.
|
|
322
|
+
for (uint256 i; i < tokenIds.length;) {
|
|
323
|
+
uint256 tokenId = tokenIds[i];
|
|
324
|
+
nextClaimRoundOf[ctx.hook][tokenId][token] = ctx.lastClaimableRound + 1;
|
|
325
|
+
|
|
326
|
+
// All accumulated past rewards for this NFT start a single fresh vesting schedule at the claim round.
|
|
327
|
+
if (tokenAmounts[i] != 0) {
|
|
328
|
+
vestingDataOf[ctx.hook][tokenId][token].push(
|
|
329
|
+
JBVestingData({releaseRound: ctx.vestingReleaseRound, amount: tokenAmounts[i], shareClaimed: 0})
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
emit Claimed({
|
|
333
|
+
hook: ctx.hook,
|
|
334
|
+
tokenId: tokenId,
|
|
335
|
+
token: token,
|
|
336
|
+
amount: tokenAmounts[i],
|
|
337
|
+
vestingReleaseRound: ctx.vestingReleaseRound,
|
|
338
|
+
caller: msg.sender
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
unchecked {
|
|
343
|
+
++i;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// @notice Claim one funded historical reward round for a batch of NFT token IDs.
|
|
349
|
+
/// @param ctx The reward-round context.
|
|
350
|
+
/// @param tokenIds The NFT token IDs to claim for.
|
|
351
|
+
/// @param tokenAmounts The cumulative amount to vest for each token ID in `tokenIds`.
|
|
352
|
+
/// @return totalVestingAmount The amount added to vesting from this reward round.
|
|
353
|
+
function _claimRewardRoundForTokenIds(
|
|
354
|
+
JBVestContext memory ctx,
|
|
355
|
+
uint256[] calldata tokenIds,
|
|
356
|
+
uint256[] memory tokenAmounts
|
|
357
|
+
)
|
|
358
|
+
internal
|
|
359
|
+
returns (uint256 totalVestingAmount)
|
|
360
|
+
{
|
|
361
|
+
// Allocate scratch arrays sized to the maximum possible number of distinct snapshot owners.
|
|
362
|
+
address[] memory owners = new address[](tokenIds.length);
|
|
363
|
+
uint256[] memory consumed = new uint256[](tokenIds.length);
|
|
364
|
+
uint256 uniqueCount;
|
|
365
|
+
|
|
366
|
+
// Claim each token ID that has not yet advanced past this reward round.
|
|
367
|
+
for (uint256 j; j < tokenIds.length;) {
|
|
368
|
+
if (nextClaimRoundOf[ctx.hook][tokenIds[j]][ctx.token] <= ctx.rewardRound) {
|
|
369
|
+
(uint256 tokenAmount, uint256 newUniqueCount) = _claimRewardRoundForTokenId({
|
|
370
|
+
ctx: ctx, tokenId: tokenIds[j], owners: owners, consumed: consumed, uniqueCount: uniqueCount
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
uniqueCount = newUniqueCount;
|
|
374
|
+
tokenAmounts[j] += tokenAmount;
|
|
375
|
+
totalVestingAmount += tokenAmount;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
unchecked {
|
|
379
|
+
++j;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Persist consumed voting power to storage to prevent cap resets across separate claim calls.
|
|
384
|
+
for (uint256 k; k < uniqueCount;) {
|
|
385
|
+
_consumedVotesOf[ctx.hook][ctx.token][ctx.rewardRound][owners[k]] = consumed[k];
|
|
386
|
+
unchecked {
|
|
387
|
+
++k;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/// @notice Claim one NFT token ID for one historical reward round, enforcing the snapshot owner's vote cap.
|
|
393
|
+
/// @param ctx The reward-round context.
|
|
394
|
+
/// @param tokenId The NFT token ID to claim for.
|
|
395
|
+
/// @param owners A scratch array mapping slot indices to snapshot owners for deduplication.
|
|
396
|
+
/// @param consumed A scratch array tracking consumed voting power by owner slot.
|
|
397
|
+
/// @param uniqueCount The number of distinct snapshot owners seen so far in this reward-round batch.
|
|
398
|
+
/// @return tokenAmount The reward amount vested for this token ID.
|
|
399
|
+
/// @return newUniqueCount The updated count of distinct snapshot owners after processing this token ID.
|
|
400
|
+
function _claimRewardRoundForTokenId(
|
|
401
|
+
JBVestContext memory ctx,
|
|
402
|
+
uint256 tokenId,
|
|
403
|
+
address[] memory owners,
|
|
404
|
+
uint256[] memory consumed,
|
|
405
|
+
uint256 uniqueCount
|
|
406
|
+
)
|
|
407
|
+
internal
|
|
408
|
+
view
|
|
409
|
+
returns (uint256 tokenAmount, uint256 newUniqueCount)
|
|
410
|
+
{
|
|
411
|
+
newUniqueCount = uniqueCount;
|
|
412
|
+
|
|
413
|
+
uint256 votingUnits =
|
|
414
|
+
IJB721TiersHook(ctx.hook)
|
|
415
|
+
.STORE()
|
|
416
|
+
.tierOfTokenId({hook: ctx.hook, tokenId: tokenId, includeResolvedUri: false}).votingUnits;
|
|
417
|
+
|
|
418
|
+
uint256 ownerIndex;
|
|
419
|
+
uint256 pastVotes;
|
|
420
|
+
{
|
|
421
|
+
// Use the funding round's snapshot block, not the block at which the NFT owner finally claims.
|
|
422
|
+
address owner = _snapshotOwnerOf({hook: ctx.hook, tokenId: tokenId, snapshotBlock: ctx.snapshotBlock});
|
|
423
|
+
if (owner == address(0)) return (0, newUniqueCount);
|
|
424
|
+
|
|
425
|
+
pastVotes = IVotes(address(IJB721TiersHook(ctx.hook).checkpoints()))
|
|
426
|
+
.getPastVotes({account: owner, timepoint: ctx.snapshotBlock});
|
|
427
|
+
if (pastVotes == 0) return (0, newUniqueCount);
|
|
428
|
+
|
|
429
|
+
bool found;
|
|
430
|
+
for (uint256 k; k < newUniqueCount;) {
|
|
431
|
+
if (owners[k] == owner) {
|
|
432
|
+
ownerIndex = k;
|
|
433
|
+
found = true;
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
unchecked {
|
|
437
|
+
++k;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (!found) {
|
|
442
|
+
ownerIndex = newUniqueCount;
|
|
443
|
+
owners[newUniqueCount] = owner;
|
|
444
|
+
// Initialize from persistent storage to prevent cap resets across separate claim calls.
|
|
445
|
+
consumed[newUniqueCount] = _consumedVotesOf[ctx.hook][ctx.token][ctx.rewardRound][owner];
|
|
446
|
+
unchecked {
|
|
447
|
+
++newUniqueCount;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
uint256 remaining = pastVotes > consumed[ownerIndex] ? pastVotes - consumed[ownerIndex] : 0;
|
|
453
|
+
uint256 stake = votingUnits < remaining ? votingUnits : remaining;
|
|
454
|
+
if (stake == 0) return (0, newUniqueCount);
|
|
455
|
+
|
|
456
|
+
// The round's reward pot is split pro-rata across checkpointed voting power.
|
|
457
|
+
tokenAmount = mulDiv({x: ctx.distributable, y: stake, denominator: ctx.totalStakeAmount});
|
|
458
|
+
if (tokenAmount == 0) return (0, newUniqueCount);
|
|
459
|
+
|
|
460
|
+
// Only non-zero reward claims consume the snapshot owner's voting budget.
|
|
461
|
+
consumed[ownerIndex] += stake;
|
|
462
|
+
}
|
|
463
|
+
|
|
164
464
|
/// @notice Override vesting to cap each owner's consumed voting power across all their NFTs.
|
|
165
465
|
/// @dev Prevents an owner with N NFTs of V voting units each from claiming N*V when their pastVotes < N*V.
|
|
166
466
|
/// Iterates over all token IDs in the batch, delegating per-token logic to `_vestSingleToken`. A pair of
|
|
@@ -187,12 +487,14 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
187
487
|
returns (uint256 totalVestingAmount)
|
|
188
488
|
{
|
|
189
489
|
// Bundle iteration-constant parameters into a struct to avoid stack-too-deep errors.
|
|
190
|
-
|
|
490
|
+
JBVestContext memory ctx = JBVestContext({
|
|
191
491
|
hook: hook,
|
|
192
492
|
token: token,
|
|
193
493
|
distributable: distributable,
|
|
194
494
|
totalStakeAmount: totalStakeAmount,
|
|
195
|
-
vestingReleaseRound: vestingReleaseRound
|
|
495
|
+
vestingReleaseRound: vestingReleaseRound,
|
|
496
|
+
rewardRound: currentRound(),
|
|
497
|
+
snapshotBlock: roundSnapshotBlock[currentRound()]
|
|
196
498
|
});
|
|
197
499
|
|
|
198
500
|
// Allocate scratch arrays sized to the maximum possible number of distinct owners (one per token ID).
|
|
@@ -221,7 +523,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
221
523
|
|
|
222
524
|
// Persist consumed voting power to storage to prevent cap resets across calls.
|
|
223
525
|
for (uint256 k; k < uniqueCount;) {
|
|
224
|
-
_consumedVotesOf[hook][token][
|
|
526
|
+
_consumedVotesOf[hook][token][ctx.rewardRound][owners[k]] = consumed[k];
|
|
225
527
|
unchecked {
|
|
226
528
|
++k;
|
|
227
529
|
}
|
|
@@ -241,6 +543,33 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
241
543
|
canClaim = IERC721(hook).ownerOf(tokenId) == account;
|
|
242
544
|
}
|
|
243
545
|
|
|
546
|
+
/// @notice Revert unless the caller is authorized to claim each NFT token ID.
|
|
547
|
+
/// @param hook The 721 hook whose NFT owners are claiming.
|
|
548
|
+
/// @param tokenIds The NFT token IDs to check.
|
|
549
|
+
function _requireCanClaimTokenIds(address hook, uint256[] calldata tokenIds) internal view {
|
|
550
|
+
// Each requested NFT must currently belong to msg.sender and appear only once in the batch.
|
|
551
|
+
for (uint256 i; i < tokenIds.length;) {
|
|
552
|
+
uint256 tokenId = tokenIds[i];
|
|
553
|
+
|
|
554
|
+
if (!_canClaim({hook: hook, tokenId: tokenId, account: msg.sender})) {
|
|
555
|
+
revert JBDistributor_NoAccess({hook: hook, tokenId: tokenId, account: msg.sender});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Reject duplicates before reward accounting so one NFT cannot replay the same historical round.
|
|
559
|
+
for (uint256 j = i + 1; j < tokenIds.length;) {
|
|
560
|
+
if (tokenIds[j] == tokenId) revert JB721Distributor_DuplicateTokenId({tokenId: tokenId});
|
|
561
|
+
|
|
562
|
+
unchecked {
|
|
563
|
+
++j;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
unchecked {
|
|
568
|
+
++i;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
244
573
|
/// @notice Checks if the given token was burned.
|
|
245
574
|
/// @param hook The hook the token belongs to.
|
|
246
575
|
/// @param tokenId The tokenId to check.
|
|
@@ -276,7 +605,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
276
605
|
uint256 pastVotes = IVotes(address(IJB721TiersHook(hook).checkpoints()))
|
|
277
606
|
.getPastVotes({account: owner, timepoint: snapshotBlock});
|
|
278
607
|
|
|
279
|
-
// If the owner had no voting power at
|
|
608
|
+
// If the owner had no voting power at the snapshot block, the token is ineligible.
|
|
280
609
|
if (pastVotes == 0) return 0;
|
|
281
610
|
|
|
282
611
|
// Cap at the token's tier voting units — the owner's past votes may cover multiple tokens,
|
|
@@ -343,7 +672,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
343
672
|
/// @return tokenAmount The reward amount vested for this token ID (0 if skipped).
|
|
344
673
|
/// @return newUniqueCount The updated count of distinct owners after processing this token ID.
|
|
345
674
|
function _vestSingleToken(
|
|
346
|
-
|
|
675
|
+
JBVestContext memory ctx,
|
|
347
676
|
uint256 tokenId,
|
|
348
677
|
address[] memory owners,
|
|
349
678
|
uint256[] memory consumed,
|
|
@@ -384,7 +713,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
384
713
|
uint256 pastVotes;
|
|
385
714
|
{
|
|
386
715
|
// Reuse the same round snapshot block for every token in this vesting batch.
|
|
387
|
-
uint256 snapshotBlock =
|
|
716
|
+
uint256 snapshotBlock = ctx.snapshotBlock;
|
|
388
717
|
address owner = _snapshotOwnerOf({hook: ctx.hook, tokenId: tokenId, snapshotBlock: snapshotBlock});
|
|
389
718
|
if (owner == address(0)) return (0, newUniqueCount);
|
|
390
719
|
|
|
@@ -392,7 +721,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
392
721
|
pastVotes = IVotes(address(IJB721TiersHook(ctx.hook).checkpoints()))
|
|
393
722
|
.getPastVotes({account: owner, timepoint: snapshotBlock});
|
|
394
723
|
|
|
395
|
-
// If the snapshot owner had no voting power at
|
|
724
|
+
// If the snapshot owner had no voting power at the snapshot block, the token is ineligible for this round.
|
|
396
725
|
if (pastVotes == 0) return (0, newUniqueCount);
|
|
397
726
|
|
|
398
727
|
// Search the owners array for an existing slot belonging to this owner.
|
|
@@ -414,7 +743,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
414
743
|
ownerIndex = newUniqueCount;
|
|
415
744
|
owners[newUniqueCount] = owner;
|
|
416
745
|
// Initialize from persistent storage to prevent cap resets across calls.
|
|
417
|
-
consumed[newUniqueCount] = _consumedVotesOf[ctx.hook][ctx.token][ctx.
|
|
746
|
+
consumed[newUniqueCount] = _consumedVotesOf[ctx.hook][ctx.token][ctx.rewardRound][owner];
|
|
418
747
|
unchecked {
|
|
419
748
|
++newUniqueCount;
|
|
420
749
|
}
|
|
@@ -455,7 +784,8 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
455
784
|
tokenId: tokenId,
|
|
456
785
|
token: ctx.token,
|
|
457
786
|
amount: tokenAmount,
|
|
458
|
-
vestingReleaseRound: ctx.vestingReleaseRound
|
|
787
|
+
vestingReleaseRound: ctx.vestingReleaseRound,
|
|
788
|
+
caller: msg.sender
|
|
459
789
|
});
|
|
460
790
|
}
|
|
461
791
|
}
|