@bananapus/core-v6 0.0.46 → 0.0.48

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/core-v6",
3
- "version": "0.0.46",
3
+ "version": "0.0.48",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -238,12 +238,12 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
238
238
  }
239
239
  }
240
240
 
241
- /// @notice Burns a holder's project tokens (or credits), permanently removing them from supply. Used by terminals
242
- /// during cash outs, or directly by holders who want to burn voluntarily.
241
+ /// @notice Burns a holder's project tokens (or unclaimed credits), permanently removing them from the project
242
+ /// token supply. Used by terminals during cash outs, or directly by holders who want to burn voluntarily.
243
243
  /// @dev Can only be called by the holder, an operator with `BURN_TOKENS` permission, or a project terminal.
244
- /// @param holder The address to burn tokens for.
245
- /// @param projectId The ID of the project to burn tokens for.
246
- /// @param tokenCount The number of tokens to burn.
244
+ /// @param holder The address whose project tokens (or credits) are being burned.
245
+ /// @param projectId The ID of the project whose tokens are being burned.
246
+ /// @param tokenCount The number of project tokens (or credits) to burn, as a fixed point number with 18 decimals.
247
247
  /// @param memo A memo to pass along to the emitted event.
248
248
  function burnTokensOf(
249
249
  address holder,
@@ -273,13 +273,16 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
273
273
  TOKENS.burnFrom({holder: holder, projectId: projectId, count: tokenCount});
274
274
  }
275
275
 
276
- /// @notice Converts internal credits into the project's ERC-20 token, transferring them to the beneficiary's
277
- /// wallet. Credits and ERC-20 tokens are interchangeable — this just makes them transferable/tradeable.
276
+ /// @notice Converts a holder's internal project token credits into the project's ERC-20 representation,
277
+ /// transferring them to the beneficiary's wallet. Credits and the ERC-20 are equivalent project tokens — this
278
+ /// just
279
+ /// makes them transferable/tradeable.
278
280
  /// @dev Can only be called by the credit holder or an operator with `CLAIM_TOKENS` permission.
279
- /// @param holder The address to redeem credits from.
280
- /// @param projectId The ID of the project to claim tokens for.
281
- /// @param tokenCount The number of tokens to claim.
282
- /// @param beneficiary The account the claimed tokens will go to.
281
+ /// @param holder The address whose project token credits are being redeemed.
282
+ /// @param projectId The ID of the project whose project tokens are being claimed.
283
+ /// @param tokenCount The number of project token credits to convert into ERC-20 project tokens, as a fixed point
284
+ /// number with 18 decimals.
285
+ /// @param beneficiary The account that receives the resulting ERC-20 project tokens.
283
286
  function claimTokensFor(
284
287
  address holder,
285
288
  uint256 projectId,
@@ -520,12 +523,14 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
520
523
  /// reserved percent (which accumulates until `sendReservedTokensToSplitsOf` is called).
521
524
  /// @dev Can be called by the project owner, an operator with `MINT_TOKENS` permission, a project terminal, or the
522
525
  /// data hook. If `allowOwnerMinting` is false in the current ruleset, only terminals and the data hook can mint.
523
- /// @param projectId The ID of the project to mint tokens for.
524
- /// @param tokenCount The number of tokens to mint, including any reserved tokens.
525
- /// @param beneficiary The address which will receive the (non-reserved) tokens.
526
+ /// @param projectId The ID of the project whose project tokens are being minted.
527
+ /// @param tokenCount The total number of project tokens to mint (the beneficiary's share plus the reserved share if
528
+ /// `useReservedPercent` is true), as a fixed point number with 18 decimals.
529
+ /// @param beneficiary The address that receives the non-reserved portion of the minted project tokens.
526
530
  /// @param memo A memo to pass along to the emitted event.
527
531
  /// @param useReservedPercent Whether to apply the ruleset's reserved percent.
528
- /// @return beneficiaryTokenCount The number of tokens minted for the `beneficiary`.
532
+ /// @return beneficiaryTokenCount The number of project tokens minted to `beneficiary` (excluding the reserved
533
+ /// share), as a fixed point number with 18 decimals.
529
534
  function mintTokensOf(
530
535
  uint256 projectId,
531
536
  uint256 tokenCount,
@@ -220,14 +220,15 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
220
220
  }
221
221
  }
222
222
 
223
- /// @notice Adds funds to a project's balance without minting tokens. Useful for topping up a project or returning
224
- /// funds. Can also unlock previously held fees by returning them to the project's balance.
223
+ /// @notice Adds funds (terminal tokens) to a project's balance without minting project tokens. Useful for topping
224
+ /// up a project or returning funds. Can also unlock previously held fees by returning them to the project's
225
+ /// balance.
225
226
  /// @dev If `shouldReturnHeldFees` is true, the added amount offsets held fees proportionally.
226
227
  /// @param projectId The ID of the project to add funds to the balance of.
227
- /// @param amount The amount of tokens to add to the balance, as a fixed point number with the same number of
228
- /// decimals as the token's accounting context. If this is a native token terminal, this is ignored and `msg.value`
229
- /// is used instead.
230
- /// @param token The token to add to the balance.
228
+ /// @param token The terminal token being added (the ERC-20, or `JBConstants.NATIVE_TOKEN` for native).
229
+ /// @param amount The amount of the terminal `token` to add, as a fixed point number with the same number of
230
+ /// decimals as the token's accounting context. If `token` is the native token, this argument is ignored and
231
+ /// `msg.value` is used instead.
231
232
  /// @param shouldReturnHeldFees If true, return held fees proportional to the amount added.
232
233
  /// @param memo A memo to pass along to the emitted event.
233
234
  /// @param metadata Extra data to pass along to the emitted event.
@@ -254,24 +255,25 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
254
255
  });
255
256
  }
256
257
 
257
- /// @notice Cash out project tokens. The project's current ruleset determines the reclaimed surplus and any data
258
- /// hook or cash out hook behavior.
259
- /// @dev Only the token holder or an operator with `CASH_OUT_TOKENS` permission from that holder can call this.
260
- /// @param holder The account cashing out tokens.
258
+ /// @notice Burn project tokens to reclaim a share of the project's surplus (held as a terminal token). The
259
+ /// project's current ruleset determines the reclaimed amount, plus any data hook or cash out hook behavior.
260
+ /// @dev Only the project token holder, or an operator with `CASH_OUT_TOKENS` permission from that holder, can call
261
+ /// this.
262
+ /// @dev Two distinct tokens are involved: **project tokens** (`cashOutCount`) are burned from `holder`, and
263
+ /// **terminal tokens** (`tokenToReclaim`) are sent to `beneficiary` in exchange.
264
+ /// @param holder The account whose project tokens are being burned.
261
265
  /// @param projectId The ID of the project the project tokens belong to.
262
- /// @param cashOutCount The number of project tokens to cash out and burn, as a fixed point number with 18
263
- /// decimals.
264
- /// @param tokenToReclaim The token to reclaim.
265
- /// @param minTokensReclaimed The minimum number of terminal tokens expected in return, as a fixed point number with
266
- /// the same number of decimals as the token's accounting context. If the amount of tokens minted for the
267
- /// beneficiary would be less than this amount, the cash out is reverted.
268
- /// @param beneficiary The address to send the cashed out terminal tokens to, and to pass along to the ruleset's
266
+ /// @param cashOutCount The number of project tokens to burn, as a fixed point number with 18 decimals.
267
+ /// @param tokenToReclaim The terminal token to reclaim from the project's surplus.
268
+ /// @param minTokensReclaimed The minimum number of terminal tokens that must be returned to `beneficiary` for the
269
+ /// call to succeed, as a fixed point number with the same number of decimals as the terminal token's accounting
270
+ /// context. If fewer terminal tokens would be reclaimed, the cash out is reverted.
271
+ /// @param beneficiary The address to send the reclaimed terminal tokens to, and to pass along to the ruleset's
269
272
  /// data hook and cash out hook if applicable.
270
273
  /// @param metadata Bytes to send along to the emitted event, as well as the data hook and cash out hook if
271
274
  /// applicable.
272
- /// @return reclaimAmount The amount of terminal tokens that the project tokens were cashed out for, as a fixed
273
- /// point
274
- /// number with 18 decimals.
275
+ /// @return reclaimAmount The amount of **terminal tokens** sent to `beneficiary` in exchange for the burned project
276
+ /// tokens, as a fixed point number with the same number of decimals as the terminal token's accounting context.
275
277
  function cashOutTokensOf(
276
278
  address holder,
277
279
  uint256 projectId,
@@ -317,7 +319,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
317
319
  address originalMessageSender
318
320
  )
319
321
  external
320
- returns (uint256 netPayoutAmount)
322
+ returns (uint256 netPayoutAmount, uint256 feeEligibleAmount)
321
323
  {
322
324
  // NOTICE: May only be called by this terminal itself.
323
325
  require(msg.sender == address(this));
@@ -332,36 +334,24 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
332
334
  revert JBMultiTerminal_SplitHookInvalid({hook: split.hook});
333
335
  }
334
336
 
335
- // This payout is eligible for a fee since the funds are leaving this contract and the split hook isn't a
336
- // feeless address.
337
337
  if (!_isFeeless({addr: address(split.hook), projectId: projectId})) {
338
338
  unchecked {
339
339
  netPayoutAmount -= _feeAmountFrom(amount);
340
340
  }
341
341
  }
342
342
 
343
- // Create the context to send to the split hook.
344
- JBSplitHookContext memory context = JBSplitHookContext({
345
- token: token,
346
- amount: netPayoutAmount,
347
- decimals: STORE.accountingContextOf({
348
- terminal: address(this), projectId: projectId, token: token
349
- }).decimals,
343
+ // Delegate the partial-pull-aware hook invocation to the library so this branch stays compact.
344
+ // The library builds the hook context internally and infers fee-eligibility from
345
+ // `netPayoutAmount < amount` (`true` only when a fee was deducted above).
346
+ (netPayoutAmount, feeEligibleAmount) = JBPayoutSplitGroupLib.invokeSplitHookWithPartial({
347
+ split: split,
350
348
  projectId: projectId,
351
- groupId: uint256(uint160(token)),
352
- split: split
349
+ token: token,
350
+ amount: amount,
351
+ netPayoutAmount: netPayoutAmount,
352
+ store: STORE
353
353
  });
354
354
 
355
- // Trigger any inherited pre-transfer logic.
356
- // Get a reference to the amount being paid in `msg.value`.
357
- uint256 payValue = _beforeTransferTo({to: address(split.hook), token: token, amount: netPayoutAmount});
358
-
359
- // If this terminal's token is the native token, send it in `msg.value`.
360
- split.hook.processSplitWith{value: payValue}(context);
361
-
362
- // Revoke the temporary pull allowance now that the hook call has finished.
363
- _afterTransferTo({to: address(split.hook), token: token});
364
-
365
355
  // Otherwise, if a project is specified, make a payment to it.
366
356
  } else if (split.projectId != 0) {
367
357
  // Get a reference to the terminal being used.
@@ -381,6 +371,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
381
371
  unchecked {
382
372
  netPayoutAmount -= _feeAmountFrom(amount);
383
373
  }
374
+ feeEligibleAmount = amount;
384
375
  }
385
376
 
386
377
  // Track the fee-free payout amount. During cashout at zero tax rate, fees apply
@@ -444,6 +435,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
444
435
  unchecked {
445
436
  netPayoutAmount -= _feeAmountFrom(amount);
446
437
  }
438
+ feeEligibleAmount = amount;
447
439
  }
448
440
 
449
441
  // If there's a beneficiary, send the funds directly to the beneficiary. Otherwise send to the
@@ -565,22 +557,25 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
565
557
  }
566
558
  }
567
559
 
568
- /// @notice Pay a project. The project's current ruleset determines how many project tokens the beneficiary receives
569
- /// and any data hook or pay hook behavior.
560
+ /// @notice Pay a project with a payment token. The project's ruleset determines how many project tokens the
561
+ /// beneficiary receives, plus any data hook or pay hook behavior.
562
+ /// @dev Two distinct tokens are involved: the **payment token** (`token`, e.g. ETH or an ERC-20) flows from the
563
+ /// payer into the terminal, and **project tokens** (the project's own ERC-20 / credit balance) are minted to the
564
+ /// `beneficiary` according to the ruleset's weight.
570
565
  /// @param projectId The ID of the project to pay.
571
- /// @param amount The amount of tokens to send, as a fixed point number with the same number of
572
- /// decimals as the token's accounting context. If this terminal's token is native, this is ignored and `msg.value`
573
- /// is used in its place.
574
- /// @param token The token to pay with.
575
- /// @param beneficiary The address to mint tokens to, and pass along to the ruleset's data hook and pay hook if
576
- /// applicable.
577
- /// @param minReturnedTokens The minimum number of project tokens expected in return for this payment, as a fixed
578
- /// point number with the same number of decimals as the token's accounting context. If the amount of tokens minted
579
- /// for the beneficiary would be less than this amount, the payment is reverted.
566
+ /// @param token The payment token to pay with (the ERC-20, or `JBConstants.NATIVE_TOKEN` for native).
567
+ /// @param amount The amount of the payment `token` to send, as a fixed point number with the same number of
568
+ /// decimals as the token's accounting context. If `token` is the native token, this argument is ignored and
569
+ /// `msg.value` is used in its place.
570
+ /// @param beneficiary The address to mint project tokens to, and to pass along to the ruleset's data hook and pay
571
+ /// hook if applicable.
572
+ /// @param minReturnedTokens The minimum number of project tokens the beneficiary must receive for the payment to
573
+ /// succeed, as a fixed point number with 18 decimals. If fewer project tokens would be minted, the payment is
574
+ /// reverted.
580
575
  /// @param memo A memo to pass along to the emitted event.
581
576
  /// @param metadata Bytes to pass along to the emitted event, as well as the data hook and pay hook if applicable.
582
- /// @return beneficiaryTokenCount The number of tokens minted to the beneficiary, as a fixed point number with 18
583
- /// decimals.
577
+ /// @return beneficiaryTokenCount The number of **project tokens** minted to `beneficiary`, as a fixed point number
578
+ /// with 18 decimals.
584
579
  function pay(
585
580
  uint256 projectId,
586
581
  address token,
@@ -728,21 +723,23 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
728
723
  /// @notice Withdraws funds from a project's surplus (beyond what's needed for payouts) up to the current ruleset's
729
724
  /// surplus allowance. Sent directly to a beneficiary address rather than through splits.
730
725
  /// @dev Only the project's owner or an operator with `USE_ALLOWANCE` permission can call this.
731
- /// @dev Incurs the 2.5% protocol fee unless the caller is a feeless address.
726
+ /// @dev Incurs the 2.5% protocol fee unless the caller is a feeless address. The fee is charged in the terminal
727
+ /// token (`token`); the fee project mints **project tokens** in return and sends them to `feeBeneficiary`.
732
728
  /// @param projectId The ID of the project to use the surplus allowance of.
733
- /// @param token The token to pay out from the surplus.
734
- /// @param amount The amount of terminal tokens to use from the project's current surplus allowance, as a fixed
729
+ /// @param token The terminal token to pay out from the surplus.
730
+ /// @param amount The amount of terminal `token` to use from the project's current surplus allowance, as a fixed
735
731
  /// point number with the same number of decimals as the token's accounting context.
736
- /// @param currency The expected currency of the amount to pay out. Must match the currency of one of the
737
- /// project's current ruleset's surplus allowances.
738
- /// @param minTokensPaidOut The minimum number of terminal tokens that should be returned from the surplus allowance
739
- /// (excluding fees), as a fixed point number with 18 decimals. If the amount of surplus used would be less than
740
- /// this amount, the transaction is reverted.
741
- /// @param beneficiary The address to send the surplus funds to.
742
- /// @param feeBeneficiary The address to send the tokens resulting from paying the fee.
732
+ /// @param currency The expected currency of `amount`. Must match the currency of one of the project's current
733
+ /// ruleset's surplus allowances.
734
+ /// @param minTokensPaidOut The minimum number of terminal tokens that must be returned from the surplus allowance
735
+ /// (excluding fees), as a fixed point number with the same number of decimals as the terminal token's accounting
736
+ /// context. If less would be paid out, the transaction is reverted.
737
+ /// @param beneficiary The address to send the reclaimed terminal tokens to.
738
+ /// @param feeBeneficiary The address that receives the **project tokens** minted by the fee project in exchange
739
+ /// for the protocol fee paid in terminal tokens.
743
740
  /// @param memo A memo to pass along to the emitted event.
744
- /// @return netAmountPaidOut The number of tokens that were sent to the beneficiary, as a fixed point number with
745
- /// the same number of decimals as the token's accounting context.
741
+ /// @return netAmountPaidOut The number of **terminal tokens** sent to `beneficiary`, net of the protocol fee, as a
742
+ /// fixed point number with the same number of decimals as the terminal token's accounting context.
746
743
  function useAllowanceOf(
747
744
  uint256 projectId,
748
745
  address token,
@@ -427,6 +427,13 @@ contract JBTerminalStore is IJBTerminalStore {
427
427
  })
428
428
  });
429
429
 
430
+ // If cross-currency conversion rounded to zero, return without consuming any payout limit. Otherwise a
431
+ // permissionless caller could repeatedly request sub-unit payouts to drain the cycle's payout limit
432
+ // without moving any funds.
433
+ if (amountPaidOut == 0) {
434
+ return (ruleset, 0);
435
+ }
436
+
430
437
  // Cache the balance slot to avoid redundant storage reads.
431
438
  uint256 currentBalance = balanceOf[msg.sender][projectId][token];
432
439
 
@@ -75,16 +75,16 @@ interface IJBCashOutTerminal is IJBTerminal {
75
75
  JBCashOutHookSpecification[] memory hookSpecifications
76
76
  );
77
77
 
78
- /// @notice Cashes out a holder's tokens for a project, reclaiming the token's proportional share of the project's
79
- /// surplus.
80
- /// @param holder The address cashing out tokens.
81
- /// @param projectId The ID of the project cashing out tokens.
82
- /// @param cashOutCount The number of project tokens to cash out.
83
- /// @param tokenToReclaim The token to reclaim from the project's surplus.
84
- /// @param minTokensReclaimed The minimum number of terminal tokens expected to be reclaimed.
85
- /// @param beneficiary The address to send the reclaimed tokens to.
78
+ /// @notice Burn a holder's project tokens to reclaim a proportional share of the project's surplus (paid out as a
79
+ /// terminal token).
80
+ /// @param holder The address whose project tokens are being burned.
81
+ /// @param projectId The ID of the project whose project tokens are being burned.
82
+ /// @param cashOutCount The number of project tokens to burn.
83
+ /// @param tokenToReclaim The terminal token to reclaim from the project's surplus.
84
+ /// @param minTokensReclaimed The minimum number of terminal tokens that must be reclaimed.
85
+ /// @param beneficiary The address to send the reclaimed terminal tokens to.
86
86
  /// @param metadata Extra data to send to the data hook and cash out hooks.
87
- /// @return reclaimAmount The amount of tokens reclaimed from the project's surplus.
87
+ /// @return reclaimAmount The number of terminal tokens reclaimed from the project's surplus.
88
88
  function cashOutTokensOf(
89
89
  address holder,
90
90
  uint256 projectId,
@@ -261,18 +261,18 @@ interface IJBController is IERC165, IJBProjectUriRegistry, IJBDirectoryAccessCon
261
261
  )
262
262
  external;
263
263
 
264
- /// @notice Burns a holder's project tokens or credits.
265
- /// @param holder The address to burn tokens for.
266
- /// @param projectId The ID of the project to burn tokens for.
267
- /// @param tokenCount The number of tokens to burn.
264
+ /// @notice Burns a holder's project tokens or unclaimed project token credits, removing them from supply.
265
+ /// @param holder The address whose project tokens (or credits) are being burned.
266
+ /// @param projectId The ID of the project whose project tokens are being burned.
267
+ /// @param tokenCount The number of project tokens (or credits) to burn.
268
268
  /// @param memo A memo to pass along to the emitted event.
269
269
  function burnTokensOf(address holder, uint256 projectId, uint256 tokenCount, string calldata memo) external;
270
270
 
271
- /// @notice Redeems credits to claim tokens into a beneficiary's account.
272
- /// @param holder The address to redeem credits from.
273
- /// @param projectId The ID of the project to claim tokens for.
274
- /// @param tokenCount The number of tokens to claim.
275
- /// @param beneficiary The account the claimed tokens will go to.
271
+ /// @notice Converts project token credits into the project's ERC-20 representation, sending them to a beneficiary.
272
+ /// @param holder The address whose project token credits are being redeemed.
273
+ /// @param projectId The ID of the project whose project tokens are being claimed.
274
+ /// @param tokenCount The number of project token credits to convert into ERC-20 project tokens.
275
+ /// @param beneficiary The account that receives the resulting ERC-20 project tokens.
276
276
  function claimTokensFor(address holder, uint256 projectId, uint256 tokenCount, address beneficiary) external;
277
277
 
278
278
  /// @notice Deploys an ERC-20 token for a project.
@@ -324,13 +324,16 @@ interface IJBController is IERC165, IJBProjectUriRegistry, IJBDirectoryAccessCon
324
324
  external
325
325
  returns (uint256 rulesetId);
326
326
 
327
- /// @notice Mints new project tokens or credits to a beneficiary, optionally reserving a portion.
328
- /// @param projectId The ID of the project to mint tokens for.
329
- /// @param tokenCount The number of tokens to mint, including any reserved tokens.
330
- /// @param beneficiary The address which will receive the non-reserved tokens.
327
+ /// @notice Mints new project tokens (or credits) to a beneficiary, optionally reserving a portion for the ruleset's
328
+ /// reserved splits.
329
+ /// @param projectId The ID of the project whose project tokens are being minted.
330
+ /// @param tokenCount The total number of project tokens to mint (the beneficiary's share plus the reserved share if
331
+ /// `useReservedPercent` is true).
332
+ /// @param beneficiary The address that receives the non-reserved portion of the minted project tokens.
331
333
  /// @param memo A memo to pass along to the emitted event.
332
334
  /// @param useReservedPercent Whether to apply the ruleset's reserved percent.
333
- /// @return beneficiaryTokenCount The number of tokens minted for the beneficiary.
335
+ /// @return beneficiaryTokenCount The number of project tokens minted to `beneficiary` (excluding the reserved
336
+ /// share).
334
337
  function mintTokensOf(
335
338
  uint256 projectId,
336
339
  uint256 tokenCount,
@@ -137,10 +137,10 @@ interface IJBTerminal is IERC165 {
137
137
  /// @param accountingContexts The accounting contexts to add.
138
138
  function addAccountingContextsFor(uint256 projectId, JBAccountingContext[] calldata accountingContexts) external;
139
139
 
140
- /// @notice Adds funds to a project's balance.
140
+ /// @notice Adds terminal tokens to a project's balance (no project tokens are minted).
141
141
  /// @param projectId The ID of the project to add funds to.
142
- /// @param token The token added.
143
- /// @param amount The amount of tokens added.
142
+ /// @param token The terminal token being added.
143
+ /// @param amount The amount of terminal `token` being added.
144
144
  /// @param shouldReturnHeldFees Whether held fees should be returned based on the amount added.
145
145
  /// @param memo A memo to pass along to the emitted event.
146
146
  /// @param metadata Extra data to pass along to the emitted event.
@@ -162,15 +162,15 @@ interface IJBTerminal is IERC165 {
162
162
  /// @return balance The amount of funds that were migrated.
163
163
  function migrateBalanceOf(uint256 projectId, address token, IJBTerminal to) external returns (uint256 balance);
164
164
 
165
- /// @notice Pays a project in a specified token.
165
+ /// @notice Pay a project with a payment token in exchange for project tokens minted to a beneficiary.
166
166
  /// @param projectId The ID of the project to pay.
167
- /// @param token The token to pay with.
168
- /// @param amount The amount of tokens to pay.
167
+ /// @param token The payment token to pay with.
168
+ /// @param amount The amount of the payment `token` to pay.
169
169
  /// @param beneficiary The address to mint project tokens to.
170
- /// @param minReturnedTokens The minimum number of project tokens expected in return.
170
+ /// @param minReturnedTokens The minimum number of project tokens the beneficiary must receive.
171
171
  /// @param memo A memo to pass along to the emitted event.
172
172
  /// @param metadata Extra data to pass along to the pay hooks.
173
- /// @return beneficiaryTokenCount The number of tokens minted for the beneficiary.
173
+ /// @return beneficiaryTokenCount The number of project tokens minted to the beneficiary.
174
174
  function pay(
175
175
  uint256 projectId,
176
176
  address token,
@@ -1,11 +1,15 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.28;
3
3
 
4
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
5
+ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
4
6
  import {mulDiv} from "@prb/math/src/Common.sol";
5
7
 
8
+ import {IJBSplitHook} from "../interfaces/IJBSplitHook.sol";
6
9
  import {IJBSplits} from "../interfaces/IJBSplits.sol";
7
10
  import {IJBTerminalStore} from "../interfaces/IJBTerminalStore.sol";
8
11
  import {JBSplit} from "../structs/JBSplit.sol";
12
+ import {JBSplitHookContext} from "../structs/JBSplitHookContext.sol";
9
13
  import {JBConstants} from "./JBConstants.sol";
10
14
 
11
15
  /// @notice Minimal callback surface used only by this library to call back into the terminal's `executePayout(...)`.
@@ -18,7 +22,11 @@ interface IJBPayoutSplitGroupExecutor {
18
22
  /// @param token The token being paid out.
19
23
  /// @param amount The amount assigned to the split.
20
24
  /// @param originalMessageSender The account that started the payout flow.
21
- /// @return netPayoutAmount The amount that was actually paid after fees or hook behavior.
25
+ /// @return netPayoutAmount The amount the split recipient actually received (may be less than the
26
+ /// post-fee amount if a split hook accepted a partial pull).
27
+ /// @return feeEligibleAmount The gross-equivalent of `netPayoutAmount` that should accrue held fees.
28
+ /// Equals `amount` on a fully-consumed non-feeless payout, `0` on feeless or when the hook took nothing,
29
+ /// and a scaled value `amount * sent / netOffered` for a partial pull.
22
30
  function executePayout(
23
31
  JBSplit calldata split,
24
32
  uint256 projectId,
@@ -27,7 +35,7 @@ interface IJBPayoutSplitGroupExecutor {
27
35
  address originalMessageSender
28
36
  )
29
37
  external
30
- returns (uint256 netPayoutAmount);
38
+ returns (uint256 netPayoutAmount, uint256 feeEligibleAmount);
31
39
  }
32
40
 
33
41
  /// @notice Handles distributing payouts to a project's split recipients. Iterates through each split, sends the
@@ -48,6 +56,92 @@ library JBPayoutSplitGroupLib {
48
56
  address caller
49
57
  );
50
58
 
59
+ /// @notice Invokes a split hook with partial-pull-aware allowance handling.
60
+ /// @dev For ERC-20: grants the hook an allowance, calls `processSplitWith`, and revokes any unconsumed
61
+ /// allowance. For native ETH: pushes via `msg.value`. The hook may take less than offered (revert or
62
+ /// short-pull); the unsent portion is routed back to the project balance via `store.recordAddedBalanceFor`,
63
+ /// scaled to include the proportional fee allocation so the held fee is effectively charged only on the
64
+ /// consumed amount. Fee-eligibility is inferred from `netPayoutAmount < amount`.
65
+ /// @dev Called via DELEGATECALL from the terminal, so `address(this)` is the terminal and `msg.sender`
66
+ /// observed by the hook is the terminal — hooks that check `DIRECTORY.isTerminalOf(msg.sender)` continue
67
+ /// to work unchanged.
68
+ /// @param split The split (must have a non-zero hook address).
69
+ /// @param projectId The originating project ID.
70
+ /// @param token The token being distributed. Use `JBConstants.NATIVE_TOKEN` for ETH.
71
+ /// @param amount The gross amount allocated to this split (pre-fee).
72
+ /// @param netPayoutAmount The amount the hook is offered (post-fee for non-feeless splits, == amount otherwise).
73
+ /// @param store The terminal store used to credit any refund back to the project and to look up decimals.
74
+ /// @return sent The amount the hook actually received.
75
+ /// @return feeEligibleAmount The gross-equivalent of `sent` (used for held-fee accounting). Zero for
76
+ /// feeless splits or when the hook consumed nothing.
77
+ function invokeSplitHookWithPartial(
78
+ JBSplit calldata split,
79
+ uint256 projectId,
80
+ address token,
81
+ uint256 amount,
82
+ uint256 netPayoutAmount,
83
+ IJBTerminalStore store
84
+ )
85
+ external
86
+ returns (uint256 sent, uint256 feeEligibleAmount)
87
+ {
88
+ // Native vs ERC-20 governs the transfer mechanism (push via msg.value vs allowance pull).
89
+ bool isNative = token == JBConstants.NATIVE_TOKEN;
90
+
91
+ // Snapshot the relevant balance before the hook call. The post-call delta tells us how much the hook
92
+ // actually took, regardless of whether it pulled the full allowance, partially pulled, or reverted.
93
+ uint256 balanceBefore = isNative ? address(this).balance : IERC20(token).balanceOf(address(this));
94
+
95
+ // Build the hook context inline so the terminal call site doesn't have to. `decimals` is looked up from
96
+ // the terminal store's recorded accounting context for this (projectId, token) pair.
97
+ JBSplitHookContext memory context = JBSplitHookContext({
98
+ token: token,
99
+ amount: netPayoutAmount,
100
+ decimals: store.accountingContextOf({terminal: address(this), projectId: projectId, token: token}).decimals,
101
+ projectId: projectId,
102
+ groupId: uint256(uint160(token)),
103
+ split: split
104
+ });
105
+
106
+ // Set up the transfer: ETH is pushed via `value:` on the hook call; ERC-20 grants the hook a pull
107
+ // allowance for the offered net amount.
108
+ uint256 payValue;
109
+ if (isNative) {
110
+ payValue = netPayoutAmount;
111
+ } else {
112
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(split.hook), value: netPayoutAmount});
113
+ }
114
+
115
+ // Wrap the hook call in try/catch so a reverting hook does not bubble out. On revert no tokens leave
116
+ // this contract (transferFrom inside the hook is rolled back; pushed ETH is returned), so `sent` will
117
+ // resolve to 0 via balance-delta below.
118
+ try split.hook.processSplitWith{value: payValue}(context) {} catch {}
119
+
120
+ // Revoke any unconsumed ERC-20 allowance immediately after the call so the hook can never pull later.
121
+ // ETH path has no allowance to revoke.
122
+ if (!isNative) SafeERC20.forceApprove({token: IERC20(token), spender: address(split.hook), value: 0});
123
+
124
+ // The hook's actual consumption is the drop in this contract's balance for the token.
125
+ sent = balanceBefore - (isNative ? address(this).balance : IERC20(token).balanceOf(address(this)));
126
+
127
+ // If the hook took less than offered, refund the proportional gross portion to the project's balance.
128
+ // refund = amount * (netPayoutAmount - sent) / netPayoutAmount. For full consumption this branch is
129
+ // skipped. For zero consumption this refunds the full `amount` (i.e. the gross, fee allocation included).
130
+ if (sent < netPayoutAmount) {
131
+ uint256 refund = mulDiv(amount, netPayoutAmount - sent, netPayoutAmount);
132
+ if (refund != 0) {
133
+ store.recordAddedBalanceFor({projectId: projectId, token: token, amount: refund});
134
+ }
135
+ }
136
+
137
+ // `netPayoutAmount < amount` iff the terminal deducted a fee above (non-feeless split). Report the
138
+ // gross-equivalent of what the hook actually consumed so the held fee scales with consumption rather
139
+ // than with the project's original payout intent.
140
+ if (netPayoutAmount < amount && sent != 0) {
141
+ feeEligibleAmount = mulDiv(amount, sent, netPayoutAmount);
142
+ }
143
+ }
144
+
51
145
  /// @notice Sends payouts to the payout splits group specified in a project's ruleset.
52
146
  /// @param splits The splits contract to read splits from.
53
147
  /// @param store The terminal store used to restore balance when a payout fails.
@@ -87,14 +181,25 @@ library JBPayoutSplitGroupLib {
87
181
  // The amount to send to the split.
88
182
  uint256 payoutAmount = mulDiv(leftoverAmount, split.percent, leftoverPercentage);
89
183
 
90
- // The final payout amount after taking out any fees.
91
- uint256 netPayoutAmount = _sendPayoutToSplit({
92
- store: store, split: split, projectId: projectId, token: token, amount: payoutAmount, caller: caller
93
- });
94
-
95
- // If the split hook is a feeless address, this payout doesn't incur a fee.
96
- if (netPayoutAmount != 0 && netPayoutAmount != payoutAmount) {
97
- amountEligibleForFees += payoutAmount;
184
+ // Send the payout (inlined to keep stack pressure manageable with the tuple return).
185
+ // Returns (netPayoutAmount sent, feeEligible gross-equivalent). For non-hook splits and fully-consumed
186
+ // hook splits, `feeEligible` equals `payoutAmount` (non-feeless) or 0 (feeless). For a partial split-hook
187
+ // pull, `feeEligible` scales with consumed. Failed payouts consume the payout limit by design — the
188
+ // try/catch keeps a single bad split from DoS-ing the rest and restores balance.
189
+ uint256 netPayoutAmount;
190
+ try IJBPayoutSplitGroupExecutor(address(this))
191
+ .executePayout({
192
+ split: split, projectId: projectId, token: token, amount: payoutAmount, originalMessageSender: caller
193
+ }) returns (
194
+ uint256 sentAmount, uint256 feeEligible
195
+ ) {
196
+ netPayoutAmount = sentAmount;
197
+ amountEligibleForFees += feeEligible;
198
+ } catch (bytes memory failureReason) {
199
+ emit PayoutReverted({
200
+ projectId: projectId, split: split, amount: payoutAmount, reason: failureReason, caller: caller
201
+ });
202
+ store.recordAddedBalanceFor({projectId: projectId, token: token, amount: payoutAmount});
98
203
  }
99
204
 
100
205
  if (payoutAmount != 0) {
@@ -123,47 +228,4 @@ library JBPayoutSplitGroupLib {
123
228
  }
124
229
  }
125
230
  }
126
-
127
- /// @notice Sends a payout to a split.
128
- /// @param store The terminal store used to restore balance when a payout fails.
129
- /// @param split The split to pay.
130
- /// @param projectId The ID of the project the split was specified by.
131
- /// @param token The address of the token to pay out.
132
- /// @param amount The total amount that the split is paid.
133
- /// @param caller The original caller of the terminal payout flow.
134
- /// @return netPayoutAmount The amount sent to the split after subtracting fees.
135
- function _sendPayoutToSplit(
136
- IJBTerminalStore store,
137
- JBSplit memory split,
138
- uint256 projectId,
139
- address token,
140
- uint256 amount,
141
- address caller
142
- )
143
- private
144
- returns (uint256 netPayoutAmount)
145
- {
146
- // Failed split payouts consume the payout limit by design. The try-catch prevents a single
147
- // split from DoS-ing the entire payout. Failed splits' amounts are returned to the project balance via
148
- // `recordAddedBalanceFor`. Payout limit consumption is correct because the project authorized the
149
- // distribution.
150
- try IJBPayoutSplitGroupExecutor(address(this))
151
- .executePayout({
152
- split: split, projectId: projectId, token: token, amount: amount, originalMessageSender: caller
153
- }) returns (
154
- uint256 payoutAmount
155
- ) {
156
- return payoutAmount;
157
- } catch (bytes memory failureReason) {
158
- emit PayoutReverted({
159
- projectId: projectId, split: split, amount: amount, reason: failureReason, caller: caller
160
- });
161
-
162
- // Add balance back to the project.
163
- store.recordAddedBalanceFor({projectId: projectId, token: token, amount: amount});
164
-
165
- // Since the payout failed the netPayoutAmount is zero.
166
- return 0;
167
- }
168
- }
169
231
  }
@@ -11,6 +11,27 @@ import {Test} from "lib/forge-std/src/Test.sol";
11
11
  contract JBTest is Test {
12
12
  using JBRulesetMetadataResolver for JBRulesetMetadata;
13
13
 
14
+ /// @notice The pinned CREATE2 address of `JBPayoutSplitGroupLib` from `foundry.toml`.
15
+ /// @dev Must match the `libraries = [...]` entry. Tests etch the runtime code here so
16
+ /// `JBMultiTerminal`'s delegatecalls into the library resolve.
17
+ address internal constant _JB_PAYOUT_SPLIT_GROUP_LIB = 0xE5767922e5f8d18c57C4685289Ef92D859eb980b;
18
+
19
+ /// @notice Deploy `JBPayoutSplitGroupLib` locally and etch its runtime bytecode at the pinned
20
+ /// address from `foundry.toml`. Forge auto-deploys linked libraries only when no `libraries`
21
+ /// entry is configured; once the address is pinned (needed so deploy-all-v6 can read
22
+ /// `artifacts/JBMultiTerminal.json::bytecode.object` as valid hex), unit tests must plant the
23
+ /// runtime code themselves or any path through `executePayout` / `sendPayoutsToSplitGroupOf`
24
+ /// reverts with `delegatecall to non-contract address`.
25
+ function _etchPayoutSplitGroupLib() internal {
26
+ bytes memory creationCode = vm.getCode("JBPayoutSplitGroupLib");
27
+ address libImpl;
28
+ assembly {
29
+ libImpl := create(0, add(creationCode, 0x20), mload(creationCode))
30
+ }
31
+ require(libImpl != address(0), "JBPayoutSplitGroupLib deploy failed");
32
+ vm.etch(_JB_PAYOUT_SPLIT_GROUP_LIB, libImpl.code);
33
+ }
34
+
14
35
  function mockExpect(address _where, bytes memory _encodedCall, bytes memory _returns) public {
15
36
  vm.mockCall(_where, _encodedCall, _returns);
16
37
  vm.expectCall(_where, _encodedCall);
@@ -290,6 +290,9 @@ contract TestBaseWorkflow is JBTest, DeployPermit2 {
290
290
 
291
291
  // Deploys and initializes contracts for testing.
292
292
  function setUp() public virtual {
293
+ // Plant `JBPayoutSplitGroupLib` at its pre-linked address so terminal delegatecalls resolve.
294
+ _etchPayoutSplitGroupLib();
295
+
293
296
  _jbPermissions = new JBPermissions(_trustedForwarder);
294
297
  _jbProjects = new JBProjects(_multisig, address(0), _trustedForwarder);
295
298
  _jbDirectory = new JBDirectory(_jbPermissions, _jbProjects, _multisig);