@bananapus/distributor-v6 0.0.19 → 0.0.22
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/package.json +4 -4
- package/src/JB721Distributor.sol +49 -41
- package/src/JBDistributor.sol +104 -40
- package/src/JBTokenDistributor.sol +25 -18
- package/src/libraries/JBVestingMath.sol +74 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/distributor-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"deploy:testnets": "source ./.env && npx sphinx propose ./script/Deploy.s.sol --networks testnets"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@bananapus/721-hook-v6": "^0.0.
|
|
30
|
-
"@bananapus/core-v6": "^0.0.
|
|
31
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
29
|
+
"@bananapus/721-hook-v6": "^0.0.54",
|
|
30
|
+
"@bananapus/core-v6": "^0.0.57",
|
|
31
|
+
"@bananapus/permission-ids-v6": "^0.0.26",
|
|
32
32
|
"@openzeppelin/contracts": "5.6.1",
|
|
33
33
|
"@prb/math": "4.1.1"
|
|
34
34
|
},
|
package/src/JB721Distributor.sol
CHANGED
|
@@ -9,7 +9,6 @@ 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
11
|
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
12
|
-
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
13
12
|
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
14
13
|
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
|
|
15
14
|
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
|
|
@@ -27,12 +26,13 @@ import {JBVestingData} from "./structs/JBVestingData.sol";
|
|
|
27
26
|
/// calculation and their unvested rewards can be reclaimed via `releaseForfeitedRewards`.
|
|
28
27
|
/// @dev Implements `IJBSplitHook` so it can receive tokens directly from Juicebox project payout splits.
|
|
29
28
|
contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
30
|
-
using SafeERC20 for IERC20;
|
|
31
|
-
|
|
32
29
|
//*********************************************************************//
|
|
33
30
|
// --------------------------- custom errors ------------------------- //
|
|
34
31
|
//*********************************************************************//
|
|
35
32
|
|
|
33
|
+
/// @notice Thrown when native ETH does not match the split hook context amount.
|
|
34
|
+
error JB721Distributor_NativeAmountMismatch(uint256 msgValue, uint256 contextAmount);
|
|
35
|
+
|
|
36
36
|
/// @notice Thrown when native ETH is sent but context.token is not NATIVE_TOKEN.
|
|
37
37
|
error JB721Distributor_TokenMismatch(address token, address expectedToken, uint256 msgValue);
|
|
38
38
|
|
|
@@ -116,25 +116,32 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
116
116
|
// The target hook is the split's beneficiary.
|
|
117
117
|
address hook = address(context.split.beneficiary);
|
|
118
118
|
|
|
119
|
-
//
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
119
|
+
// Native splits must conserve the terminal's stated context amount exactly.
|
|
120
|
+
if (context.token == JBConstants.NATIVE_TOKEN) {
|
|
121
|
+
if (msg.value != context.amount) {
|
|
122
|
+
revert JB721Distributor_NativeAmountMismatch({msgValue: msg.value, contextAmount: context.amount});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (msg.value != 0) {
|
|
126
|
+
_balanceOf[hook][IERC20(context.token)] += msg.value;
|
|
127
|
+
_accountedBalanceOf[IERC20(context.token)] += msg.value;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
// Validate that native ETH is not cross-booked under an ERC-20 token.
|
|
131
|
+
if (msg.value != 0) {
|
|
131
132
|
revert JB721Distributor_TokenMismatch({
|
|
132
133
|
token: context.token, expectedToken: JBConstants.NATIVE_TOKEN, msgValue: msg.value
|
|
133
134
|
});
|
|
134
135
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
|
|
137
|
+
if (context.amount == 0) return;
|
|
138
|
+
|
|
139
|
+
// Pull tokens via transferFrom. Both terminals and controllers grant an ERC-20
|
|
140
|
+
// allowance before calling. Balance delta handles fee-on-transfer tokens correctly.
|
|
141
|
+
uint256 delta =
|
|
142
|
+
_acceptErc20FundsFrom({token: IERC20(context.token), from: msg.sender, amount: context.amount});
|
|
143
|
+
_balanceOf[hook][IERC20(context.token)] += delta;
|
|
144
|
+
_accountedBalanceOf[IERC20(context.token)] += delta;
|
|
138
145
|
}
|
|
139
146
|
}
|
|
140
147
|
|
|
@@ -266,7 +273,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
266
273
|
|
|
267
274
|
// Use the checkpoints module to verify the token's snapshot owner had voting power at the round's snapshot
|
|
268
275
|
// block. If the token did not exist then, ownerOfAt returns zero above and the token is not eligible.
|
|
269
|
-
uint256 pastVotes = IVotes(address(IJB721TiersHook(hook).
|
|
276
|
+
uint256 pastVotes = IVotes(address(IJB721TiersHook(hook).checkpoints()))
|
|
270
277
|
.getPastVotes({account: owner, timepoint: snapshotBlock});
|
|
271
278
|
|
|
272
279
|
// If the owner had no voting power at round start, the token is ineligible.
|
|
@@ -278,14 +285,14 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
278
285
|
}
|
|
279
286
|
|
|
280
287
|
/// @notice The total stake at a specific block, using the hook's checkpoints module for historical accuracy.
|
|
281
|
-
/// @dev Uses `IVotes.getPastTotalSupply` from the hook's
|
|
288
|
+
/// @dev Uses `IVotes.getPastTotalSupply` from the hook's checkpoints module. This ensures that only NFTs
|
|
282
289
|
/// that existed (and were delegated) at `blockNumber` are counted, preventing late mints from diluting or
|
|
283
290
|
/// capturing rewards within the current round.
|
|
284
291
|
/// @param hook The hook to get the total stake for.
|
|
285
292
|
/// @param blockNumber The block number to get the total staked amount at.
|
|
286
293
|
/// @return total The total checkpointed voting units at the given block.
|
|
287
294
|
function _totalStake(address hook, uint256 blockNumber) internal view override returns (uint256 total) {
|
|
288
|
-
IJB721Checkpoints checkpoints = IJB721TiersHook(hook).
|
|
295
|
+
IJB721Checkpoints checkpoints = IJB721TiersHook(hook).checkpoints();
|
|
289
296
|
total = IVotes(address(checkpoints)).getPastTotalSupply(blockNumber);
|
|
290
297
|
}
|
|
291
298
|
|
|
@@ -311,7 +318,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
311
318
|
returns (address owner)
|
|
312
319
|
{
|
|
313
320
|
// The 721 hook owns the checkpoint module; the distributor only trusts that module's historical proof.
|
|
314
|
-
IJB721Checkpoints checkpoints = IJB721TiersHook(hook).
|
|
321
|
+
IJB721Checkpoints checkpoints = IJB721TiersHook(hook).checkpoints();
|
|
315
322
|
|
|
316
323
|
// Use staticcall so older hooks without `ownerOfAt` fail closed instead of reverting the whole distribution.
|
|
317
324
|
(bool success, bytes memory data) =
|
|
@@ -382,7 +389,7 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
382
389
|
if (owner == address(0)) return (0, newUniqueCount);
|
|
383
390
|
|
|
384
391
|
// Query the owner's checkpointed voting power at the round's snapshot block.
|
|
385
|
-
pastVotes = IVotes(address(IJB721TiersHook(ctx.hook).
|
|
392
|
+
pastVotes = IVotes(address(IJB721TiersHook(ctx.hook).checkpoints()))
|
|
386
393
|
.getPastVotes({account: owner, timepoint: snapshotBlock});
|
|
387
394
|
|
|
388
395
|
// If the snapshot owner had no voting power at round start, the token is ineligible for this round.
|
|
@@ -424,30 +431,31 @@ contract JB721Distributor is JBDistributor, IJB721Distributor {
|
|
|
424
431
|
stake = votingUnits < remaining ? votingUnits : remaining;
|
|
425
432
|
}
|
|
426
433
|
|
|
427
|
-
// Record that this owner has consumed additional voting power from their budget.
|
|
428
|
-
consumed[ownerIndex] += stake;
|
|
429
|
-
|
|
430
434
|
// If the effective stake is zero, the owner's budget is exhausted — skip this token.
|
|
431
435
|
if (stake == 0) return (0, newUniqueCount);
|
|
432
436
|
|
|
433
437
|
// Calculate the pro-rata reward amount: (distributable * stake) / totalStakeAmount.
|
|
434
438
|
tokenAmount = mulDiv({x: ctx.distributable, y: stake, denominator: ctx.totalStakeAmount});
|
|
435
439
|
|
|
440
|
+
// If the pro-rata amount rounds to zero, do not consume the owner's voting budget.
|
|
441
|
+
if (tokenAmount == 0) return (0, newUniqueCount);
|
|
442
|
+
|
|
443
|
+
// Record that this owner has consumed additional voting power from their budget.
|
|
444
|
+
consumed[ownerIndex] += stake;
|
|
445
|
+
|
|
436
446
|
// Only create a vesting entry and emit an event if there is a non-zero reward.
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
});
|
|
451
|
-
}
|
|
447
|
+
// Push a new vesting data entry for this token ID, starting with zero shareClaimed.
|
|
448
|
+
vestingDataOf[ctx.hook][tokenId][ctx.token].push(
|
|
449
|
+
JBVestingData({releaseRound: ctx.vestingReleaseRound, amount: tokenAmount, shareClaimed: 0})
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Emit the claim event for off-chain indexers.
|
|
453
|
+
emit Claimed({
|
|
454
|
+
hook: ctx.hook,
|
|
455
|
+
tokenId: tokenId,
|
|
456
|
+
token: ctx.token,
|
|
457
|
+
amount: tokenAmount,
|
|
458
|
+
vestingReleaseRound: ctx.vestingReleaseRound
|
|
459
|
+
});
|
|
452
460
|
}
|
|
453
461
|
}
|
package/src/JBDistributor.sol
CHANGED
|
@@ -7,6 +7,7 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol
|
|
|
7
7
|
import {mulDiv} from "@prb/math/src/Common.sol";
|
|
8
8
|
|
|
9
9
|
import {IJBDistributor} from "./interfaces/IJBDistributor.sol";
|
|
10
|
+
import {JBVestingMath} from "./libraries/JBVestingMath.sol";
|
|
10
11
|
import {JBTokenSnapshotData} from "./structs/JBTokenSnapshotData.sol";
|
|
11
12
|
import {JBVestingData} from "./structs/JBVestingData.sol";
|
|
12
13
|
|
|
@@ -27,18 +28,21 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
27
28
|
/// @notice Thrown when an empty tokenIds array is passed.
|
|
28
29
|
error JBDistributor_EmptyTokenIds(uint256 tokenIdCount);
|
|
29
30
|
|
|
31
|
+
/// @notice Thrown when the round duration is zero.
|
|
32
|
+
error JBDistributor_InvalidRoundDuration(uint256 roundDuration);
|
|
33
|
+
|
|
30
34
|
/// @notice Thrown when a native ETH transfer fails.
|
|
31
35
|
error JBDistributor_NativeTransferFailed(address beneficiary, uint256 amount);
|
|
32
36
|
|
|
33
37
|
/// @notice Thrown when the caller does not have access to the token.
|
|
34
38
|
error JBDistributor_NoAccess(address hook, uint256 tokenId, address account);
|
|
35
39
|
|
|
36
|
-
/// @notice Thrown when the round duration is zero.
|
|
37
|
-
error JBDistributor_InvalidRoundDuration(uint256 roundDuration);
|
|
38
|
-
|
|
39
40
|
/// @notice Thrown when there is nothing to distribute for a token in the current round.
|
|
40
41
|
error JBDistributor_NothingToDistribute(address hook, address token, uint256 round);
|
|
41
42
|
|
|
43
|
+
/// @notice Thrown when an ERC-20 reenters a funding balance-delta measurement.
|
|
44
|
+
error JBDistributor_ReentrantTokenTransfer(address token);
|
|
45
|
+
|
|
42
46
|
/// @notice Thrown when unexpected native ETH is sent with an ERC-20 operation.
|
|
43
47
|
error JBDistributor_UnexpectedNativeValue(uint256 msgValue, address token);
|
|
44
48
|
|
|
@@ -115,6 +119,13 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
115
119
|
/// @custom:param round The round to which the data applies.
|
|
116
120
|
mapping(address hook => mapping(IERC20 token => mapping(uint256 round => bool))) internal _snapshotInitializedFor;
|
|
117
121
|
|
|
122
|
+
//*********************************************************************//
|
|
123
|
+
// ------------------- transient stored properties ------------------- //
|
|
124
|
+
//*********************************************************************//
|
|
125
|
+
|
|
126
|
+
/// @notice The ERC-20 whose incoming balance delta is currently being measured.
|
|
127
|
+
address transient _acceptingToken;
|
|
128
|
+
|
|
118
129
|
//*********************************************************************//
|
|
119
130
|
// -------------------------- constructor ---------------------------- //
|
|
120
131
|
//*********************************************************************//
|
|
@@ -141,6 +152,11 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
141
152
|
/// @param tokenIds The staker token IDs to claim rewards for.
|
|
142
153
|
/// @param tokens The reward tokens to begin vesting.
|
|
143
154
|
function beginVesting(address hook, uint256[] calldata tokenIds, IERC20[] calldata tokens) external override {
|
|
155
|
+
// Reward accounting cannot change while an ERC-20 `transferFrom` is in progress. A callback-capable reward
|
|
156
|
+
// token could otherwise snapshot, vest, or collect against balances between `balanceBefore` and
|
|
157
|
+
// `balanceAfter`, distorting the delta credited to the funder.
|
|
158
|
+
_requireNotAcceptingToken();
|
|
159
|
+
|
|
144
160
|
// Revert if no token IDs are provided.
|
|
145
161
|
if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
|
|
146
162
|
|
|
@@ -203,9 +219,7 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
203
219
|
revert JBDistributor_UnexpectedNativeValue({msgValue: msg.value, token: address(token)});
|
|
204
220
|
}
|
|
205
221
|
// Use balance delta to handle fee-on-transfer tokens correctly.
|
|
206
|
-
|
|
207
|
-
token.safeTransferFrom({from: msg.sender, to: address(this), value: amount});
|
|
208
|
-
amount = token.balanceOf(address(this)) - balanceBefore;
|
|
222
|
+
amount = _acceptErc20FundsFrom({token: token, from: msg.sender, amount: amount});
|
|
209
223
|
}
|
|
210
224
|
_balanceOf[hook][token] += amount;
|
|
211
225
|
_accountedBalanceOf[token] += amount;
|
|
@@ -233,6 +247,9 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
233
247
|
external
|
|
234
248
|
override
|
|
235
249
|
{
|
|
250
|
+
// Do not let reward-token callbacks mutate vesting state during inbound balance-delta accounting.
|
|
251
|
+
_requireNotAcceptingToken();
|
|
252
|
+
|
|
236
253
|
// Make sure that all tokens are burned.
|
|
237
254
|
for (uint256 i; i < tokenIds.length;) {
|
|
238
255
|
if (!_tokenBurned({hook: hook, tokenId: tokenIds[i]})) {
|
|
@@ -285,7 +302,9 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
285
302
|
JBVestingData memory vesting = vestingDataOf[hook][tokenId][token][vestedIndex];
|
|
286
303
|
|
|
287
304
|
// Use `original - alreadyPaid` to include rounding dust in the remaining amount.
|
|
288
|
-
tokenAmount +=
|
|
305
|
+
tokenAmount += JBVestingMath.unclaimedAmountOf({
|
|
306
|
+
amount: vesting.amount, shareClaimed: vesting.shareClaimed, maxShare: MAX_SHARE
|
|
307
|
+
});
|
|
289
308
|
|
|
290
309
|
unchecked {
|
|
291
310
|
++vestedIndex;
|
|
@@ -324,23 +343,22 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
324
343
|
// Keep a reference to the vested data being iterated on.
|
|
325
344
|
JBVestingData memory vesting = vestingDataOf[hook][tokenId][token][vestedIndex];
|
|
326
345
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
346
|
+
lockedShare = JBVestingMath.lockedShareOf({
|
|
347
|
+
releaseRound: vesting.releaseRound,
|
|
348
|
+
currentRound: round,
|
|
349
|
+
vestingRounds: vestingRounds,
|
|
350
|
+
maxShare: MAX_SHARE
|
|
351
|
+
});
|
|
331
352
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
}
|
|
353
|
+
// Calculate the newly unlocked amount from cumulative shares rather than the incremental share delta.
|
|
354
|
+
// Incremental floor rounding can otherwise underpay partial collections and leave dust stranded.
|
|
355
|
+
(uint256 claimAmount,) = JBVestingMath.newlyClaimableAmountOf({
|
|
356
|
+
amount: vesting.amount,
|
|
357
|
+
shareClaimed: vesting.shareClaimed,
|
|
358
|
+
lockedShare: lockedShare,
|
|
359
|
+
maxShare: MAX_SHARE
|
|
360
|
+
});
|
|
361
|
+
tokenAmount += claimAmount;
|
|
344
362
|
|
|
345
363
|
unchecked {
|
|
346
364
|
++vestedIndex;
|
|
@@ -400,6 +418,10 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
400
418
|
public
|
|
401
419
|
override
|
|
402
420
|
{
|
|
421
|
+
// Collections transfer reward tokens out. If this runs inside the same reward token's inbound transfer, the
|
|
422
|
+
// outgoing transfer can net against the incoming balance delta and strand the new funds unaccounted.
|
|
423
|
+
_requireNotAcceptingToken();
|
|
424
|
+
|
|
403
425
|
// Revert if no token IDs are provided.
|
|
404
426
|
if (tokenIds.length == 0) revert JBDistributor_EmptyTokenIds({tokenIdCount: tokenIds.length});
|
|
405
427
|
|
|
@@ -459,6 +481,40 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
459
481
|
// ---------------------- internal transactions ---------------------- //
|
|
460
482
|
//*********************************************************************//
|
|
461
483
|
|
|
484
|
+
/// @notice Accepts an ERC-20 funding transfer and returns the actual balance delta.
|
|
485
|
+
/// @param token The ERC-20 token to accept.
|
|
486
|
+
/// @param from The address to pull tokens from.
|
|
487
|
+
/// @param amount The nominal amount to pull.
|
|
488
|
+
/// @return acceptedAmount The actual amount received.
|
|
489
|
+
function _acceptErc20FundsFrom(
|
|
490
|
+
IERC20 token,
|
|
491
|
+
address from,
|
|
492
|
+
uint256 amount
|
|
493
|
+
)
|
|
494
|
+
internal
|
|
495
|
+
returns (uint256 acceptedAmount)
|
|
496
|
+
{
|
|
497
|
+
// Arm the scoped guard before any token call, including `balanceOf`, because reward tokens are arbitrary and
|
|
498
|
+
// an upgradeable or adversarial token can reenter from either the snapshot or transfer path.
|
|
499
|
+
address tokenBeingAccepted = _acceptingToken;
|
|
500
|
+
if (tokenBeingAccepted != address(0)) revert JBDistributor_ReentrantTokenTransfer(tokenBeingAccepted);
|
|
501
|
+
_acceptingToken = address(token);
|
|
502
|
+
|
|
503
|
+
// Snapshot this contract's token balance after the guard is armed so fee-on-transfer tokens are credited by the
|
|
504
|
+
// actual amount received instead of the caller-provided nominal `amount`.
|
|
505
|
+
uint256 balanceBefore = token.balanceOf(address(this));
|
|
506
|
+
|
|
507
|
+
// Pull the nominal amount from the funder; SafeERC20 handles tokens that do not return a boolean.
|
|
508
|
+
token.safeTransferFrom({from: from, to: address(this), value: amount});
|
|
509
|
+
|
|
510
|
+
// Credit only the balance delta. This supports fee-on-transfer tokens and ignores any overstatement in
|
|
511
|
+
// `amount`.
|
|
512
|
+
acceptedAmount = token.balanceOf(address(this)) - balanceBefore;
|
|
513
|
+
|
|
514
|
+
// Close the transfer window after the token balance has been measured.
|
|
515
|
+
_acceptingToken = address(0);
|
|
516
|
+
}
|
|
517
|
+
|
|
462
518
|
/// @notice Ensures that a snapshot block is recorded for the given round.
|
|
463
519
|
/// @dev Uses `block.number - 1` because `IVotes.getPastVotes` requires a strictly past block.
|
|
464
520
|
/// @param round The round to ensure a snapshot block for.
|
|
@@ -592,26 +648,26 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
592
648
|
// Keep a reference to the vested data being iterated on.
|
|
593
649
|
JBVestingData memory vesting = vestings[vestedIndex];
|
|
594
650
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
uint256 claimAmount;
|
|
651
|
+
uint256 lockedShare = JBVestingMath.lockedShareOf({
|
|
652
|
+
releaseRound: vesting.releaseRound,
|
|
653
|
+
currentRound: round,
|
|
654
|
+
vestingRounds: vestingRounds,
|
|
655
|
+
maxShare: MAX_SHARE
|
|
656
|
+
});
|
|
602
657
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
658
|
+
// Match `claimedFor`/`collectableFor` by using the difference between cumulative rounded claims.
|
|
659
|
+
// Rounding each incremental share independently can underpay partial unlocks and leave
|
|
660
|
+
// `totalVestingAmountOf` larger than the remaining claims.
|
|
661
|
+
(uint256 claimAmount,) = JBVestingMath.newlyClaimableAmountOf({
|
|
662
|
+
amount: vesting.amount,
|
|
663
|
+
shareClaimed: vesting.shareClaimed,
|
|
664
|
+
lockedShare: lockedShare,
|
|
665
|
+
maxShare: MAX_SHARE
|
|
666
|
+
});
|
|
613
667
|
|
|
614
668
|
if (claimAmount != 0) {
|
|
669
|
+
// Persist the cumulative unlocked share, not just this round's delta, so later collections
|
|
670
|
+
// compare against the same rounded checkpoint that produced `claimAmount`.
|
|
615
671
|
vestings[vestedIndex].shareClaimed = MAX_SHARE - lockedShare;
|
|
616
672
|
totalTokenAmount += claimAmount;
|
|
617
673
|
emit Collected({
|
|
@@ -732,6 +788,14 @@ abstract contract JBDistributor is IJBDistributor {
|
|
|
732
788
|
/// @return canClaim True if the account can collect rewards for this token ID.
|
|
733
789
|
function _canClaim(address hook, uint256 tokenId, address account) internal view virtual returns (bool canClaim);
|
|
734
790
|
|
|
791
|
+
/// @notice Revert if called while an inbound ERC-20 transfer is being measured.
|
|
792
|
+
/// @dev Reward tokens are arbitrary contracts. This guard prevents token callbacks from mutating distributor
|
|
793
|
+
/// accounting midway through a balance-delta measurement.
|
|
794
|
+
function _requireNotAcceptingToken() internal view {
|
|
795
|
+
address token = _acceptingToken;
|
|
796
|
+
if (token != address(0)) revert JBDistributor_ReentrantTokenTransfer(token);
|
|
797
|
+
}
|
|
798
|
+
|
|
735
799
|
/// @notice Check whether a staker token has been burned. Burned tokens are excluded from stake calculations
|
|
736
800
|
/// and their unvested rewards can be released via `releaseForfeitedRewards`.
|
|
737
801
|
/// @param hook The hook the token belongs to.
|
|
@@ -8,7 +8,6 @@ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
|
8
8
|
import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
|
|
9
9
|
import {IVotes} from "@openzeppelin/contracts/governance/utils/IVotes.sol";
|
|
10
10
|
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
11
|
-
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
|
|
12
11
|
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
13
12
|
|
|
14
13
|
import {IJBTokenDistributor} from "./interfaces/IJBTokenDistributor.sol";
|
|
@@ -22,8 +21,6 @@ import {JBDistributor} from "./JBDistributor.sol";
|
|
|
22
21
|
/// Holders must delegate (even to themselves) to participate. Non-delegated supply stays in pool for future rounds.
|
|
23
22
|
/// @dev Implements `IJBSplitHook` so it can receive tokens directly from Juicebox project payout splits.
|
|
24
23
|
contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
|
|
25
|
-
using SafeERC20 for IERC20;
|
|
26
|
-
|
|
27
24
|
//*********************************************************************//
|
|
28
25
|
// --------------------------- custom errors ------------------------- //
|
|
29
26
|
//*********************************************************************//
|
|
@@ -31,6 +28,9 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
|
|
|
31
28
|
/// @notice Thrown when a tokenId has non-zero upper bits (above 160), which would alias to the same staker address.
|
|
32
29
|
error JBTokenDistributor_InvalidTokenId(uint256 tokenId);
|
|
33
30
|
|
|
31
|
+
/// @notice Thrown when native ETH does not match the split hook context amount.
|
|
32
|
+
error JBTokenDistributor_NativeAmountMismatch(uint256 msgValue, uint256 contextAmount);
|
|
33
|
+
|
|
34
34
|
/// @notice Thrown when native ETH is sent but context.token is not NATIVE_TOKEN.
|
|
35
35
|
error JBTokenDistributor_TokenMismatch(address token, address expectedToken, uint256 msgValue);
|
|
36
36
|
|
|
@@ -86,25 +86,32 @@ contract JBTokenDistributor is JBDistributor, IJBTokenDistributor {
|
|
|
86
86
|
// The target hook is the split's beneficiary (the IVotes token address).
|
|
87
87
|
address hook = address(context.split.beneficiary);
|
|
88
88
|
|
|
89
|
-
//
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
89
|
+
// Native splits must conserve the terminal's stated context amount exactly.
|
|
90
|
+
if (context.token == JBConstants.NATIVE_TOKEN) {
|
|
91
|
+
if (msg.value != context.amount) {
|
|
92
|
+
revert JBTokenDistributor_NativeAmountMismatch({msgValue: msg.value, contextAmount: context.amount});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (msg.value != 0) {
|
|
96
|
+
_balanceOf[hook][IERC20(context.token)] += msg.value;
|
|
97
|
+
_accountedBalanceOf[IERC20(context.token)] += msg.value;
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
// Validate that native ETH is not cross-booked under an ERC-20 token.
|
|
101
|
+
if (msg.value != 0) {
|
|
101
102
|
revert JBTokenDistributor_TokenMismatch({
|
|
102
103
|
token: context.token, expectedToken: JBConstants.NATIVE_TOKEN, msgValue: msg.value
|
|
103
104
|
});
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
|
|
107
|
+
if (context.amount == 0) return;
|
|
108
|
+
|
|
109
|
+
// Pull tokens via transferFrom. Both terminals and controllers grant an ERC-20
|
|
110
|
+
// allowance before calling. Balance delta handles fee-on-transfer tokens correctly.
|
|
111
|
+
uint256 delta =
|
|
112
|
+
_acceptErc20FundsFrom({token: IERC20(context.token), from: msg.sender, amount: context.amount});
|
|
113
|
+
_balanceOf[hook][IERC20(context.token)] += delta;
|
|
114
|
+
_accountedBalanceOf[IERC20(context.token)] += delta;
|
|
108
115
|
}
|
|
109
116
|
}
|
|
110
117
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {mulDiv} from "@prb/math/src/Common.sol";
|
|
5
|
+
|
|
6
|
+
/// @notice Pure helpers for the distributor's linear vesting arithmetic.
|
|
7
|
+
library JBVestingMath {
|
|
8
|
+
/// @notice The share still locked for a vesting entry at `currentRound`.
|
|
9
|
+
/// @dev Mirrors the distributor's existing linear vesting formula. Callers maintain the invariant that
|
|
10
|
+
/// `releaseRound - currentRound <= vestingRounds` whenever `releaseRound > currentRound`.
|
|
11
|
+
/// @param releaseRound The round when the entry is fully unlocked.
|
|
12
|
+
/// @param currentRound The current distributor round.
|
|
13
|
+
/// @param vestingRounds The number of rounds in the vesting period.
|
|
14
|
+
/// @param maxShare The share value representing 100%.
|
|
15
|
+
/// @return lockedShare The share of the entry still locked.
|
|
16
|
+
function lockedShareOf(
|
|
17
|
+
uint256 releaseRound,
|
|
18
|
+
uint256 currentRound,
|
|
19
|
+
uint256 vestingRounds,
|
|
20
|
+
uint256 maxShare
|
|
21
|
+
)
|
|
22
|
+
internal
|
|
23
|
+
pure
|
|
24
|
+
returns (uint256 lockedShare)
|
|
25
|
+
{
|
|
26
|
+
if (releaseRound > currentRound) lockedShare = (releaseRound - currentRound) * maxShare / vestingRounds;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/// @notice The newly claimable amount for a vesting entry at a locked-share checkpoint.
|
|
30
|
+
/// @param amount The original vesting entry amount.
|
|
31
|
+
/// @param shareClaimed The cumulative share already claimed.
|
|
32
|
+
/// @param lockedShare The share of the entry still locked.
|
|
33
|
+
/// @param maxShare The share value representing 100%.
|
|
34
|
+
/// @return claimAmount The newly unlocked amount.
|
|
35
|
+
/// @return newShareClaimed The cumulative share that should be stored if `claimAmount` is nonzero.
|
|
36
|
+
function newlyClaimableAmountOf(
|
|
37
|
+
uint256 amount,
|
|
38
|
+
uint256 shareClaimed,
|
|
39
|
+
uint256 lockedShare,
|
|
40
|
+
uint256 maxShare
|
|
41
|
+
)
|
|
42
|
+
internal
|
|
43
|
+
pure
|
|
44
|
+
returns (uint256 claimAmount, uint256 newShareClaimed)
|
|
45
|
+
{
|
|
46
|
+
if (lockedShare == 0 && shareClaimed < maxShare) {
|
|
47
|
+
return (unclaimedAmountOf({amount: amount, shareClaimed: shareClaimed, maxShare: maxShare}), maxShare);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
newShareClaimed = maxShare - lockedShare;
|
|
51
|
+
if (newShareClaimed > shareClaimed) {
|
|
52
|
+
claimAmount = mulDiv({x: amount, y: newShareClaimed, denominator: maxShare})
|
|
53
|
+
- mulDiv({x: amount, y: shareClaimed, denominator: maxShare});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// @notice The amount remaining after the previously claimed share is deducted.
|
|
58
|
+
/// @dev Computing `amount - paid` releases any floor-division dust on the final unlock.
|
|
59
|
+
/// @param amount The original vesting entry amount.
|
|
60
|
+
/// @param shareClaimed The cumulative share already claimed.
|
|
61
|
+
/// @param maxShare The share value representing 100%.
|
|
62
|
+
/// @return unclaimedAmount The entry amount not yet claimed.
|
|
63
|
+
function unclaimedAmountOf(
|
|
64
|
+
uint256 amount,
|
|
65
|
+
uint256 shareClaimed,
|
|
66
|
+
uint256 maxShare
|
|
67
|
+
)
|
|
68
|
+
internal
|
|
69
|
+
pure
|
|
70
|
+
returns (uint256 unclaimedAmount)
|
|
71
|
+
{
|
|
72
|
+
unclaimedAmount = amount - mulDiv({x: amount, y: shareClaimed, denominator: maxShare});
|
|
73
|
+
}
|
|
74
|
+
}
|