@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/distributor-v6",
3
- "version": "0.0.20",
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.52",
30
- "@bananapus/core-v6": "^0.0.55",
31
- "@bananapus/permission-ids-v6": "^0.0.25",
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
  },
@@ -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
- // If it's not a native-token transfer, credit the ERC-20 amount.
120
- if (msg.value == 0 && context.amount != 0) {
121
- // Pull tokens via transferFrom. Both terminals and controllers grant an ERC-20
122
- // allowance before calling. Balance delta handles fee-on-transfer tokens correctly.
123
- uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
124
- IERC20(context.token).safeTransferFrom({from: msg.sender, to: address(this), value: context.amount});
125
- uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
126
- _balanceOf[hook][IERC20(context.token)] += delta;
127
- _accountedBalanceOf[IERC20(context.token)] += delta;
128
- } else if (msg.value != 0) {
129
- // Validate that context.token matches NATIVE_TOKEN to prevent cross-booking attacks.
130
- if (context.token != JBConstants.NATIVE_TOKEN) {
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
- // Native ETH: credit actual value received.
136
- _balanceOf[hook][IERC20(context.token)] += msg.value;
137
- _accountedBalanceOf[IERC20(context.token)] += msg.value;
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
- if (tokenAmount > 0) {
438
- // Push a new vesting data entry for this token ID, starting with zero shareClaimed.
439
- vestingDataOf[ctx.hook][tokenId][ctx.token].push(
440
- JBVestingData({releaseRound: ctx.vestingReleaseRound, amount: tokenAmount, shareClaimed: 0})
441
- );
442
-
443
- // Emit the claim event for off-chain indexers.
444
- emit Claimed({
445
- hook: ctx.hook,
446
- tokenId: tokenId,
447
- token: ctx.token,
448
- amount: tokenAmount,
449
- vestingReleaseRound: ctx.vestingReleaseRound
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
  }
@@ -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
- uint256 balanceBefore = token.balanceOf(address(this));
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 += vesting.amount - mulDiv({x: vesting.amount, y: vesting.shareClaimed, denominator: MAX_SHARE});
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
- // Calculate the share amount that is locked.
328
- if (vesting.releaseRound > round) {
329
- lockedShare = (vesting.releaseRound - round) * MAX_SHARE / vestingRounds;
330
- }
346
+ lockedShare = JBVestingMath.lockedShareOf({
347
+ releaseRound: vesting.releaseRound,
348
+ currentRound: round,
349
+ vestingRounds: vestingRounds,
350
+ maxShare: MAX_SHARE
351
+ });
331
352
 
332
- if (lockedShare == 0 && vesting.shareClaimed < MAX_SHARE) {
333
- // Final unlock: compute remaining as `original - alreadyPaid` to include dust.
334
- tokenAmount += vesting.amount
335
- - mulDiv({x: vesting.amount, y: vesting.shareClaimed, denominator: MAX_SHARE});
336
- } else {
337
- uint256 newShareClaimed = MAX_SHARE - lockedShare;
338
- if (newShareClaimed > vesting.shareClaimed) {
339
- tokenAmount += mulDiv({
340
- x: vesting.amount, y: newShareClaimed - vesting.shareClaimed, denominator: MAX_SHARE
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
- // Calculate the share amount that is locked.
596
- uint256 lockedShare;
597
- if (vesting.releaseRound > round) {
598
- lockedShare = (vesting.releaseRound - round) * MAX_SHARE / vestingRounds;
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
- if (lockedShare == 0 && vesting.shareClaimed < MAX_SHARE) {
604
- // Final unlock: compute remaining amount as `original - alreadyPaid` to force
605
- // rounding dust out so nothing is stranded in the entry.
606
- claimAmount =
607
- vesting.amount - mulDiv({x: vesting.amount, y: vesting.shareClaimed, denominator: MAX_SHARE});
608
- } else if (MAX_SHARE - lockedShare > vesting.shareClaimed) {
609
- claimAmount = mulDiv({
610
- x: vesting.amount, y: MAX_SHARE - lockedShare - vesting.shareClaimed, denominator: MAX_SHARE
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
- // If it's not a native-token transfer, credit the ERC-20 amount.
90
- if (msg.value == 0 && context.amount != 0) {
91
- // Pull tokens via transferFrom. Both terminals and controllers grant an ERC-20
92
- // allowance before calling. Balance delta handles fee-on-transfer tokens correctly.
93
- uint256 balanceBefore = IERC20(context.token).balanceOf(address(this));
94
- IERC20(context.token).safeTransferFrom({from: msg.sender, to: address(this), value: context.amount});
95
- uint256 delta = IERC20(context.token).balanceOf(address(this)) - balanceBefore;
96
- _balanceOf[hook][IERC20(context.token)] += delta;
97
- _accountedBalanceOf[IERC20(context.token)] += delta;
98
- } else if (msg.value != 0) {
99
- // Validate that context.token matches NATIVE_TOKEN to prevent cross-booking attacks.
100
- if (context.token != JBConstants.NATIVE_TOKEN) {
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
- // Native ETH: credit actual value received.
106
- _balanceOf[hook][IERC20(context.token)] += msg.value;
107
- _accountedBalanceOf[IERC20(context.token)] += msg.value;
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
+ }