@bananapus/distributor-v6 0.0.20 → 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 +44 -36
- 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
|
|
|
@@ -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
|
+
}
|