@bananapus/distributor-v6 0.0.3 → 0.0.5

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.
@@ -13,7 +13,7 @@ What new trust boundary, failure mode, operational dependency, or integration ca
13
13
  - [ ] Updated `/v6/evm/RISKS.md` because ecosystem behavior changed
14
14
  - [ ] If no `RISKS.md` update was needed, I explained why in this PR
15
15
 
16
- Reference: [`/v6/evm/docs/RISKS_MAINTENANCE.md`](../docs/RISKS_MAINTENANCE.md)
16
+ Reference: [`/v6/evm/RISKS_MAINTENANCE.md`](../../RISKS_MAINTENANCE.md)
17
17
 
18
18
  ## Checklist
19
19
 
@@ -22,5 +22,7 @@ jobs:
22
22
  uses: foundry-rs/foundry-toolchain@v1
23
23
  - name: Run tests
24
24
  run: forge test --fail-fast --summary --detailed --skip "*/script/**"
25
+ env:
26
+ RPC_ETHEREUM_MAINNET: ${{ secrets.RPC_ETHEREUM_MAINNET }}
25
27
  - name: Check contract sizes
26
28
  run: forge build --sizes --skip "*/test/**" --skip "*/script/**" --skip SphinxUtils
package/USER_JOURNEYS.md CHANGED
@@ -55,6 +55,8 @@ This repo distributes already-owned assets over time. It snapshots stake, starts
55
55
  2. The distributor snapshots the relevant balance and stake source.
56
56
  3. Vesting entries become claimable over the configured schedule.
57
57
 
58
+ **Snapshot timing:** The snapshot for each round is taken when the previous round first sees activity (`poke`, `beginVesting`, or `collectVestedRewards`). To be included in round N's distribution, make sure your tokens are held and delegated before anyone interacts with round N-1. In practice, keep your delegation current — if it is set before the previous round's activity begins, your voting power will be counted for the next round.
59
+
58
60
  **Failure Modes**
59
61
  - zero total stake
60
62
  - bad deployment parameters such as zero round duration or zero vesting rounds
package/foundry.lock ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "lib/forge-std": {
3
+ "rev": "f494b0c2c045dda3df3d761bc82209b9a015c4e7"
4
+ }
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/distributor-v6",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,9 +17,9 @@
17
17
  "deploy:testnets": "source ./.env && npx sphinx propose ./script/Deploy.s.sol --networks testnets"
18
18
  },
19
19
  "dependencies": {
20
- "@bananapus/721-hook-v6": "^0.0.36",
21
- "@bananapus/core-v6": "^0.0.34",
22
- "@bananapus/permission-ids-v6": "^0.0.17",
20
+ "@bananapus/721-hook-v6": "^0.0.38",
21
+ "@bananapus/core-v6": "^0.0.36",
22
+ "@bananapus/permission-ids-v6": "^0.0.19",
23
23
  "@openzeppelin/contracts": "^5.6.1",
24
24
  "@prb/math": "^4.1.0"
25
25
  },
@@ -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 minimum amount of time stakers have to claim rewards, specified in blocks.
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, check if the caller approved tokens (terminal pattern).
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 pattern: pull tokens via transferFrom.
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 start block,
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 = IJB721TiersHook(hook).STORE().tierOfTokenId(hook, tokenId, false).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 start block.
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 = IVotes(address(checkpoints)).getPastVotes(owner, roundStartBlock(currentRound()));
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
- /// @notice Checks if the given token was burned.
169
- /// @param hook The hook the token belongs to.
170
- /// @param tokenId The tokenId to check.
171
- /// @return tokenWasBurned True if the token was burned.
172
- function _tokenBurned(address hook, uint256 tokenId) internal view override returns (bool tokenWasBurned) {
173
- // slither-disable-next-line unused-return
174
- try IERC721(hook).ownerOf(tokenId) returns (address) {
175
- tokenWasBurned = false;
176
- } catch {
177
- tokenWasBurned = true;
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
  }