@bananapus/core-v6 0.0.58 → 0.0.59

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.58",
3
+ "version": "0.0.59",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -30,6 +30,7 @@ import {IJBTerminalStore} from "./interfaces/IJBTerminalStore.sol";
30
30
  import {IJBTokens} from "./interfaces/IJBTokens.sol";
31
31
  import {JBConstants} from "./libraries/JBConstants.sol";
32
32
  import {JBFees} from "./libraries/JBFees.sol";
33
+ import {JBHeldFees} from "./libraries/JBHeldFees.sol";
33
34
  import {JBMetadataResolver} from "./libraries/JBMetadataResolver.sol";
34
35
  import {JBPayoutSplitGroupLib} from "./libraries/JBPayoutSplitGroupLib.sol";
35
36
  import {JBRulesetMetadataResolver} from "./libraries/JBRulesetMetadataResolver.sol";
@@ -81,9 +82,6 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
81
82
  // ------------------------ internal constants ----------------------- //
82
83
  //*********************************************************************//
83
84
 
84
- /// @notice Project ID #1 receives fees. It should be the first project launched during the deployment process.
85
- uint256 internal constant _FEE_BENEFICIARY_PROJECT_ID = 1;
86
-
87
85
  /// @notice The number of seconds fees can be held for.
88
86
  uint256 internal constant _FEE_HOLDING_SECONDS = 2_419_200; // 28 days
89
87
 
@@ -143,7 +141,26 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
143
141
  mapping(uint256 projectId => mapping(address token => uint256)) internal _nextHeldFeeIndexOf;
144
142
 
145
143
  //*********************************************************************//
146
- // ------------------- transient stored properties ------------------- //
144
+ // --------------- public transient stored properties --------------- //
145
+ //*********************************************************************//
146
+
147
+ /// @notice Caller-originated referrer reference for the duration of the current external fee-paying call.
148
+ /// @dev Encoded as `(referralChainId << 48) | referralProjectId`: bits [79:48] are the referrer's EIP-155 chain
149
+ /// ID (uint32), bits [47:0] are the referrer's project ID on that chain (uint48). The upper bits of the
150
+ /// uint256 are reserved for future encoding extensions.
151
+ /// @dev The entry points `cashOutTokensOf`, `sendPayoutsOf`, and `useAllowanceOf` auto-resolve a bare project ID
152
+ /// (non-zero project, zero chain bits) to the current execution chain via `block.chainid` before writing this
153
+ /// slot. So storage (and indexers reading `feeVolumeByReferralOf`) always observe a fully-resolved
154
+ /// `(chainId, projectId)` pair — callers can write `referralProjectId: someProjectId` without manually packing
155
+ /// their own chain ID.
156
+ /// @dev Set by the three entry points via the `_setReferralProjectId` save-restore wrapper. Read inside `_pay`
157
+ /// to credit `feeVolumeByReferralOf` when the fee project's pay call is recorded locally.
158
+ /// @dev Public so pay/cashout/split hooks can introspect which referral originated the in-flight call (e.g. to
159
+ /// apply referral-specific logic). Reads `0` outside any fee-paying call.
160
+ uint256 public transient override currentReferralProjectId;
161
+
162
+ //*********************************************************************//
163
+ // -------------- internal transient stored properties -------------- //
147
164
  //*********************************************************************//
148
165
 
149
166
  /// @notice Whether this terminal is currently measuring an incoming ERC-20 balance delta.
@@ -277,6 +294,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
277
294
  /// data hook and cash out hook if applicable.
278
295
  /// @param metadata Bytes to send along to the emitted event, as well as the data hook and cash out hook if
279
296
  /// applicable.
297
+ /// @param referralProjectId Optional referrer reference to credit with the protocol fee volume taken by this
298
+ /// call, encoded as `(referralChainId << 48) | referralProjectId` (chain ID in the upper 32 bits, project ID
299
+ /// in the lower 48). A bare project ID (chain bits zero) is auto-resolved to the current chain via `block.chainid`.
300
+ /// Pass `0` for no referral credit.
280
301
  /// @return reclaimAmount The amount of **terminal tokens** sent to `beneficiary` in exchange for the burned project
281
302
  /// tokens, as a fixed point number with the same number of decimals as the terminal token's accounting context.
282
303
  function cashOutTokensOf(
@@ -286,7 +307,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
286
307
  address tokenToReclaim,
287
308
  uint256 minTokensReclaimed,
288
309
  address payable beneficiary,
289
- bytes calldata metadata
310
+ bytes calldata metadata,
311
+ uint256 referralProjectId
290
312
  )
291
313
  external
292
314
  override
@@ -295,6 +317,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
295
317
  // Enforce permissions.
296
318
  _requirePermissionFrom({account: holder, projectId: projectId, permissionId: JBPermissionIds.CASH_OUT_TOKENS});
297
319
 
320
+ // Save-restore the transient referral slot so nested reentrant fee-paying calls don't pollute each other.
321
+ uint256 priorReferral = _setReferralProjectId(referralProjectId);
322
+
298
323
  reclaimAmount = _cashOutTokensOf({
299
324
  holder: holder,
300
325
  projectId: projectId,
@@ -304,6 +329,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
304
329
  metadata: metadata
305
330
  });
306
331
 
332
+ _setReferralProjectId(priorReferral);
333
+
307
334
  // The amount being reclaimed must be at least as much as was expected.
308
335
  _checkMin({value: reclaimAmount, min: minTokensReclaimed});
309
336
  }
@@ -498,7 +525,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
498
525
 
499
526
  _efficientPay({
500
527
  terminal: feeTerminal,
501
- projectId: _FEE_BENEFICIARY_PROJECT_ID,
528
+ projectId: JBConstants.FEE_BENEFICIARY_PROJECT_ID,
502
529
  token: token,
503
530
  amount: amount,
504
531
  beneficiary: beneficiary,
@@ -569,7 +596,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
569
596
  // Migration to a non-feeless terminal incurs the standard 2.5% fee, same as any other fund egress.
570
597
  // This also settles any fee-free surplus liability that would otherwise be lost on the new terminal.
571
598
  uint256 feeAmount;
572
- if (!_isFeeless({addr: address(to), projectId: projectId}) && projectId != _FEE_BENEFICIARY_PROJECT_ID) {
599
+ if (
600
+ !_isFeeless({addr: address(to), projectId: projectId})
601
+ && projectId != JBConstants.FEE_BENEFICIARY_PROJECT_ID
602
+ ) {
573
603
  // Fee processing failures never block migration. If the fee route is broken, `_processFee` credits
574
604
  // the fee amount back to this source terminal and emits `FeeReverted`; the post-fee amount still
575
605
  // migrates so project funds are not trapped behind project #1 routing issues.
@@ -666,7 +696,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
666
696
  /// @param count The number of fees to process.
667
697
  function processHeldFeesOf(uint256 projectId, address token, uint256 count) external override {
668
698
  // Keep a reference to the terminal that'll receive the fees.
669
- IJBTerminal feeTerminal = _primaryTerminalOf({projectId: _FEE_BENEFICIARY_PROJECT_ID, token: token});
699
+ IJBTerminal feeTerminal = _primaryTerminalOf({projectId: JBConstants.FEE_BENEFICIARY_PROJECT_ID, token: token});
670
700
 
671
701
  // Process each fee. Re-read the index and array length from storage each iteration to account for reentrant
672
702
  // calls that may have already advanced the index or cleaned up the array.
@@ -696,6 +726,13 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
696
726
  delete _heldFeesOf[projectId][token][currentIndex];
697
727
  _nextHeldFeeIndexOf[projectId][token] = currentIndex + 1;
698
728
 
729
+ // Restore the originating fee-paying call's referral project for the duration of this fee's processing
730
+ // so the credit in `_processFee` attributes to the right (chain, project) pair. No save needed:
731
+ // `processHeldFeesOf` is a top-level keeper call in practice — the incoming transient is 0. Defensive
732
+ // cleanup happens once after the loop instead of per-iteration. Reconstructs the packed encoding from
733
+ // the two struct halves: `(chainId << 48) | projectId`.
734
+ currentReferralProjectId = (uint256(heldFee.referralChainId) << 48) | uint256(heldFee.referralProjectId);
735
+
699
736
  // Process the standard fee on the original gross amount recorded when the held fee was created.
700
737
  _processFee({
701
738
  projectId: projectId,
@@ -705,11 +742,17 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
705
742
  feeTerminal: feeTerminal,
706
743
  wasHeld: true
707
744
  });
745
+
708
746
  unchecked {
709
747
  ++i;
710
748
  }
711
749
  }
712
750
 
751
+ // Defensive cleanup: zero the transient so any subsequent in-tx work (e.g. hook reentrancy) doesn't
752
+ // inherit the last processed fee's referral. EIP-1153 would clear it at end-of-tx anyway, but in-tx
753
+ // reentry is the only case where this matters.
754
+ currentReferralProjectId = 0;
755
+
713
756
  // If all held fees have been processed, reset the array and index entirely to bound storage growth.
714
757
  if (
715
758
  _nextHeldFeeIndexOf[projectId][token] >= _heldFeesOf[projectId][token].length
@@ -736,20 +779,29 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
736
779
  /// in terms of the token's accounting context currency), as a fixed point number with the same number of decimals
737
780
  /// as the token's accounting context. If the amount of tokens paid out would be less than this amount, the send is
738
781
  /// reverted.
782
+ /// @param referralProjectId Optional referrer reference to credit with the protocol fee volume taken by this
783
+ /// call, encoded as `(referralChainId << 48) | referralProjectId` (chain ID in the upper 32 bits, project ID
784
+ /// in the lower 48). A bare project ID (chain bits zero) is auto-resolved to the current chain via `block.chainid`.
785
+ /// Pass `0` for no referral credit.
739
786
  /// @return amountPaidOut The total amount paid out.
740
787
  function sendPayoutsOf(
741
788
  uint256 projectId,
742
789
  address token,
743
790
  uint256 amount,
744
791
  uint256 currency,
745
- uint256 minTokensPaidOut
792
+ uint256 minTokensPaidOut,
793
+ uint256 referralProjectId
746
794
  )
747
795
  external
748
796
  override
749
797
  returns (uint256 amountPaidOut)
750
798
  {
799
+ uint256 priorReferral = _setReferralProjectId(referralProjectId);
800
+
751
801
  amountPaidOut = _sendPayoutsOf({projectId: projectId, token: token, amount: amount, currency: currency});
752
802
 
803
+ _setReferralProjectId(priorReferral);
804
+
753
805
  // The amount being paid out must be at least as much as was expected.
754
806
  _checkMin({value: amountPaidOut, min: minTokensPaidOut});
755
807
  }
@@ -772,6 +824,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
772
824
  /// @param feeBeneficiary The address that receives the **project tokens** minted by the fee project in exchange
773
825
  /// for the protocol fee paid in terminal tokens.
774
826
  /// @param memo A memo to pass along to the emitted event.
827
+ /// @param referralProjectId Optional referrer reference to credit with the protocol fee volume taken by this
828
+ /// call, encoded as `(referralChainId << 48) | referralProjectId` (chain ID in the upper 32 bits, project ID
829
+ /// in the lower 48). A bare project ID (chain bits zero) is auto-resolved to the current chain via `block.chainid`.
830
+ /// Pass `0` for no referral credit.
775
831
  /// @return netAmountPaidOut The number of **terminal tokens** sent to `beneficiary`, net of the protocol fee, as a
776
832
  /// fixed point number with the same number of decimals as the terminal token's accounting context.
777
833
  function useAllowanceOf(
@@ -782,7 +838,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
782
838
  uint256 minTokensPaidOut,
783
839
  address payable beneficiary,
784
840
  address payable feeBeneficiary,
785
- string calldata memo
841
+ string calldata memo,
842
+ uint256 referralProjectId
786
843
  )
787
844
  external
788
845
  override
@@ -794,6 +851,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
794
851
  // Enforce permissions.
795
852
  _requirePermissionFrom({account: owner, projectId: projectId, permissionId: JBPermissionIds.USE_ALLOWANCE});
796
853
 
854
+ uint256 priorReferral = _setReferralProjectId(referralProjectId);
855
+
797
856
  netAmountPaidOut = _useAllowanceOf({
798
857
  projectId: projectId,
799
858
  owner: owner,
@@ -805,6 +864,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
805
864
  memo: memo
806
865
  });
807
866
 
867
+ _setReferralProjectId(priorReferral);
868
+
808
869
  // The amount being withdrawn must be at least as much as was expected.
809
870
  _checkMin({value: netAmountPaidOut, min: minTokensPaidOut});
810
871
  }
@@ -879,30 +940,15 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
879
940
  external
880
941
  view
881
942
  override
882
- returns (JBFee[] memory heldFees)
943
+ returns (JBFee[] memory)
883
944
  {
884
- // Keep a reference to the start index.
885
- uint256 startIndex = _nextHeldFeeIndexOf[projectId][token];
886
-
887
- // Get a reference to the number of held fees.
888
- uint256 numberOfHeldFees = _heldFeesOf[projectId][token].length;
889
-
890
- // If the start index is greater than or equal to the number of held fees, return 0.
891
- if (startIndex >= numberOfHeldFees) return new JBFee[](0);
892
-
893
- // If the start index plus the count is greater than the number of fees, set the count to the number of fees
894
- if (startIndex + count > numberOfHeldFees) count = numberOfHeldFees - startIndex;
895
-
896
- // Create a new array to hold the fees.
897
- heldFees = new JBFee[](count);
898
-
899
- // Copy the fees into the array.
900
- for (uint256 i; i < count;) {
901
- heldFees[i] = _heldFeesOf[projectId][token][startIndex + i];
902
- unchecked {
903
- ++i;
904
- }
905
- }
945
+ return JBHeldFees.viewHeldFees({
946
+ heldFeesOf: _heldFeesOf,
947
+ nextHeldFeeIndexOf: _nextHeldFeeIndexOf,
948
+ projectId: projectId,
949
+ token: token,
950
+ count: count
951
+ });
906
952
  }
907
953
 
908
954
  /// @notice Simulates a cash out without modifying state — use this to preview how many tokens a holder would
@@ -1705,6 +1751,15 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1705
1751
  payer: payer, amount: tokenAmount, projectId: projectId, beneficiary: beneficiary, metadata: metadata
1706
1752
  });
1707
1753
 
1754
+ // Credit the originating fee-paying call's referral project. This is only meaningful when this pay is a
1755
+ // protocol-fee payment landing on the fee project AND a referral was set by the outer entry point (or
1756
+ // restored from a held fee by `processHeldFeesOf`). The store handles the no-op case when either input is
1757
+ // zero. Done here (after `recordPaymentFrom`) instead of inside the store so the credit logic stays in the
1758
+ // terminal and the store is never asked to call back into us.
1759
+ if (projectId == JBConstants.FEE_BENEFICIARY_PROJECT_ID && currentReferralProjectId != 0) {
1760
+ STORE.recordFeeReferralCreditOf({referralProjectId: currentReferralProjectId, amount: tokenAmount});
1761
+ }
1762
+
1708
1763
  // Only the value retained in the destination balance needs later cashout fee recovery. Non-feeless pay-hook
1709
1764
  // forwards pay their source-equivalent fee inline before leaving the project.
1710
1765
  if (internalSplitPayProjectId != 0) {
@@ -1804,7 +1859,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1804
1859
  emit FeeReverted({
1805
1860
  projectId: projectId,
1806
1861
  token: token,
1807
- feeProjectId: _FEE_BENEFICIARY_PROJECT_ID,
1862
+ feeProjectId: JBConstants.FEE_BENEFICIARY_PROJECT_ID,
1808
1863
  amount: amount,
1809
1864
  reason: reason,
1810
1865
  caller: _msgSender()
@@ -1832,66 +1887,13 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1832
1887
  /// as the token's accounting context.
1833
1888
  /// @return returnedFees The amount of held fees that were returned, as a fixed point number with the same number of
1834
1889
  /// decimals as the token's accounting context.
1835
- function _returnHeldFees(uint256 projectId, address token, uint256 amount) internal returns (uint256 returnedFees) {
1836
- // Start from the first held fee that has not already been returned, processed, or forgiven.
1837
- uint256 startIndex = _nextHeldFeeIndexOf[projectId][token];
1838
-
1839
- // Use the original array length as the upper bound. Returning held fees never appends new entries.
1840
- uint256 numberOfHeldFees = _heldFeesOf[projectId][token].length;
1841
-
1842
- if (startIndex >= numberOfHeldFees) return 0;
1843
-
1844
- // Track how much of the new balance remains available to match against held fees.
1845
- uint256 leftoverAmount = amount;
1846
-
1847
- // Move this forward for each fully returned held fee.
1848
- uint256 newStartIndex = startIndex;
1849
-
1850
- for (uint256 i = startIndex; i < numberOfHeldFees;) {
1851
- if (leftoverAmount == 0) break;
1852
-
1853
- // Held fees store the original gross amount that paid out before its fee was removed.
1854
- JBFee memory heldFee = _heldFeesOf[projectId][token][i];
1855
-
1856
- // Recompute the standard fee associated with the held gross amount.
1857
- uint256 feeAmount = _feeAmountFrom(heldFee.amount);
1858
-
1859
- // This is the net amount that originally left the project after the held fee was removed.
1860
- uint256 amountPaidOut = heldFee.amount - feeAmount;
1861
-
1862
- if (leftoverAmount >= amountPaidOut) {
1863
- unchecked {
1864
- leftoverAmount -= amountPaidOut;
1865
- returnedFees += feeAmount;
1866
- }
1867
-
1868
- // Move the start index forward to the fee after this fully returned one.
1869
- newStartIndex = i + 1;
1870
- } else {
1871
- // Only part of this held fee can be returned. Convert the remaining net replenishment back into
1872
- // its corresponding gross fee and shrink the stored gross amount.
1873
- feeAmount = JBFees.standardFeeAmountResultingIn(leftoverAmount);
1874
-
1875
- unchecked {
1876
- _heldFeesOf[projectId][token][i].amount -= (leftoverAmount + feeAmount);
1877
- returnedFees += feeAmount;
1878
- }
1879
- leftoverAmount = 0;
1880
- }
1881
- unchecked {
1882
- ++i;
1883
- }
1884
- }
1885
-
1886
- // Update the next held fee index.
1887
- if (startIndex != newStartIndex) _nextHeldFeeIndexOf[projectId][token] = newStartIndex;
1888
-
1889
- emit ReturnHeldFees({
1890
+ function _returnHeldFees(uint256 projectId, address token, uint256 amount) internal returns (uint256) {
1891
+ return JBHeldFees.returnHeldFees({
1892
+ heldFeesOf: _heldFeesOf,
1893
+ nextHeldFeeIndexOf: _nextHeldFeeIndexOf,
1890
1894
  projectId: projectId,
1891
1895
  token: token,
1892
1896
  amount: amount,
1893
- returnedFees: returnedFees,
1894
- leftoverAmount: leftoverAmount,
1895
1897
  caller: _msgSender()
1896
1898
  });
1897
1899
  }
@@ -2012,7 +2014,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
2012
2014
  });
2013
2015
  }
2014
2016
 
2015
- /// @notice Takes a fee into the platform's project (with the `_FEE_BENEFICIARY_PROJECT_ID`).
2017
+ /// @notice Takes a fee into the platform's project (with the `JBConstants.FEE_BENEFICIARY_PROJECT_ID`).
2016
2018
  /// @param projectId The ID of the project paying the fee.
2017
2019
  /// @param token The address of the token that the fee is paid in.
2018
2020
  /// @param amount The fee's token amount, as a fixed point number with the same number of decimals as the token's
@@ -2036,12 +2038,22 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
2036
2038
 
2037
2039
  if (shouldHoldFees) {
2038
2040
  // Store the gross amount so future repayments can recover the corresponding fee.
2041
+ // Capture the in-flight `currentReferralProjectId` so attribution survives the 28-day hold window —
2042
+ // by the time `processHeldFeesOf` runs, the transient slot has been cleared. The encoded
2043
+ // `(chainId << 48) | projectId` pair in the transient slot is decomposed into its two halves so the
2044
+ // struct stays in 2 storage slots.
2045
+ uint256 encodedReferral = currentReferralProjectId;
2039
2046
  _heldFeesOf[projectId][token].push(
2040
2047
  JBFee({
2041
- amount: amount,
2048
+ // forge-lint: disable-next-line(unsafe-typecast)
2049
+ amount: uint224(amount),
2050
+ // forge-lint: disable-next-line(unsafe-typecast)
2051
+ referralChainId: uint32(encodedReferral >> 48),
2042
2052
  beneficiary: beneficiary,
2043
2053
  // forge-lint: disable-next-line(unsafe-typecast)
2044
- unlockTimestamp: uint48(block.timestamp + _FEE_HOLDING_SECONDS)
2054
+ unlockTimestamp: uint48(block.timestamp + _FEE_HOLDING_SECONDS),
2055
+ // forge-lint: disable-next-line(unsafe-typecast)
2056
+ referralProjectId: uint48(encodedReferral)
2045
2057
  })
2046
2058
  );
2047
2059
 
@@ -2055,7 +2067,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
2055
2067
  });
2056
2068
  } else {
2057
2069
  // Resolve the fee project's terminal for this token and process the fee immediately.
2058
- IJBTerminal feeTerminal = _primaryTerminalOf({projectId: _FEE_BENEFICIARY_PROJECT_ID, token: token});
2070
+ IJBTerminal feeTerminal =
2071
+ _primaryTerminalOf({projectId: JBConstants.FEE_BENEFICIARY_PROJECT_ID, token: token});
2059
2072
 
2060
2073
  _processFee({
2061
2074
  projectId: projectId,
@@ -2295,4 +2308,22 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
2295
2308
  function _feeAmountFrom(uint256 amount) private pure returns (uint256) {
2296
2309
  return JBFees.standardFeeAmountFrom(amount);
2297
2310
  }
2311
+
2312
+ /// @notice Set the transient `currentReferralProjectId` slot and return the prior value (for save-restore).
2313
+ /// @dev Returning the prior value lets the caller restore it after the inner call completes, which is required
2314
+ /// to keep nested reentrant fee-paying calls from polluting each other. Per EIP-1153, a revert in the inner
2315
+ /// call also reverts the transient write, so no explicit cleanup on the failure path is needed.
2316
+ /// @dev Backfills `block.chainid` into the chain-bits half of the encoding when the caller passed a bare
2317
+ /// project ID (non-zero project, zero chain). This lets callers write `referralProjectId: someProjectId`
2318
+ /// without manually packing their own chain ID — the entry point resolves it to the current execution chain,
2319
+ /// so storage and indexers always see a fully-resolved `(chainId, projectId)` pair.
2320
+ /// @param referralProjectId The new value to write into the transient slot.
2321
+ /// @return prior The value previously stored in the slot.
2322
+ function _setReferralProjectId(uint256 referralProjectId) private returns (uint256 prior) {
2323
+ if (referralProjectId != 0 && referralProjectId >> 48 == 0) {
2324
+ referralProjectId |= block.chainid << 48;
2325
+ }
2326
+ prior = currentReferralProjectId;
2327
+ currentReferralProjectId = referralProjectId;
2328
+ }
2298
2329
  }
@@ -91,6 +91,26 @@ contract JBTerminalStore is IJBTerminalStore {
91
91
  public
92
92
  override balanceOf;
93
93
 
94
+ /// @notice Cumulative fee payment amount credited to a referrer (chainId, projectId) pair as a result of
95
+ /// fee-paying calls that originated through a given terminal.
96
+ /// @dev Written by terminals via `recordFeeReferralCreditOf`; the writing terminal is `msg.sender`, so a caller
97
+ /// can only pollute their own bucket.
98
+ /// @dev Nested by chain ID then project ID so off-chain consumers can read the credit for a specific
99
+ /// `(chainId, projectId)` directly without re-packing the encoded form themselves.
100
+ /// @custom:param terminal The terminal that originated the fee-paying call.
101
+ /// @custom:param referralChainId The EIP-155 chain ID of the referrer's home chain.
102
+ /// @custom:param referralProjectId The referrer's bare project ID on `referralChainId`.
103
+ mapping(address terminal => mapping(uint256 referralChainId => mapping(uint256 referralProjectId => uint256)))
104
+ public
105
+ override feeVolumeByReferralOf;
106
+
107
+ /// @notice Cumulative fee payment amount credited across all referral projects for a given terminal.
108
+ /// @dev Updated in lockstep with `feeVolumeByReferralOf` so consumers can compute a referrer's pro-rata share
109
+ /// in a single SLOAD pair without enumerating referrers. Used as the denominator by split hooks that distribute
110
+ /// rewards proportional to attributed fee volume.
111
+ /// @custom:param terminal The terminal that originated the fee-paying calls.
112
+ mapping(address terminal => uint256) public override totalFeeVolumeOf;
113
+
94
114
  /// @notice The currency-denominated amount of funds that a project has already paid out from its payout limit
95
115
  /// during the current ruleset for each terminal, in terms of the payout limit's currency.
96
116
  /// @dev Increases as projects pay out funds.
@@ -322,6 +342,22 @@ contract JBTerminalStore is IJBTerminalStore {
322
342
  }
323
343
  }
324
344
 
345
+ /// @notice Credit a referral project with a fee payment amount routed through `msg.sender` (the calling terminal).
346
+ /// @dev Called by `JBMultiTerminal._pay` after a payment lands on the fee project. Permissionless: writes are
347
+ /// scoped to `msg.sender`'s slots so an arbitrary caller can only pollute their own buckets — off-chain
348
+ /// consumers should filter on known terminal addresses. The amount is normalized to `NATIVE_TOKEN` units
349
+ /// (18 decimals) here in the store (where `PRICES` is available) so all credits share a common denominator.
350
+ /// @param referralProjectId The referral project to credit.
351
+ /// @param amount The fee amount paid by the originating fee-take call (raw value, decimals, currency).
352
+ function recordFeeReferralCreditOf(uint256 referralProjectId, JBTokenAmount calldata amount) external override {
353
+ _creditFeeReferral({
354
+ referralProjectId: referralProjectId,
355
+ amount: _normalizeToNativeTokenUnits({
356
+ value: amount.value, decimals: amount.decimals, currency: amount.currency
357
+ })
358
+ });
359
+ }
360
+
325
361
  /// @notice Records a payment — calculates how many project tokens to mint based on the payment amount and the
326
362
  /// current ruleset's weight. Uses the data hook if configured, otherwise mints proportionally.
327
363
  /// @dev Called by the terminal after accepting funds. Updates the project's recorded balance.
@@ -1378,4 +1414,79 @@ contract JBTerminalStore is IJBTerminalStore {
1378
1414
  }
1379
1415
  }
1380
1416
  }
1417
+
1418
+ //*********************************************************************//
1419
+ // -------------------------- private helpers ------------------------ //
1420
+ //*********************************************************************//
1421
+
1422
+ /// @notice Credit a referrer with a fee payment amount. Internal counterpart of `recordFeeReferralCreditOf` —
1423
+ /// both write to the same slots and emit the same event.
1424
+ /// @dev No-op when `referralProjectId == 0` or `amount == 0`.
1425
+ /// @dev Unpacks the encoded `(chainId << 48) | projectId` form into the nested mapping at write-time so the
1426
+ /// public getter exposes `(terminal, chainId, projectId) → cumulative` directly. The event topics carry the
1427
+ /// two halves separately as well.
1428
+ /// @param referralProjectId The packed `(chainId << 48) | projectId` referrer reference to credit.
1429
+ /// @param amount The fee amount to credit.
1430
+ function _creditFeeReferral(uint256 referralProjectId, uint256 amount) private {
1431
+ if (referralProjectId == 0 || amount == 0) return;
1432
+
1433
+ uint256 referralChainId = referralProjectId >> 48;
1434
+ uint256 bareProjectId = referralProjectId & ((1 << 48) - 1);
1435
+
1436
+ feeVolumeByReferralOf[msg.sender][referralChainId][bareProjectId] += amount;
1437
+ uint256 newTotal = totalFeeVolumeOf[msg.sender] + amount;
1438
+ totalFeeVolumeOf[msg.sender] = newTotal;
1439
+
1440
+ emit ReferralCredit({
1441
+ terminal: msg.sender,
1442
+ referralChainId: referralChainId,
1443
+ referralProjectId: bareProjectId,
1444
+ amount: amount,
1445
+ newTotal: newTotal
1446
+ });
1447
+ }
1448
+
1449
+ /// @notice Normalize a fee-token amount to `JBConstants.NATIVE_TOKEN` units at 18 decimals.
1450
+ /// @dev Two-step: first adjust decimals to 18 via `JBFixedPointNumber.adjustDecimals`, then convert currency
1451
+ /// via `PRICES.pricePerUnitOf` using the fee project's price feeds. If no price feed exists for the pair, the
1452
+ /// `try` block catches the revert and the credit is silently skipped — the payment itself still succeeds.
1453
+ /// @param value The amount in the source token's native decimals.
1454
+ /// @param decimals The source token's decimals.
1455
+ /// @param currency The source token's accounting-context currency (`uint32(uint160(token))`).
1456
+ /// @return normalized The amount expressed in `NATIVE_TOKEN` units (18 decimals), or 0 if conversion failed.
1457
+ function _normalizeToNativeTokenUnits(
1458
+ uint256 value,
1459
+ uint256 decimals,
1460
+ uint256 currency
1461
+ )
1462
+ private
1463
+ view
1464
+ returns (uint256 normalized)
1465
+ {
1466
+ // Adjust the source amount up/down to 18 decimals so all credits share a common precision.
1467
+ normalized = decimals == _MAX_FIXED_POINT_FIDELITY
1468
+ ? value
1469
+ : JBFixedPointNumber.adjustDecimals({
1470
+ value: value, decimals: decimals, targetDecimals: _MAX_FIXED_POINT_FIDELITY
1471
+ });
1472
+
1473
+ if (normalized == 0 || currency == JBConstants.NATIVE_TOKEN_CURRENCY) return normalized;
1474
+
1475
+ // Convert from the source currency to NATIVE_TOKEN via the fee project's price feeds. A missing feed
1476
+ // reverts inside `PRICES.pricePerUnitOf` — caught here so the payment is not blocked.
1477
+ try PRICES.pricePerUnitOf({
1478
+ projectId: JBConstants.FEE_BENEFICIARY_PROJECT_ID,
1479
+ pricingCurrency: currency,
1480
+ unitCurrency: JBConstants.NATIVE_TOKEN_CURRENCY,
1481
+ decimals: _MAX_FIXED_POINT_FIDELITY
1482
+ }) returns (
1483
+ uint256 price
1484
+ ) {
1485
+ normalized = price == 0
1486
+ ? 0
1487
+ : mulDiv({x: normalized, y: 10 ** _MAX_FIXED_POINT_FIDELITY, denominator: price});
1488
+ } catch {
1489
+ normalized = 0;
1490
+ }
1491
+ }
1381
1492
  }
@@ -84,6 +84,8 @@ interface IJBCashOutTerminal is IJBTerminal {
84
84
  /// @param minTokensReclaimed The minimum number of terminal tokens that must be reclaimed.
85
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
+ /// @param referralProjectId Optional project to credit with the protocol fee volume taken by this call. Pass `0`
88
+ /// to credit the project being cashed out.
87
89
  /// @return reclaimAmount The number of terminal tokens reclaimed from the project's surplus.
88
90
  function cashOutTokensOf(
89
91
  address holder,
@@ -92,7 +94,8 @@ interface IJBCashOutTerminal is IJBTerminal {
92
94
  address tokenToReclaim,
93
95
  uint256 minTokensReclaimed,
94
96
  address payable beneficiary,
95
- bytes calldata metadata
97
+ bytes calldata metadata,
98
+ uint256 referralProjectId
96
99
  )
97
100
  external
98
101
  returns (uint256 reclaimAmount);
@@ -30,4 +30,17 @@ interface IJBMultiTerminal is IJBTerminal, IJBFeeTerminal, IJBCashOutTerminal, I
30
30
 
31
31
  /// @notice The contract that manages token minting and burning.
32
32
  function TOKENS() external view returns (IJBTokens);
33
+
34
+ /// @notice The caller-originated referrer reference for the in-flight fee-paying external call.
35
+ /// @dev Encoded as `(referralChainId << 48) | referralProjectId`: bits [79:48] are the referrer's EIP-155 chain
36
+ /// ID (uint32), bits [47:0] are the referrer's project ID on that chain (uint48). Allows a referrer with a
37
+ /// project on chain X to be credited for activity on chain Y. A bare project ID (chain bits zero) is auto-
38
+ /// resolved to the current execution chain via `block.chainid` at the entry point, so storage and indexers
39
+ /// always see a fully-resolved `(chainId, projectId)` pair.
40
+ /// @dev Backed by transient storage. Set by `cashOutTokensOf`, `sendPayoutsOf`, and `useAllowanceOf` (save-
41
+ /// restore wrapper) so hooks invoked during that call (pay hooks, cashout hooks, split hooks) can introspect
42
+ /// which referrer originated the activity. Reads `0` outside any fee-paying call.
43
+ /// @dev Per-referrer cumulative fee payment amounts credited via this terminal are stored in
44
+ /// `JBTerminalStore.feeVolumeByReferralOf(address terminal, uint256 referralProjectId)`.
45
+ function currentReferralProjectId() external view returns (uint256);
33
46
  }
@@ -102,13 +102,16 @@ interface IJBPayoutTerminal is IJBTerminal {
102
102
  /// @param amount The total amount of tokens to pay out.
103
103
  /// @param currency The currency the amount is denominated in.
104
104
  /// @param minTokensPaidOut The minimum number of terminal tokens expected to be paid out.
105
+ /// @param referralProjectId Optional project to credit with the protocol fee volume taken by this call. Pass `0`
106
+ /// to credit the project sending payouts.
105
107
  /// @return amountPaidOut The total amount paid out.
106
108
  function sendPayoutsOf(
107
109
  uint256 projectId,
108
110
  address token,
109
111
  uint256 amount,
110
112
  uint256 currency,
111
- uint256 minTokensPaidOut
113
+ uint256 minTokensPaidOut,
114
+ uint256 referralProjectId
112
115
  )
113
116
  external
114
117
  returns (uint256 amountPaidOut);
@@ -122,6 +125,8 @@ interface IJBPayoutTerminal is IJBTerminal {
122
125
  /// @param beneficiary The address to send the funds to.
123
126
  /// @param feeBeneficiary The address that will receive any project tokens minted from fees.
124
127
  /// @param memo A memo to pass along to the emitted event.
128
+ /// @param referralProjectId Optional project to credit with the protocol fee volume taken by this call. Pass `0`
129
+ /// to credit the project whose surplus allowance is being used.
125
130
  /// @return netAmountPaidOut The net amount paid out to the beneficiary after fees.
126
131
  function useAllowanceOf(
127
132
  uint256 projectId,
@@ -131,7 +136,8 @@ interface IJBPayoutTerminal is IJBTerminal {
131
136
  uint256 minTokensPaidOut,
132
137
  address payable beneficiary,
133
138
  address payable feeBeneficiary,
134
- string calldata memo
139
+ string calldata memo,
140
+ uint256 referralProjectId
135
141
  )
136
142
  external
137
143
  returns (uint256 netAmountPaidOut);
@@ -15,6 +15,24 @@ import {JBTokenAmount} from "../structs/JBTokenAmount.sol";
15
15
  /// allowances, calculates token issuance per payment, and determines cash-out reclaim amounts via the bonding curve.
16
16
  /// Terminals delegate all state-changing accounting to this contract.
17
17
  interface IJBTerminalStore {
18
+ /// @notice Emitted when a referrer is credited with a fee payment amount.
19
+ /// @dev `referralChainId` and `referralProjectId` are emitted as separate indexed topics so off-chain consumers
20
+ /// can filter directly on either dimension. The `feeVolumeByReferralOf` storage key is the packed
21
+ /// `(referralChainId << 48) | referralProjectId` form — indexers re-pack the two fields when looking up the
22
+ /// cumulative balance for a referrer.
23
+ /// @param terminal The terminal that originated the fee-paying call (`msg.sender` on `recordFeeReferralCreditOf`).
24
+ /// @param referralChainId The EIP-155 chain ID of the referrer's home chain.
25
+ /// @param referralProjectId The referrer's bare project ID on `referralChainId` (no chain bits).
26
+ /// @param amount The fee amount credited, in the terminal's accounting-context units.
27
+ /// @param newTotal The new value of `totalFeeVolumeOf[terminal]` after this credit.
28
+ event ReferralCredit(
29
+ address indexed terminal,
30
+ uint256 indexed referralChainId,
31
+ uint256 indexed referralProjectId,
32
+ uint256 amount,
33
+ uint256 newTotal
34
+ );
35
+
18
36
  /// @notice The directory of terminals and controllers for projects.
19
37
  function DIRECTORY() external view returns (IJBDirectory);
20
38
 
@@ -142,6 +160,27 @@ interface IJBTerminalStore {
142
160
  view
143
161
  returns (uint256);
144
162
 
163
+ /// @notice The cumulative fee payment amount credited to a referrer (chainId, projectId) pair as a result of
164
+ /// fee-paying calls that originated through a given terminal.
165
+ /// @dev Written by terminals via `recordFeeReferralCreditOf` — `msg.sender` is recorded as the writing terminal,
166
+ /// so a malicious caller can only pollute their own slot. Off-chain consumers should filter on known terminal
167
+ /// addresses.
168
+ /// @dev Nested by chain ID then project ID so consumers can read the credit for a specific
169
+ /// `(chainId, projectId)` directly without re-packing the encoded form. The encoded `(chainId << 48) | projectId`
170
+ /// form is used only inside the transient slot and the entry-point parameter; storage exposes the unpacked pair.
171
+ /// @param terminal The terminal that originated the fee-paying call.
172
+ /// @param referralChainId The EIP-155 chain ID of the referrer's home chain.
173
+ /// @param referralProjectId The referrer's bare project ID on `referralChainId`.
174
+ /// @return The cumulative fee amount credited.
175
+ function feeVolumeByReferralOf(
176
+ address terminal,
177
+ uint256 referralChainId,
178
+ uint256 referralProjectId
179
+ )
180
+ external
181
+ view
182
+ returns (uint256);
183
+
145
184
  /// @notice Simulates a cash out without modifying state.
146
185
  /// @param terminal The terminal to simulate the cash out from.
147
186
  /// @param holder The address cashing out.
@@ -194,6 +233,13 @@ interface IJBTerminalStore {
194
233
  view
195
234
  returns (JBRuleset memory ruleset, uint256 tokenCount, JBPayHookSpecification[] memory hookSpecifications);
196
235
 
236
+ /// @notice The cumulative fee payment amount credited across all referral projects for a given terminal.
237
+ /// @dev Incremented in lockstep with `feeVolumeByReferralOf` so consumers can compute pro-rata shares in a
238
+ /// single SLOAD pair without enumerating referrers.
239
+ /// @param terminal The terminal that originated the fee-paying calls.
240
+ /// @return The cumulative total fee amount credited across all referrers for this terminal.
241
+ function totalFeeVolumeOf(address terminal) external view returns (uint256);
242
+
197
243
  /// @notice Returns the amount of payout limit used by a terminal for a project in a given cycle.
198
244
  /// @param terminal The terminal to get the used payout limit of.
199
245
  /// @param projectId The ID of the project.
@@ -269,6 +315,15 @@ interface IJBTerminalStore {
269
315
  JBCashOutHookSpecification[] memory hookSpecifications
270
316
  );
271
317
 
318
+ /// @notice Credit a referral project with a fee payment amount routed through `msg.sender` (the calling terminal).
319
+ /// @dev Permissionless: the write is scoped to `msg.sender`'s slot, so an arbitrary caller can only pollute
320
+ /// their own bucket. The amount is normalized to `JBConstants.NATIVE_TOKEN` units (18 decimals) using the fee
321
+ /// project's price feeds so credits across different fee tokens (ETH, USDC, USDT, …) are summable. No-op when
322
+ /// `referralProjectId == 0`, when `amount.value == 0`, or when no price feed exists for the pair.
323
+ /// @param referralProjectId The referral project to credit.
324
+ /// @param amount The fee amount paid by this fee-take call (raw token amount, decimals, and currency).
325
+ function recordFeeReferralCreditOf(uint256 referralProjectId, JBTokenAmount calldata amount) external;
326
+
272
327
  /// @notice Records a payment to a project.
273
328
  /// @param payer The address of the payer.
274
329
  /// @param amount The amount to pay.
@@ -3,24 +3,34 @@ pragma solidity 0.8.28;
3
3
 
4
4
  /// @notice Protocol-wide constants. These define the boundaries for economic parameters throughout Juicebox.
5
5
  library JBConstants {
6
- /// @notice The sentinel address used to represent each chain's native token (ETH on mainnet, etc.).
7
- address public constant NATIVE_TOKEN = address(0x000000000000000000000000000000000000EEEe);
8
-
9
- /// @notice The maximum reserved token percentage (basis points). 10,000 = 100% of minted tokens go to reserves.
10
- uint16 public constant MAX_RESERVED_PERCENT = 10_000;
6
+ /// @notice The project ID that receives protocol fees. Project #1 is the protocol's own project; every
7
+ /// terminal forwards 2.5% of qualifying outflows to its primary fee terminal for this project.
8
+ uint256 public constant FEE_BENEFICIARY_PROJECT_ID = 1;
11
9
 
12
10
  /// @notice The maximum cash-out tax rate (basis points). 10,000 = 100% tax, meaning token holders reclaim nothing.
13
11
  uint16 public constant MAX_CASH_OUT_TAX_RATE = 10_000;
14
12
 
13
+ /// @notice The fee denominator. The protocol fee is `STANDARD_FEE / MAX_FEE`.
14
+ uint16 public constant MAX_FEE = 1000;
15
+
16
+ /// @notice The maximum reserved token percentage (basis points). 10,000 = 100% of minted tokens go to reserves.
17
+ uint16 public constant MAX_RESERVED_PERCENT = 10_000;
18
+
15
19
  /// @notice The maximum weight cut percent (9-decimal precision). 1,000,000,000 = 100% cut per cycle (no issuance).
16
20
  uint32 public constant MAX_WEIGHT_CUT_PERCENT = 1_000_000_000;
17
21
 
22
+ /// @notice The sentinel address used to represent each chain's native token (ETH on mainnet, etc.).
23
+ address public constant NATIVE_TOKEN = address(0x000000000000000000000000000000000000EEEe);
24
+
25
+ /// @notice The accounting-context currency identifier for `NATIVE_TOKEN`. Derived from `NATIVE_TOKEN`'s address
26
+ /// through the same cast (`uint32(uint160(token))`) that `JBAccountingContext.currency` uses, so a comparison
27
+ /// against this constant identifies the native-token currency without recomputing the cast at each call site.
28
+ // forge-lint: disable-next-line(unsafe-typecast)
29
+ uint32 public constant NATIVE_TOKEN_CURRENCY = uint32(uint160(NATIVE_TOKEN));
30
+
18
31
  /// @notice The denominator for split percentages (9-decimal precision). A split of 1,000,000,000 = 100%.
19
32
  uint32 public constant SPLITS_TOTAL_PERCENT = 1_000_000_000;
20
33
 
21
- /// @notice The fee denominator. The protocol fee is `STANDARD_FEE / MAX_FEE`.
22
- uint16 public constant MAX_FEE = 1000;
23
-
24
34
  /// @notice The standard protocol fee numerator. The protocol fee is `STANDARD_FEE / MAX_FEE` = 2.5%.
25
35
  uint16 public constant STANDARD_FEE = 25;
26
36
  }
@@ -0,0 +1,170 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {IJBFeeTerminal} from "../interfaces/IJBFeeTerminal.sol";
5
+ import {JBFee} from "../structs/JBFee.sol";
6
+ import {JBFees} from "./JBFees.sol";
7
+
8
+ /// @notice External-library implementation of held-fee storage operations for `JBMultiTerminal`.
9
+ /// @dev Functions are `external` so they live in this library's deployed bytecode and are reached from the caller
10
+ /// terminal via `DELEGATECALL`, keeping terminal bytecode below the EIP-170 limit. Storage refs are passed as
11
+ /// parameters so the library reads/writes the caller's mappings.
12
+ library JBHeldFees {
13
+ /// @notice Returns held fees back to a project's balance based on the specified incoming amount.
14
+ /// @dev Walks unprocessed held fees in storage order. For each fee, checks if the incoming `amount` covers
15
+ /// the original net payout (gross minus fee). If yes, the fee is fully credited back and the entry is
16
+ /// tombstoned by advancing the index. If only part of the fee can be refunded, the entry's stored gross
17
+ /// amount is shrunk in place using the back-calculated fee so a future top-up can still settle the remainder.
18
+ /// @dev Partial refunds use `standardFeeAmountResultingIn` so repaying a dust amount cannot both credit the
19
+ /// payer project and leave the fee project owed the 1-unit minimum fee. Reads `caller` from a parameter
20
+ /// instead of `msg.sender` because the library runs under `DELEGATECALL` from the terminal — `msg.sender`
21
+ /// here is the original external caller of the terminal, not the meta-tx-aware caller the terminal would
22
+ /// emit. The terminal passes `_msgSender()` so the event carries the meta-tx sender as intended.
23
+ /// @param heldFeesOf The terminal's held-fee storage mapping.
24
+ /// @param nextHeldFeeIndexOf The terminal's per-project/token next-index storage mapping.
25
+ /// @param projectId The project to return held fees to.
26
+ /// @param token The token that the held fees are in.
27
+ /// @param amount The incoming amount available to match against held fees.
28
+ /// @param caller The address that triggered the return, forwarded into the `ReturnHeldFees` event.
29
+ /// @return returnedFees The total fee amount returned to the project (sum of fully and partially refunded fees).
30
+ function returnHeldFees(
31
+ mapping(uint256 => mapping(address => JBFee[])) storage heldFeesOf,
32
+ mapping(uint256 => mapping(address => uint256)) storage nextHeldFeeIndexOf,
33
+ uint256 projectId,
34
+ address token,
35
+ uint256 amount,
36
+ address caller
37
+ )
38
+ external
39
+ returns (uint256 returnedFees)
40
+ {
41
+ // The first slot not yet returned, processed, or forgiven. Earlier slots are tombstones and skipped.
42
+ uint256 startIndex = nextHeldFeeIndexOf[projectId][token];
43
+
44
+ // Upper bound for the loop. Returning held fees never appends new entries, so the live length is fixed
45
+ // for the duration of this call.
46
+ uint256 numberOfHeldFees = heldFeesOf[projectId][token].length;
47
+
48
+ // No live entries — nothing to refund. Early return keeps the gas cost predictable for projects that
49
+ // never held a fee.
50
+ if (startIndex >= numberOfHeldFees) return 0;
51
+
52
+ // Tracks how much of the incoming `amount` is still available to match against further held fees as the
53
+ // loop consumes them. Initialized to the full incoming amount.
54
+ uint256 leftoverAmount = amount;
55
+
56
+ // Tracks how far the live-entry window has advanced. Only persisted if it actually moves, to avoid an
57
+ // unnecessary SSTORE when the loop ends without fully refunding any fee.
58
+ uint256 newStartIndex = startIndex;
59
+
60
+ for (uint256 i = startIndex; i < numberOfHeldFees;) {
61
+ // Stop early once the incoming amount has been fully consumed. Remaining held fees stay live and
62
+ // can be refunded by a future top-up.
63
+ if (leftoverAmount == 0) break;
64
+
65
+ // Snapshot the held fee into memory so subsequent reads (`.amount`) don't re-fetch from storage.
66
+ JBFee memory heldFee = heldFeesOf[projectId][token][i];
67
+
68
+ // Recompute the standard fee that was originally withheld from this entry's gross amount.
69
+ uint256 feeAmount = JBFees.standardFeeAmountFrom(heldFee.amount);
70
+
71
+ // The net amount that originally left the project after the fee was withheld — the deposit threshold
72
+ // this incoming amount must clear to fully release the fee.
73
+ uint256 amountPaidOut = heldFee.amount - feeAmount;
74
+
75
+ if (leftoverAmount >= amountPaidOut) {
76
+ // Full refund: the incoming amount covers everything the project paid out for this entry, so
77
+ // the whole fee comes back. Consume `amountPaidOut` from `leftoverAmount` and advance the
78
+ // tombstone window past this slot.
79
+ unchecked {
80
+ leftoverAmount -= amountPaidOut;
81
+ returnedFees += feeAmount;
82
+ }
83
+ newStartIndex = i + 1;
84
+ } else {
85
+ // Partial refund: only some of the original payout has been redeposited. Back-calculate the
86
+ // fee that corresponds to the remaining net amount (using `standardFeeAmountResultingIn` so
87
+ // the fee project never gets shorted on dust). Shrink the stored gross amount by both the
88
+ // refunded net and its fee, leaving the slot live so the leftover can be refunded later.
89
+ feeAmount = JBFees.standardFeeAmountResultingIn(leftoverAmount);
90
+ unchecked {
91
+ // `JBFee.amount` is `uint224`; the subtraction operand is `uint256`. Narrow the operand to
92
+ // `uint224` for the in-place update. The held gross amount was itself stored as `uint224`
93
+ // and the partial refund is bounded above by it, so the narrowed subtrahend always fits.
94
+ // forge-lint: disable-next-line(unsafe-typecast)
95
+ heldFeesOf[projectId][token][i].amount -= uint224(leftoverAmount + feeAmount);
96
+ returnedFees += feeAmount;
97
+ }
98
+ // All of the incoming amount has been matched — exit the loop next iteration.
99
+ leftoverAmount = 0;
100
+ }
101
+ unchecked {
102
+ ++i;
103
+ }
104
+ }
105
+
106
+ // Persist the new tombstone boundary only if any entry was fully refunded — partial refunds leave the
107
+ // boundary alone so they can be revisited.
108
+ if (startIndex != newStartIndex) nextHeldFeeIndexOf[projectId][token] = newStartIndex;
109
+
110
+ // Emit through the interface qualifier so the event topic matches what `IJBFeeTerminal` declares. The
111
+ // log surfaces under the calling terminal's address (we're inside its DELEGATECALL frame), so off-chain
112
+ // consumers filter on the terminal as usual.
113
+ emit IJBFeeTerminal.ReturnHeldFees({
114
+ projectId: projectId,
115
+ token: token,
116
+ amount: amount,
117
+ returnedFees: returnedFees,
118
+ leftoverAmount: leftoverAmount,
119
+ caller: caller
120
+ });
121
+ }
122
+
123
+ /// @notice Returns up to `count` held fees for a project/token, starting from the next unprocessed index.
124
+ /// @param heldFeesOf The terminal's held-fee storage mapping (project => token => array).
125
+ /// @param nextHeldFeeIndexOf The terminal's per-project/token next-index storage mapping.
126
+ /// @param projectId The ID of the project to read held fees for.
127
+ /// @param token The token the fees are denominated in.
128
+ /// @param count The maximum number of held fees to return.
129
+ /// @return heldFees A view-only copy of the unprocessed held fees, in storage order.
130
+ function viewHeldFees(
131
+ mapping(uint256 => mapping(address => JBFee[])) storage heldFeesOf,
132
+ mapping(uint256 => mapping(address => uint256)) storage nextHeldFeeIndexOf,
133
+ uint256 projectId,
134
+ address token,
135
+ uint256 count
136
+ )
137
+ external
138
+ view
139
+ returns (JBFee[] memory heldFees)
140
+ {
141
+ // The first slot not yet returned, processed, or forgiven. Slots before this are tombstones (zeroed by
142
+ // `processHeldFeesOf` / `returnHeldFees`) and must be skipped so callers only see live fees.
143
+ uint256 startIndex = nextHeldFeeIndexOf[projectId][token];
144
+
145
+ // Total entries ever appended for this project/token (live + tombstoned). Used as the upper bound when
146
+ // bounding `count`.
147
+ uint256 numberOfHeldFees = heldFeesOf[projectId][token].length;
148
+
149
+ // Nothing live to return — either the array was never populated, or every entry has already been processed
150
+ // since the last full-array delete. Return an empty array so callers can branch on `.length == 0`.
151
+ if (startIndex >= numberOfHeldFees) return new JBFee[](0);
152
+
153
+ // Cap `count` to the number of live entries so we don't index past `length`. Keeps the common
154
+ // "ask for more than there is" case from over-allocating the return buffer.
155
+ if (startIndex + count > numberOfHeldFees) count = numberOfHeldFees - startIndex;
156
+
157
+ // Allocate the return buffer at exactly the size we'll fill — saves the caller from filtering trailing
158
+ // empty entries.
159
+ heldFees = new JBFee[](count);
160
+
161
+ // Copy live entries from storage into the return buffer. `++i` is unchecked because `i < count` already
162
+ // bounds it well below `type(uint256).max`.
163
+ for (uint256 i; i < count;) {
164
+ heldFees[i] = heldFeesOf[projectId][token][startIndex + i];
165
+ unchecked {
166
+ ++i;
167
+ }
168
+ }
169
+ }
170
+ }
@@ -2,11 +2,22 @@
2
2
  pragma solidity ^0.8.0;
3
3
 
4
4
  /// @custom:member amount The total amount the fee was taken from, as a fixed point number with the same number of
5
- /// decimals as the token's accounting context.
5
+ /// decimals as the token's accounting context. `uint224` covers any realistic per-call fee basis (max
6
+ /// ~2.7e49 ETH-equivalent at 18 decimals) and is the same width the protocol's payout/surplus-allowance limits use.
7
+ /// @custom:member referralChainId The EIP-155 chain ID of the referrer's home chain, captured from the upper bits of
8
+ /// the transient referral encoding at fee-take time. A value of `0` is the "current chain" sentinel — a referrer that
9
+ /// only encoded a project ID without a chain prefix is credited on the chain the fee is paid on. Packing this beside
10
+ /// `amount` in the same storage slot avoids growing `JBFee` to a third slot.
6
11
  /// @custom:member beneficiary The address that will receive the tokens that are minted as a result of the fee payment.
7
12
  /// @custom:member unlockTimestamp The timestamp at which the fee is unlocked and can be processed.
13
+ /// @custom:member referralProjectId The referrer's project ID on `referralChainId`. Captured from the lower bits of
14
+ /// `currentReferralProjectId` at fee-take time so held-fee attribution survives the 28-day hold window. The on-chain
15
+ /// transient slot, function argument, and `feeVolumeByReferralOf` mapping key all use the packed `uint256` form
16
+ /// `(referralChainId << 48) | referralProjectId`; this struct stores the two halves separately for cheap storage.
8
17
  struct JBFee {
9
- uint256 amount;
18
+ uint224 amount;
19
+ uint32 referralChainId;
10
20
  address beneficiary;
11
21
  uint48 unlockTimestamp;
22
+ uint48 referralProjectId;
12
23
  }
@@ -18,7 +18,8 @@ contract MaliciousPayoutBeneficiary is IERC721Receiver, Test {
18
18
  amount: 5 * 10 ** 18,
19
19
  currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
20
20
  token: JBConstants.NATIVE_TOKEN,
21
- minTokensPaidOut: 0
21
+ minTokensPaidOut: 0,
22
+ referralProjectId: 0
22
23
  });
23
24
  assertEq(_reentrantPayout, 0);
24
25
  }
@@ -49,7 +50,8 @@ contract MaliciousAllowanceBeneficiary is IERC721Receiver, Test {
49
50
  minTokensPaidOut: 0,
50
51
  beneficiary: payable(address(this)),
51
52
  feeBeneficiary: payable(0x000000000000000000000000000000000000007B),
52
- memo: "MEMO"
53
+ memo: "MEMO",
54
+ referralProjectId: 0
53
55
  });
54
56
  }
55
57