@bananapus/distributor-v6 0.0.3 → 0.0.4
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/foundry.lock +5 -0
- package/package.json +1 -1
- package/src/JB721Distributor.sol +236 -22
- package/src/JBDistributor.sol +293 -213
- package/src/JBTokenDistributor.sol +32 -27
- package/src/interfaces/IJBDistributor.sol +37 -24
- package/test/AuditFixes.t.sol +429 -0
- package/test/JB721Distributor.t.sol +232 -163
- package/test/JBTokenDistributor.t.sol +92 -13
- package/test/audit/H26VotingPowerCap.t.sol +338 -0
- package/test/invariant/JB721DistributorInvariant.t.sol +11 -12
package/foundry.lock
ADDED
package/package.json
CHANGED
package/src/JB721Distributor.sol
CHANGED
|
@@ -13,8 +13,11 @@ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
|
13
13
|
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
|
|
14
14
|
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
|
|
15
15
|
|
|
16
|
+
import {mulDiv} from "@prb/math/src/Common.sol";
|
|
17
|
+
|
|
16
18
|
import {IJB721Distributor} from "./interfaces/IJB721Distributor.sol";
|
|
17
19
|
import {JBDistributor} from "./JBDistributor.sol";
|
|
20
|
+
import {JBVestingData} from "./structs/JBVestingData.sol";
|
|
18
21
|
|
|
19
22
|
/// @notice A singleton distributor that distributes ERC-20 rewards to JB 721 NFT stakers with linear vesting.
|
|
20
23
|
/// @dev Any project can use this distributor by configuring a payout split with
|
|
@@ -32,6 +35,19 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
32
35
|
/// @notice Thrown when the caller is not a terminal or controller for the project.
|
|
33
36
|
error JB721Distributor_Unauthorized();
|
|
34
37
|
|
|
38
|
+
//*********************************************************************//
|
|
39
|
+
// ----------------------------- structs ----------------------------- //
|
|
40
|
+
//*********************************************************************//
|
|
41
|
+
|
|
42
|
+
/// @dev Bundles per-round vesting parameters to avoid stack-too-deep.
|
|
43
|
+
struct VestContext {
|
|
44
|
+
address hook;
|
|
45
|
+
IERC20 token;
|
|
46
|
+
uint256 distributable;
|
|
47
|
+
uint256 totalStakeAmount;
|
|
48
|
+
uint256 vestingReleaseRound;
|
|
49
|
+
}
|
|
50
|
+
|
|
35
51
|
//*********************************************************************//
|
|
36
52
|
// ---------------- public immutable stored properties --------------- //
|
|
37
53
|
//*********************************************************************//
|
|
@@ -44,7 +60,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
44
60
|
//*********************************************************************//
|
|
45
61
|
|
|
46
62
|
/// @param directory The JB directory used to verify terminal/controller callers.
|
|
47
|
-
/// @param roundDuration_ The
|
|
63
|
+
/// @param roundDuration_ The duration of each round, specified in seconds.
|
|
48
64
|
/// @param vestingRounds_ The number of rounds until tokens are fully vested.
|
|
49
65
|
constructor(
|
|
50
66
|
IJBDirectory directory,
|
|
@@ -84,18 +100,21 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
84
100
|
// The target hook is the split's beneficiary.
|
|
85
101
|
address hook = address(context.split.beneficiary);
|
|
86
102
|
|
|
87
|
-
// If it's not a native-token transfer,
|
|
103
|
+
// If it's not a native-token transfer, credit the ERC-20 amount.
|
|
88
104
|
if (msg.value == 0 && context.amount != 0) {
|
|
89
|
-
uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
|
|
90
|
-
// Check if the caller has granted an allowance (terminal). If so, pull the tokens.
|
|
91
|
-
// The controller sends tokens before calling, so no pull is needed in that case.
|
|
92
105
|
uint256 allowance = IERC20(context.token).allowance(msg.sender, address(this));
|
|
93
106
|
if (allowance >= context.amount) {
|
|
94
|
-
// Terminal
|
|
107
|
+
// Terminal path: the caller granted an allowance — pull tokens via transferFrom.
|
|
108
|
+
// Use balance delta to handle fee-on-transfer tokens correctly.
|
|
109
|
+
uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
|
|
95
110
|
IERC20(context.token).safeTransferFrom(msg.sender, address(this), context.amount);
|
|
111
|
+
_balanceOf[hook][IERC20(context.token)] += IERC20(context.token).balanceOf(address(this))
|
|
112
|
+
- balanceBefore;
|
|
113
|
+
} else {
|
|
114
|
+
// Controller-prepaid path: the controller sends tokens directly before calling processSplitWith.
|
|
115
|
+
// Credit the declared amount since the tokens are already held by this contract.
|
|
116
|
+
_balanceOf[hook][IERC20(context.token)] += context.amount;
|
|
96
117
|
}
|
|
97
|
-
// For both terminal and controller paths, credit actual received amount (handles fee-on-transfer).
|
|
98
|
-
_balanceOf[hook][IERC20(context.token)] += IERC20(context.token).balanceOf(address(this)) - balanceBefore;
|
|
99
118
|
} else if (msg.value != 0) {
|
|
100
119
|
// Native ETH: credit actual value received.
|
|
101
120
|
_balanceOf[hook][IERC20(context.token)] += msg.value;
|
|
@@ -118,6 +137,69 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
118
137
|
// ---------------------- internal transactions ---------------------- //
|
|
119
138
|
//*********************************************************************//
|
|
120
139
|
|
|
140
|
+
/// @notice Override vesting to cap each owner's consumed voting power across all their NFTs.
|
|
141
|
+
/// @dev Prevents an owner with N NFTs of V voting units each from claiming N*V when their pastVotes < N*V.
|
|
142
|
+
/// Iterates over all token IDs in the batch, delegating per-token logic to `_vestSingleToken`. A pair of
|
|
143
|
+
/// scratch arrays (`owners` and `consumed`) tracks how much voting power each distinct owner has used so far,
|
|
144
|
+
/// ensuring the aggregate claim never exceeds the owner's snapshot voting power.
|
|
145
|
+
/// Silently skips burned tokens, already-vested tokens, and tokens whose owner had no snapshot voting power.
|
|
146
|
+
/// @param hook The address of the 721 hook whose stakers are vesting.
|
|
147
|
+
/// @param tokenIds The NFT token IDs to vest rewards for.
|
|
148
|
+
/// @param token The ERC-20 reward token being distributed.
|
|
149
|
+
/// @param distributable The total distributable amount of `token` for this round.
|
|
150
|
+
/// @param totalStakeAmount The aggregate voting power at the round's snapshot block.
|
|
151
|
+
/// @param vestingReleaseRound The round number at which the vesting period ends and tokens become fully claimable.
|
|
152
|
+
/// @return totalVestingAmount The sum of reward tokens that began vesting across all processed token IDs.
|
|
153
|
+
function _vestTokenIds(
|
|
154
|
+
address hook,
|
|
155
|
+
uint256[] calldata tokenIds,
|
|
156
|
+
IERC20 token,
|
|
157
|
+
uint256 distributable,
|
|
158
|
+
uint256 totalStakeAmount,
|
|
159
|
+
uint256 vestingReleaseRound
|
|
160
|
+
)
|
|
161
|
+
internal
|
|
162
|
+
override
|
|
163
|
+
returns (uint256 totalVestingAmount)
|
|
164
|
+
{
|
|
165
|
+
// Bundle iteration-constant parameters into a struct to avoid stack-too-deep errors.
|
|
166
|
+
VestContext memory ctx = VestContext({
|
|
167
|
+
hook: hook,
|
|
168
|
+
token: token,
|
|
169
|
+
distributable: distributable,
|
|
170
|
+
totalStakeAmount: totalStakeAmount,
|
|
171
|
+
vestingReleaseRound: vestingReleaseRound
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Allocate scratch arrays sized to the maximum possible number of distinct owners (one per token ID).
|
|
175
|
+
address[] memory owners = new address[](tokenIds.length);
|
|
176
|
+
uint256[] memory consumed = new uint256[](tokenIds.length);
|
|
177
|
+
|
|
178
|
+
// Track how many distinct owners have been recorded in the scratch arrays so far.
|
|
179
|
+
uint256 uniqueCount;
|
|
180
|
+
|
|
181
|
+
// Iterate over every token ID in the batch.
|
|
182
|
+
for (uint256 j; j < tokenIds.length;) {
|
|
183
|
+
// Vest the single token, receiving its reward amount and the updated distinct owner count.
|
|
184
|
+
(uint256 tokenAmount, uint256 newUniqueCount) = _vestSingleToken({
|
|
185
|
+
ctx: ctx, tokenId: tokenIds[j], owners: owners, consumed: consumed, uniqueCount: uniqueCount
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Carry the updated owner count forward so subsequent tokens can reference the same tracking data.
|
|
189
|
+
uniqueCount = newUniqueCount;
|
|
190
|
+
|
|
191
|
+
unchecked {
|
|
192
|
+
// Accumulate the individual token's reward into the batch-wide total.
|
|
193
|
+
totalVestingAmount += tokenAmount;
|
|
194
|
+
++j;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
//*********************************************************************//
|
|
200
|
+
// ----------------------- internal views ---------------------------- //
|
|
201
|
+
//*********************************************************************//
|
|
202
|
+
|
|
121
203
|
/// @notice Check if the account owns the given NFT token ID.
|
|
122
204
|
/// @param hook The hook the token belongs to.
|
|
123
205
|
/// @param tokenId The ID of the token to check.
|
|
@@ -127,22 +209,40 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
127
209
|
canClaim = IERC721(hook).ownerOf(tokenId) == account;
|
|
128
210
|
}
|
|
129
211
|
|
|
212
|
+
/// @notice Checks if the given token was burned.
|
|
213
|
+
/// @param hook The hook the token belongs to.
|
|
214
|
+
/// @param tokenId The tokenId to check.
|
|
215
|
+
/// @return tokenWasBurned True if the token was burned.
|
|
216
|
+
function _tokenBurned(address hook, uint256 tokenId) internal view override returns (bool tokenWasBurned) {
|
|
217
|
+
// slither-disable-next-line unused-return
|
|
218
|
+
try IERC721(hook).ownerOf(tokenId) returns (address) {
|
|
219
|
+
tokenWasBurned = false;
|
|
220
|
+
} catch {
|
|
221
|
+
tokenWasBurned = true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
130
225
|
/// @notice The stake weight of a given NFT token ID based on its tier's voting units, validated against historical
|
|
131
226
|
/// state.
|
|
132
|
-
/// @dev Returns 0 if the token's current owner had no checkpointed voting power at the round's
|
|
227
|
+
/// @dev Returns 0 if the token's current owner had no checkpointed voting power at the round's snapshot block,
|
|
133
228
|
/// preventing late mints from capturing pro-rata rewards within the current round.
|
|
134
229
|
/// @param hook The hook the token belongs to.
|
|
135
230
|
/// @param tokenId The ID of the token to get the stake weight of.
|
|
136
231
|
/// @return tokenStakeAmount The voting units of the token's tier (or 0 if ineligible).
|
|
137
232
|
function _tokenStake(address hook, uint256 tokenId) internal view override returns (uint256 tokenStakeAmount) {
|
|
138
|
-
uint256 votingUnits =
|
|
233
|
+
uint256 votingUnits =
|
|
234
|
+
IJB721TiersHook(hook)
|
|
235
|
+
.STORE()
|
|
236
|
+
.tierOfTokenId({hook: hook, tokenId: tokenId, includeResolvedUri: false})
|
|
237
|
+
.votingUnits;
|
|
139
238
|
|
|
140
|
-
// Use the checkpoints module to verify the token's owner had voting power at the round's
|
|
239
|
+
// Use the checkpoints module to verify the token's owner had voting power at the round's snapshot block.
|
|
141
240
|
// If they had no voting power at that time, this token was minted or acquired after the round started
|
|
142
241
|
// and is not eligible for this round's rewards.
|
|
143
242
|
IJB721Checkpoints checkpoints = IJB721TiersHook(hook).CHECKPOINTS();
|
|
144
243
|
address owner = IERC721(hook).ownerOf(tokenId);
|
|
145
|
-
uint256 pastVotes =
|
|
244
|
+
uint256 pastVotes =
|
|
245
|
+
IVotes(address(checkpoints)).getPastVotes({account: owner, timepoint: roundSnapshotBlock[currentRound()]});
|
|
146
246
|
|
|
147
247
|
// If the owner had no voting power at round start, the token is ineligible.
|
|
148
248
|
// slither-disable-next-line incorrect-equality
|
|
@@ -165,16 +265,130 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
165
265
|
total = IVotes(address(checkpoints)).getPastTotalSupply(blockNumber);
|
|
166
266
|
}
|
|
167
267
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
268
|
+
//*********************************************************************//
|
|
269
|
+
// ----------------------- private helpers --------------------------- //
|
|
270
|
+
//*********************************************************************//
|
|
271
|
+
|
|
272
|
+
/// @notice Vest a single NFT token, enforcing a per-owner voting power cap across the batch.
|
|
273
|
+
/// @dev Returns 0 for burned tokens, already-vested tokens, tokens whose owner had no snapshot voting power,
|
|
274
|
+
/// and tokens whose owner has already exhausted their voting power cap within this batch.
|
|
275
|
+
/// The `owners` and `consumed` arrays form a compact map that tracks how much voting power each unique
|
|
276
|
+
/// owner has consumed so far. `uniqueCount` tracks how many slots are used.
|
|
277
|
+
/// @param ctx The vesting context containing hook address, reward token, distributable amount, total stake,
|
|
278
|
+
/// and release round.
|
|
279
|
+
/// @param tokenId The NFT token ID to process.
|
|
280
|
+
/// @param owners A scratch array mapping slot indices to owner addresses for deduplication within this batch.
|
|
281
|
+
/// @param consumed A scratch array tracking how much voting power each owner (by slot index) has consumed.
|
|
282
|
+
/// @param uniqueCount The number of distinct owners seen so far in the batch.
|
|
283
|
+
/// @return tokenAmount The reward amount vested for this token ID (0 if skipped).
|
|
284
|
+
/// @return newUniqueCount The updated count of distinct owners after processing this token ID.
|
|
285
|
+
// slither-disable-next-line incorrect-equality
|
|
286
|
+
function _vestSingleToken(
|
|
287
|
+
VestContext memory ctx,
|
|
288
|
+
uint256 tokenId,
|
|
289
|
+
address[] memory owners,
|
|
290
|
+
uint256[] memory consumed,
|
|
291
|
+
uint256 uniqueCount
|
|
292
|
+
)
|
|
293
|
+
private
|
|
294
|
+
returns (uint256 tokenAmount, uint256 newUniqueCount)
|
|
295
|
+
{
|
|
296
|
+
// Initialize the return value to the current count of distinct owners.
|
|
297
|
+
newUniqueCount = uniqueCount;
|
|
298
|
+
|
|
299
|
+
// Skip burned tokens — they are excluded from _totalStake, so including them would overbook vesting.
|
|
300
|
+
if (_tokenBurned({hook: ctx.hook, tokenId: tokenId})) return (0, newUniqueCount);
|
|
301
|
+
|
|
302
|
+
// Skip already-vested tokenIds — check if the last vesting entry targets the same release round.
|
|
303
|
+
{
|
|
304
|
+
// Load the number of existing vesting entries for this token.
|
|
305
|
+
uint256 numVesting = vestingDataOf[ctx.hook][tokenId][ctx.token].length;
|
|
306
|
+
|
|
307
|
+
// If at least one entry exists and its release round matches, this token was already vested this round.
|
|
308
|
+
if (
|
|
309
|
+
numVesting != 0
|
|
310
|
+
&& vestingDataOf[ctx.hook][tokenId][ctx.token][numVesting - 1].releaseRound
|
|
311
|
+
== ctx.vestingReleaseRound
|
|
312
|
+
) {
|
|
313
|
+
return (0, newUniqueCount);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Look up the NFT's voting units from its tier in the hook's store.
|
|
318
|
+
uint256 votingUnits =
|
|
319
|
+
IJB721TiersHook(ctx.hook)
|
|
320
|
+
.STORE()
|
|
321
|
+
.tierOfTokenId({hook: ctx.hook, tokenId: tokenId, includeResolvedUri: false})
|
|
322
|
+
.votingUnits;
|
|
323
|
+
|
|
324
|
+
// Look up the owner, verify snapshot eligibility, and find or create the owner's tracking slot.
|
|
325
|
+
uint256 ownerIndex;
|
|
326
|
+
uint256 pastVotes;
|
|
327
|
+
{
|
|
328
|
+
// Get the current owner of the NFT.
|
|
329
|
+
address owner = IERC721(ctx.hook).ownerOf(tokenId);
|
|
330
|
+
|
|
331
|
+
// Query the owner's checkpointed voting power at the round's snapshot block.
|
|
332
|
+
pastVotes = IVotes(address(IJB721TiersHook(ctx.hook).CHECKPOINTS()))
|
|
333
|
+
.getPastVotes({account: owner, timepoint: roundSnapshotBlock[currentRound()]});
|
|
334
|
+
|
|
335
|
+
// If the owner had no voting power at round start, the token is ineligible for this round.
|
|
336
|
+
// slither-disable-next-line incorrect-equality
|
|
337
|
+
if (pastVotes == 0) return (0, newUniqueCount);
|
|
338
|
+
|
|
339
|
+
// Search the owners array for an existing slot belonging to this owner.
|
|
340
|
+
bool found;
|
|
341
|
+
for (uint256 k; k < newUniqueCount;) {
|
|
342
|
+
if (owners[k] == owner) {
|
|
343
|
+
// Re-use the existing tracking slot for this owner.
|
|
344
|
+
ownerIndex = k;
|
|
345
|
+
found = true;
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
unchecked {
|
|
349
|
+
++k;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// If no existing slot was found, allocate a new one at the end of the arrays.
|
|
354
|
+
if (!found) {
|
|
355
|
+
ownerIndex = newUniqueCount;
|
|
356
|
+
owners[newUniqueCount] = owner;
|
|
357
|
+
unchecked {
|
|
358
|
+
++newUniqueCount;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Cap this NFT's effective stake at the owner's remaining voting power budget for this batch.
|
|
364
|
+
uint256 stake;
|
|
365
|
+
{
|
|
366
|
+
// Calculate how much voting power the owner has left after prior tokens in this batch.
|
|
367
|
+
uint256 remaining = pastVotes > consumed[ownerIndex] ? pastVotes - consumed[ownerIndex] : 0;
|
|
368
|
+
|
|
369
|
+
// The effective stake is the lesser of the NFT's voting units and the owner's remaining budget.
|
|
370
|
+
stake = votingUnits < remaining ? votingUnits : remaining;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Record that this owner has consumed additional voting power from their budget.
|
|
374
|
+
consumed[ownerIndex] += stake;
|
|
375
|
+
|
|
376
|
+
// If the effective stake is zero, the owner's budget is exhausted — skip this token.
|
|
377
|
+
// slither-disable-next-line incorrect-equality
|
|
378
|
+
if (stake == 0) return (0, newUniqueCount);
|
|
379
|
+
|
|
380
|
+
// Calculate the pro-rata reward amount: (distributable * stake) / totalStakeAmount.
|
|
381
|
+
tokenAmount = mulDiv({x: ctx.distributable, y: stake, denominator: ctx.totalStakeAmount});
|
|
382
|
+
|
|
383
|
+
// Only create a vesting entry and emit an event if there is a non-zero reward.
|
|
384
|
+
if (tokenAmount > 0) {
|
|
385
|
+
// Push a new vesting data entry for this token ID, starting with zero shareClaimed.
|
|
386
|
+
vestingDataOf[ctx.hook][tokenId][ctx.token].push(
|
|
387
|
+
JBVestingData({releaseRound: ctx.vestingReleaseRound, amount: tokenAmount, shareClaimed: 0})
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
// Emit the claim event for off-chain indexers.
|
|
391
|
+
emit Claimed(ctx.hook, tokenId, ctx.token, tokenAmount, ctx.vestingReleaseRound);
|
|
178
392
|
}
|
|
179
393
|
}
|
|
180
394
|
}
|