@bananapus/core-v6 0.0.55 → 0.0.57

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.55",
3
+ "version": "0.0.57",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-core-v6'"
39
39
  },
40
40
  "dependencies": {
41
- "@bananapus/permission-ids-v6": "^0.0.23",
41
+ "@bananapus/permission-ids-v6": "^0.0.26",
42
42
  "@chainlink/contracts": "1.5.0",
43
43
  "@openzeppelin/contracts": "5.6.1",
44
44
  "@prb/math": "4.1.1",
@@ -67,6 +67,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
67
67
  error JBController_NoReservedTokens(uint256 projectId);
68
68
  error JBController_OnlyDirectory(address sender, IJBDirectory directory);
69
69
  error JBController_PendingReservedTokens(uint256 pendingReservedTokenBalance);
70
+ error JBController_ReservedTokenSplitProjectSameAsOwner(uint256 projectId);
70
71
  error JBController_RulesetsAlreadyLaunched(uint256 projectId);
71
72
  error JBController_RulesetsArrayEmpty(uint256 projectId, uint256 rulesetConfigurationCount);
72
73
  error JBController_RulesetSetTokenNotAllowed(uint256 projectId);
@@ -382,8 +383,10 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
382
383
  /// @notice Creates a new Juicebox project in one transaction — mints the project NFT, queues initial rulesets,
383
384
  /// and
384
385
  /// configures terminals. This is the primary entry point for launching a project.
385
- /// @dev Anyone can call this on behalf of any owner. Each sub-operation (mint, queue, configure) can also be done
386
- /// individually if needed.
386
+ /// @dev Anyone can call this on behalf of any owner. This is a launch convenience, not owner authorization proof:
387
+ /// frontends and operators must use the transaction sender, an explicit owner signature, or their own deployment
388
+ /// flow to decide whether the owner intentionally launched a configuration. Each sub-operation (mint, queue,
389
+ /// configure) can also be done individually if needed.
387
390
  /// @param owner The project's owner. The project ERC-721 will be minted to this address.
388
391
  /// @param projectUri The project's metadata URI. This is typically an IPFS hash, optionally with the `ipfs://`
389
392
  /// prefix. This can be updated by the project's owner.
@@ -1117,7 +1120,18 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
1117
1120
  // sender.
1118
1121
  address beneficiary = split.beneficiary != address(0) ? split.beneficiary : messageSender;
1119
1122
 
1123
+ // Reserved token splits with no project ID mint directly to the beneficiary. A non-zero project ID
1124
+ // takes the "pay another project" path, which treats the reserved tokens as revenue for the
1125
+ // destination project and can mint another batch of tokens according to its ruleset.
1120
1126
  if (split.projectId != 0) {
1127
+ if (split.projectId == projectId) {
1128
+ // The source project is not a valid destination for the terminal-payment path.
1129
+ // `sendReservedTokensToSplitsOf` clears the pending reserve balance before this helper
1130
+ // runs. Paying those freshly minted reserves into the same project's terminal would book
1131
+ // them as new revenue, mint another batch of tokens, and start the reserve cycle again.
1132
+ revert JBController_ReservedTokenSplitProjectSameAsOwner({projectId: projectId});
1133
+ }
1134
+
1121
1135
  // Get a reference to the receiving project's primary payment terminal for the token.
1122
1136
  IJBTerminal terminal = token == IJBToken(address(0))
1123
1137
  ? IJBTerminal(address(0))
@@ -87,20 +87,26 @@ contract JBFeelessAddresses is Ownable, IJBFeelessAddresses, IERC165 {
87
87
  // ------------------------- external views -------------------------- //
88
88
  //*********************************************************************//
89
89
 
90
- /// @notice Returns whether the specified address is feeless for a specific project, considering the wildcard
91
- /// (projectId 0) feeless status, the project-specific feeless status, and the feeless hook (if set).
92
- /// @dev The hook is invoked via try/catch a reverting or out-of-gas hook is treated as `false` so it cannot
93
- /// brick the fee path in terminals.
90
+ /// @notice Returns whether the specified address is feeless for a specific project, providing the outer caller
91
+ /// of the fee-bearing operation so hooks can scope grants by who initiated the action.
92
+ /// @dev The static admin-set mappings are caller-agnostic and always apply. The hook (if set) receives `caller`
93
+ /// (typically the terminal's `_msgSender()`) and may use it to narrow its grant — e.g. grant an ecosystem
94
+ /// router feeless cash-outs only when the router itself is the caller, not when it appears as a split recipient
95
+ /// of someone else's payout. Hook is invoked via try/catch — a reverting hook is treated as `false`.
94
96
  /// @param addr The address to check.
95
97
  /// @param projectId The ID of the project to check.
96
- /// @return A flag indicating whether the address is feeless (globally, for the project, or per the hook).
97
- function isFeelessFor(address addr, uint256 projectId) external view override returns (bool) {
98
+ /// @param caller The outer caller (typically the terminal's `_msgSender()`). Pass `address(0)` for lookups
99
+ /// without caller context.
100
+ /// @return A flag indicating whether the address is feeless.
101
+ function isFeelessFor(address addr, uint256 projectId, address caller) external view override returns (bool) {
102
+ // Static grants are administrative and do not depend on who triggered the fee-bearing operation.
98
103
  if (_isFeelessFor[0][addr] || _isFeelessFor[projectId][addr]) return true;
99
104
 
100
105
  IJBFeelessHook hook = feelessHook;
101
106
  if (address(hook) == address(0)) return false;
102
107
 
103
- try hook.isFeeless({projectId: projectId, addr: addr}) returns (bool result) {
108
+ // Hook grants are optional and caller-aware; a reverting hook is treated as "not feeless".
109
+ try hook.isFeeless({projectId: projectId, addr: addr, caller: caller}) returns (bool result) {
104
110
  return result;
105
111
  } catch {
106
112
  return false;
@@ -18,6 +18,9 @@ contract JBFundAccessLimits is JBControlled, IJBFundAccessLimits {
18
18
  // --------------------------- custom errors ------------------------- //
19
19
  //*********************************************************************//
20
20
 
21
+ error JBFundAccessLimits_DuplicateFundAccessLimitGroup(
22
+ uint256 projectId, uint256 rulesetId, uint256 groupIndex, address terminal, address token
23
+ );
21
24
  error JBFundAccessLimits_InvalidPayoutLimitCurrencyOrdering(
22
25
  uint256 projectId, uint256 rulesetId, uint256 groupIndex, uint256 limitIndex
23
26
  );
@@ -95,6 +98,28 @@ contract JBFundAccessLimits is JBControlled, IJBFundAccessLimits {
95
98
  // Set the limits being iterated on.
96
99
  JBFundAccessLimitGroup calldata fundAccessLimitGroup = fundAccessLimitGroups[i];
97
100
 
101
+ // Each terminal/token pair should have exactly one group. The per-group currency ordering checks below
102
+ // prevent duplicate currencies inside one group; this prevents splitting a duplicate currency across two
103
+ // groups for the same terminal/token pair.
104
+ for (uint256 j; j < i;) {
105
+ JBFundAccessLimitGroup calldata previousGroup = fundAccessLimitGroups[j];
106
+ if (
107
+ previousGroup.terminal == fundAccessLimitGroup.terminal
108
+ && previousGroup.token == fundAccessLimitGroup.token
109
+ ) {
110
+ revert JBFundAccessLimits_DuplicateFundAccessLimitGroup({
111
+ projectId: projectId,
112
+ rulesetId: rulesetId,
113
+ groupIndex: i,
114
+ terminal: fundAccessLimitGroup.terminal,
115
+ token: fundAccessLimitGroup.token
116
+ });
117
+ }
118
+ unchecked {
119
+ ++j;
120
+ }
121
+ }
122
+
98
123
  // Keep a reference to the number of payout limits.
99
124
  uint256 numberOfPayoutLimits = fundAccessLimitGroup.payoutLimits.length;
100
125
 
@@ -64,14 +64,16 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
64
64
  //*********************************************************************//
65
65
 
66
66
  error JBMultiTerminal_FeeTerminalNotFound(address token);
67
- error JBMultiTerminal_MintNotAllowed(uint256 projectId, uint256 splitProjectId, address terminal);
67
+ error JBMultiTerminal_MintNotAllowed();
68
68
  error JBMultiTerminal_NoMsgValueAllowed(uint256 value);
69
69
  error JBMultiTerminal_OverflowAlert(uint256 value, uint256 limit);
70
70
  error JBMultiTerminal_PermitAllowanceNotEnough(uint256 amount, uint256 allowance);
71
71
  error JBMultiTerminal_RecipientProjectTerminalNotFound(uint256 projectId, address token);
72
+ error JBMultiTerminal_ReentrantTokenTransfer(address token);
72
73
  error JBMultiTerminal_SplitHookInvalid(IJBSplitHook hook);
73
- error JBMultiTerminal_TerminalTokensIncompatible(uint256 projectId, address token, IJBTerminal terminal);
74
74
  error JBMultiTerminal_TemporaryAllowanceNotConsumed(address token, address spender, uint256 allowance);
75
+ error JBMultiTerminal_TerminalMigrationToSelf(uint256 projectId, address token);
76
+ error JBMultiTerminal_TerminalTokensIncompatible(uint256 projectId, address token, IJBTerminal terminal);
75
77
  error JBMultiTerminal_TokenNotAccepted(address token);
76
78
  error JBMultiTerminal_UnderMin(uint256 value, uint256 min);
77
79
 
@@ -140,6 +142,18 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
140
142
  /// @custom:param token The token the fees are held in.
141
143
  mapping(uint256 projectId => mapping(address token => uint256)) internal _nextHeldFeeIndexOf;
142
144
 
145
+ //*********************************************************************//
146
+ // ------------------- transient stored properties ------------------- //
147
+ //*********************************************************************//
148
+
149
+ /// @notice Whether this terminal is currently measuring an incoming ERC-20 balance delta.
150
+ bool transient _acceptingToken;
151
+
152
+ /// @notice Source project ID for the same-terminal split pay currently being recorded.
153
+ /// @dev After `_pay` consumes and clears this value, `_fulfillPayHookSpecificationsFor` reuses the slot to return
154
+ /// the fee basis to `executePayout`.
155
+ uint256 transient _internalSplitPayProjectId;
156
+
143
157
  //*********************************************************************//
144
158
  // -------------------------- constructor ---------------------------- //
145
159
  //*********************************************************************//
@@ -326,6 +340,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
326
340
  }
327
341
 
328
342
  if (!_isFeeless({addr: address(split.hook), projectId: projectId})) {
343
+ // Split hooks pull funds out of this terminal, so non-feeless hooks receive the net amount after
344
+ // the standard terminal fee.
329
345
  unchecked {
330
346
  netPayoutAmount -= _feeAmountFrom(amount);
331
347
  }
@@ -347,6 +363,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
347
363
  } else if (split.projectId != 0) {
348
364
  // Get a reference to the terminal being used.
349
365
  IJBTerminal terminal = _primaryTerminalOf({projectId: split.projectId, token: token});
366
+ bool isThisTerminal = terminal == this;
350
367
 
351
368
  // The project must have a terminal to send funds to.
352
369
  if (terminal == IJBTerminal(address(0))) {
@@ -358,7 +375,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
358
375
  // the fee model taxes value leaving the protocol ecosystem, not internal rebalancing.
359
376
  // This payout is eligible for a fee if the funds are leaving this contract and the receiving terminal isn't
360
377
  // a feeless address.
361
- if (terminal != this && !_isFeeless({addr: address(terminal), projectId: projectId})) {
378
+ if (!isThisTerminal && !_isFeeless({addr: address(terminal), projectId: projectId})) {
379
+ // Cross-terminal payouts leave this terminal's custody, so charge the standard terminal fee unless
380
+ // the recipient terminal is feeless.
362
381
  unchecked {
363
382
  netPayoutAmount -= _feeAmountFrom(amount);
364
383
  }
@@ -382,19 +401,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
382
401
  // consumption, payout-limit drawdown, fee-free-surplus accounting); routing it
383
402
  // through `sendPayoutsOf` is never the right surface.
384
403
  // The try-catch in the split group lib catches this revert and restores the balance.
385
- if (split.projectId == projectId) {
386
- revert JBMultiTerminal_MintNotAllowed({
387
- projectId: projectId, splitProjectId: split.projectId, terminal: address(terminal)
388
- });
389
- }
390
-
391
- // Track the fee-free payout amount. During cashout at zero tax rate, fees apply
392
- // only up to this accumulated amount, preventing round-trip fee bypass. Same-project
393
- // splits would inflate this counter against the project's own future zero-tax cashouts
394
- // but are already excluded by the self-reference revert above.
395
- if (terminal == this) {
396
- _feeFreeSurplusOf[split.projectId][token] += netPayoutAmount;
397
- }
404
+ if (split.projectId == projectId) revert JBMultiTerminal_MintNotAllowed();
398
405
 
399
406
  // Send the `projectId` in the metadata as a referral.
400
407
  bytes memory metadata = bytes(abi.encodePacked(projectId));
@@ -408,10 +415,20 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
408
415
  amount: netPayoutAmount,
409
416
  metadata: metadata
410
417
  });
418
+
419
+ // Same-terminal adds never invoke destination pay hooks, so the full amount remains in the
420
+ // destination project's balance and must be fee-liable on its later zero-tax cashout.
421
+ if (isThisTerminal) _feeFreeSurplusOf[split.projectId][token] += netPayoutAmount;
411
422
  } else {
412
423
  // Keep a reference to the beneficiary of the payment.
413
424
  address beneficiary = split.beneficiary != address(0) ? split.beneficiary : originalMessageSender;
414
425
 
426
+ // Mark same-terminal split pays so `_pay` can fee pay-hook forwards inline and track only retained
427
+ // value as fee-free surplus.
428
+ if (isThisTerminal) {
429
+ _internalSplitPayProjectId = projectId;
430
+ }
431
+
415
432
  _efficientPay({
416
433
  terminal: terminal,
417
434
  projectId: split.projectId,
@@ -421,12 +438,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
421
438
  metadata: metadata
422
439
  });
423
440
 
424
- // Cap fee-free surplus at remaining balance.
425
- // Why: _feeFreeSurplusOf was incremented by the full netPayoutAmount above, but if the
426
- // destination project's data hook forwarded part of the payment to pay hooks, the store
427
- // only recorded a partial balance increase. Without this cap, _feeFreeSurplusOf can exceed
428
- // STORE.balanceOf, causing users to be overcharged fees on zero-tax cashouts.
429
- _capFeeFreeSurplus({projectId: split.projectId, token: token});
441
+ if (isThisTerminal) {
442
+ feeEligibleAmount += _internalSplitPayProjectId;
443
+ delete _internalSplitPayProjectId;
444
+ }
430
445
  }
431
446
  } else {
432
447
  // If there's a beneficiary, send the funds directly to the beneficiary.
@@ -437,6 +452,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
437
452
  // This payout is eligible for a fee since the funds are leaving this contract and the recipient isn't a
438
453
  // feeless address.
439
454
  if (!_isFeeless({addr: recipient, projectId: projectId})) {
455
+ // Direct payouts leave the terminal, so non-feeless recipients receive the net amount after the
456
+ // standard terminal fee.
440
457
  unchecked {
441
458
  netPayoutAmount -= _feeAmountFrom(amount);
442
459
  }
@@ -521,6 +538,12 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
521
538
  account: _ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.MIGRATE_TERMINAL
522
539
  });
523
540
 
541
+ // Migrating to the same terminal would zero this terminal's store balance and then try to re-add it through
542
+ // the external terminal interface. ERC-20 self-transfers produce no balance delta, leaving funds stranded.
543
+ if (address(to) == address(this)) {
544
+ revert JBMultiTerminal_TerminalMigrationToSelf({projectId: projectId, token: token});
545
+ }
546
+
524
547
  // The terminal being migrated to must accept the same token as this terminal.
525
548
  if (to.accountingContextForTokenOf({projectId: projectId, token: token}).currency == 0) {
526
549
  revert JBMultiTerminal_TerminalTokensIncompatible({projectId: projectId, token: token, terminal: to});
@@ -545,6 +568,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
545
568
  // This also settles any fee-free surplus liability that would otherwise be lost on the new terminal.
546
569
  uint256 feeAmount;
547
570
  if (!_isFeeless({addr: address(to), projectId: projectId}) && projectId != _FEE_BENEFICIARY_PROJECT_ID) {
571
+ // Fee processing failures never block migration. If the fee route is broken, `_processFee` credits
572
+ // the fee amount back to this source terminal and emits `FeeReverted`; the post-fee amount still
573
+ // migrates so project funds are not trapped behind project #1 routing issues.
548
574
  feeAmount = _takeFeeFrom({
549
575
  projectId: projectId,
550
576
  token: token,
@@ -668,7 +694,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
668
694
  delete _heldFeesOf[projectId][token][currentIndex];
669
695
  _nextHeldFeeIndexOf[projectId][token] = currentIndex + 1;
670
696
 
671
- // Process the fee.
697
+ // Process the standard fee on the original gross amount recorded when the held fee was created.
672
698
  _processFee({
673
699
  projectId: projectId,
674
700
  token: token,
@@ -1042,11 +1068,20 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1042
1068
  // Get a reference to the balance before receiving tokens.
1043
1069
  uint256 balanceBefore = _balanceOf(token);
1044
1070
 
1071
+ // Prevent callback-capable tokens from nesting another incoming ERC-20 transfer inside this balance-delta
1072
+ // measurement.
1073
+ if (_acceptingToken) revert JBMultiTerminal_ReentrantTokenTransfer(token);
1074
+ _acceptingToken = true;
1075
+
1045
1076
  // Transfer tokens to this terminal from the msg sender.
1046
1077
  _transferFrom({from: _msgSender(), to: payable(address(this)), token: token, amount: amount});
1047
1078
 
1048
1079
  // The amount should reflect the change in balance.
1049
- return _balanceOf(token) - balanceBefore;
1080
+ uint256 acceptedAmount = _balanceOf(token) - balanceBefore;
1081
+
1082
+ _acceptingToken = false;
1083
+
1084
+ return acceptedAmount;
1050
1085
  }
1051
1086
 
1052
1087
  /// @notice Adds funds to a project's balance without minting tokens.
@@ -1360,6 +1395,34 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1360
1395
  }
1361
1396
  }
1362
1397
 
1398
+ /// @notice Emits a `Pay` event. Extracted from `_pay` so the 9-field event payload gets its own stack frame —
1399
+ /// inlining the emit into `_pay` overflows the non-IR build's stack budget.
1400
+ function _emitPay(
1401
+ JBRuleset memory ruleset,
1402
+ uint256 projectId,
1403
+ address payer,
1404
+ address beneficiary,
1405
+ uint256 amount,
1406
+ uint256 newlyIssuedTokenCount,
1407
+ string memory memo,
1408
+ bytes memory metadata
1409
+ )
1410
+ internal
1411
+ {
1412
+ emit Pay({
1413
+ rulesetId: ruleset.id,
1414
+ rulesetCycleNumber: ruleset.cycleNumber,
1415
+ projectId: projectId,
1416
+ payer: payer,
1417
+ beneficiary: beneficiary,
1418
+ amount: amount,
1419
+ newlyIssuedTokenCount: newlyIssuedTokenCount,
1420
+ memo: memo,
1421
+ metadata: metadata,
1422
+ caller: _msgSender()
1423
+ });
1424
+ }
1425
+
1363
1426
  /// @notice Fund a project on another terminal by granting a temporary pull allowance for this call only.
1364
1427
  /// @param terminal The recipient terminal.
1365
1428
  /// @param projectId The ID of the project to fund.
@@ -1447,7 +1510,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1447
1510
  continue;
1448
1511
  }
1449
1512
 
1450
- // Get the fee for the specified amount.
1513
+ // Cash-out hooks receive the net amount after the standard fee unless the hook is feeless.
1451
1514
  uint256 specificationAmountFee = _isFeeless({addr: address(specification.hook), projectId: projectId})
1452
1515
  ? 0
1453
1516
  : _feeAmountFrom(specification.amount);
@@ -1503,6 +1566,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1503
1566
  /// @param beneficiary The address which will receive any tokens that the payment yields.
1504
1567
  /// @param newlyIssuedTokenCount The number of tokens issued and sent to the beneficiary.
1505
1568
  /// @param metadata Bytes to send along to the emitted event and pay hooks as applicable.
1569
+ /// @param internalSplitPayProjectId The source project when this payment came from a same-terminal split.
1506
1570
  function _fulfillPayHookSpecificationsFor(
1507
1571
  uint256 projectId,
1508
1572
  JBPayHookSpecification[] memory specifications,
@@ -1511,10 +1575,13 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1511
1575
  JBRuleset memory ruleset,
1512
1576
  address beneficiary,
1513
1577
  uint256 newlyIssuedTokenCount,
1514
- bytes memory metadata
1578
+ bytes memory metadata,
1579
+ uint256 internalSplitPayProjectId
1515
1580
  )
1516
1581
  internal
1517
1582
  {
1583
+ uint256 amountEligibleForFees;
1584
+
1518
1585
  // Keep a reference to payment context for the pay hooks.
1519
1586
  JBAfterPayRecordedContext memory context = JBAfterPayRecordedContext({
1520
1587
  payer: payer,
@@ -1542,9 +1609,25 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1542
1609
  continue;
1543
1610
  }
1544
1611
 
1612
+ uint256 specificationAmount = specification.amount;
1613
+
1614
+ if (
1615
+ internalSplitPayProjectId != 0
1616
+ && !_isFeeless({addr: address(specification.hook), projectId: internalSplitPayProjectId})
1617
+ ) {
1618
+ // Same-terminal split pays defer source-side fees until the destination data hook is known. Net
1619
+ // non-feeless hook forwards here so they match ordinary payout semantics before funds leave.
1620
+ // Keep the fee basis local until every hook returns. Writing the transient accumulator before the
1621
+ // hook call would let a reentrant payout overwrite the outer split's pending fee basis.
1622
+ unchecked {
1623
+ amountEligibleForFees += specificationAmount;
1624
+ specificationAmount -= _feeAmountFrom(specificationAmount);
1625
+ }
1626
+ }
1627
+
1545
1628
  // Pass the correct token `forwardedAmount` to the hook.
1546
1629
  context.forwardedAmount = JBTokenAmount({
1547
- value: specification.amount,
1630
+ value: specificationAmount,
1548
1631
  token: tokenAmount.token,
1549
1632
  decimals: tokenAmount.decimals,
1550
1633
  currency: tokenAmount.currency
@@ -1556,7 +1639,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1556
1639
  // Trigger any inherited pre-transfer logic.
1557
1640
  // Keep a reference to the amount that'll be paid as a `msg.value`.
1558
1641
  uint256 payValue = _beforeTransferTo({
1559
- to: address(specification.hook), token: tokenAmount.token, amount: specification.amount
1642
+ to: address(specification.hook), token: tokenAmount.token, amount: specificationAmount
1560
1643
  });
1561
1644
 
1562
1645
  // Fulfill the specification.
@@ -1568,13 +1651,17 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1568
1651
  emit HookAfterRecordPay({
1569
1652
  hook: specification.hook,
1570
1653
  context: context,
1571
- specificationAmount: specification.amount,
1654
+ specificationAmount: specificationAmount,
1572
1655
  caller: _msgSender()
1573
1656
  });
1574
1657
  unchecked {
1575
1658
  ++i;
1576
1659
  }
1577
1660
  }
1661
+
1662
+ // `_pay` consumed and cleared the source project ID before calling hooks, so the transient slot can now carry
1663
+ // the hook-derived fee basis back to `executePayout`. Publish it only after all untrusted hook calls return.
1664
+ _internalSplitPayProjectId = amountEligibleForFees;
1578
1665
  }
1579
1666
 
1580
1667
  /// @notice Internal implementation of payment logic. Records the payment in the store, mints tokens via the
@@ -1600,6 +1687,11 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1600
1687
  )
1601
1688
  internal
1602
1689
  {
1690
+ // Same-terminal split pays are the only inbound pays whose source-side fee was intentionally deferred. Cache
1691
+ // and clear the transient source project before untrusted data/pay hooks can reenter ordinary pay flows.
1692
+ uint256 internalSplitPayProjectId = _internalSplitPayProjectId;
1693
+ if (internalSplitPayProjectId != 0) delete _internalSplitPayProjectId;
1694
+
1603
1695
  // Keep a reference to the token amount to forward to the store.
1604
1696
  JBTokenAmount memory tokenAmount = _tokenAmountOf({projectId: projectId, token: token, value: amount});
1605
1697
 
@@ -1611,6 +1703,20 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1611
1703
  payer: payer, amount: tokenAmount, projectId: projectId, beneficiary: beneficiary, metadata: metadata
1612
1704
  });
1613
1705
 
1706
+ // Only the value retained in the destination balance needs later cashout fee recovery. Non-feeless pay-hook
1707
+ // forwards pay their source-equivalent fee inline before leaving the project.
1708
+ if (internalSplitPayProjectId != 0) {
1709
+ uint256 feeFreeAmount = tokenAmount.value;
1710
+ for (uint256 i; i < hookSpecifications.length;) {
1711
+ // The store already proved the hook-spec total does not exceed the pay amount.
1712
+ unchecked {
1713
+ feeFreeAmount -= hookSpecifications[i].amount;
1714
+ ++i;
1715
+ }
1716
+ }
1717
+ _feeFreeSurplusOf[projectId][token] += feeFreeAmount;
1718
+ }
1719
+
1614
1720
  // Keep a reference to the number of tokens issued for the beneficiary.
1615
1721
  uint256 newlyIssuedTokenCount;
1616
1722
 
@@ -1628,17 +1734,19 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1628
1734
  });
1629
1735
  }
1630
1736
 
1631
- emit Pay({
1632
- rulesetId: ruleset.id,
1633
- rulesetCycleNumber: ruleset.cycleNumber,
1737
+ // `_pay` already carries ~10 locals (ruleset, tokenCount, hookSpecifications, balanceDiff, tokenAmount,
1738
+ // newlyIssuedTokenCount, internalSplitPayProjectId, feeFreeAmount, plus loop-local `i` and `hookAmount`).
1739
+ // Inlining the 9-arg `emit Pay` here hits "Stack too deep" under the non-IR build, so the emit is extracted
1740
+ // to `_emitPay` which gets its own stack frame.
1741
+ _emitPay({
1742
+ ruleset: ruleset,
1634
1743
  projectId: projectId,
1635
1744
  payer: payer,
1636
1745
  beneficiary: beneficiary,
1637
1746
  amount: amount,
1638
1747
  newlyIssuedTokenCount: newlyIssuedTokenCount,
1639
1748
  memo: memo,
1640
- metadata: metadata,
1641
- caller: _msgSender()
1749
+ metadata: metadata
1642
1750
  });
1643
1751
 
1644
1752
  // If the data hook returned pay hook specifications, fulfill them.
@@ -1651,7 +1759,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1651
1759
  ruleset: ruleset,
1652
1760
  beneficiary: beneficiary,
1653
1761
  newlyIssuedTokenCount: newlyIssuedTokenCount,
1654
- metadata: metadata
1762
+ metadata: metadata,
1763
+ internalSplitPayProjectId: internalSplitPayProjectId
1655
1764
  });
1656
1765
  }
1657
1766
  }
@@ -1663,6 +1772,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1663
1772
  /// @param beneficiary The address which will receive any platform tokens minted.
1664
1773
  /// @param feeTerminal The terminal that'll receive the fee.
1665
1774
  /// @param wasHeld A flag indicating if the fee to process was held by this terminal.
1775
+ /// @dev Fee-route failures are forgiven instead of reverted so project funds cannot be trapped by project #1
1776
+ /// misconfiguration. The failed fee amount is credited back to the payer project's balance on this terminal.
1666
1777
  function _processFee(
1667
1778
  uint256 projectId,
1668
1779
  address token,
@@ -1685,10 +1796,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1685
1796
  caller: _msgSender()
1686
1797
  });
1687
1798
  } catch (bytes memory reason) {
1688
- // Fee processing failed intentionally forgive the fee and return the amount to the project.
1689
- // The held-fee entry (if any) was already deleted by `processHeldFeesOf` before this call, so there is no
1690
- // retry path. This is by design: a broken or misconfigured fee route should not permanently lock project
1691
- // funds. The `FeeReverted` event makes this observable off-chain.
1799
+ // Fee processing is fail-open for project liveness: a broken project #1 terminal or fee route must not
1800
+ // trap payouts, cash outs, allowances, held-fee processing, or terminal migration. The fee is forgiven,
1801
+ // credited back to the originating project on this terminal, and surfaced through `FeeReverted`.
1692
1802
  emit FeeReverted({
1693
1803
  projectId: projectId,
1694
1804
  token: token,
@@ -1741,6 +1851,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1741
1851
  // Held fees store the original gross amount that paid out before its fee was removed.
1742
1852
  JBFee memory heldFee = _heldFeesOf[projectId][token][i];
1743
1853
 
1854
+ // Recompute the standard fee associated with the held gross amount.
1744
1855
  uint256 feeAmount = _feeAmountFrom(heldFee.amount);
1745
1856
 
1746
1857
  // This is the net amount that originally left the project after the held fee was removed.
@@ -1906,7 +2017,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1906
2017
  /// accounting context.
1907
2018
  /// @param beneficiary The address to mint the platform's project's tokens for.
1908
2019
  /// @param shouldHoldFees If fees should be tracked and held instead of processing them immediately.
1909
- /// @return feeAmount The amount of the fee taken.
2020
+ /// @return feeAmount The fee withheld from the current outflow. If immediate fee processing fails, `_processFee`
2021
+ /// credits this amount back to the payer project while the current outflow continues.
1910
2022
  function _takeFeeFrom(
1911
2023
  uint256 projectId,
1912
2024
  address token,
@@ -2112,11 +2224,14 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
2112
2224
  }
2113
2225
 
2114
2226
  /// @notice Returns a flag indicating if interacting with an address should not incur fees.
2227
+ /// @dev Forwards `_msgSender()` (the outer caller of the terminal, with ERC-2771 forwarders unwrapped) to the
2228
+ /// registry so an installed feeless hook can scope its grant by caller — e.g. recognise an ecosystem router
2229
+ /// that wraps cash-out → pay and grant it fee-free cash-outs only when it itself is the caller.
2115
2230
  /// @param addr The address to check.
2116
2231
  /// @param projectId The ID of the project to check the per-project feeless status for.
2117
2232
  /// @return A flag indicating if the address should not incur fees (globally or for the project).
2118
2233
  function _isFeeless(address addr, uint256 projectId) internal view returns (bool) {
2119
- return FEELESS_ADDRESSES.isFeelessFor({addr: addr, projectId: projectId});
2234
+ return FEELESS_ADDRESSES.isFeelessFor({addr: addr, projectId: projectId, caller: _msgSender()});
2120
2235
  }
2121
2236
 
2122
2237
  /// @notice The calldata. Preferred to use over `msg.data`.
package/src/JBPrices.sol CHANGED
@@ -27,6 +27,7 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
27
27
 
28
28
  error JBPrices_PriceFeedAlreadyExists(IJBPriceFeed feed);
29
29
  error JBPrices_PriceFeedNotFound(uint256 projectId, uint256 pricingCurrency, uint256 unitCurrency);
30
+ error JBPrices_ZeroPrice(uint256 projectId, uint256 pricingCurrency, uint256 unitCurrency, IJBPriceFeed feed);
30
31
  error JBPrices_ZeroPricingCurrency(uint256 projectId, uint256 pricingCurrency);
31
32
  error JBPrices_ZeroUnitCurrency(uint256 projectId, uint256 unitCurrency);
32
33
 
@@ -191,8 +192,16 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
191
192
  // Get a reference to the price feed.
192
193
  IJBPriceFeed feed = priceFeedFor[projectId][pricingCurrency][unitCurrency];
193
194
 
194
- // If the feed exists, return its price.
195
- if (feed != IJBPriceFeed(address(0))) return feed.currentUnitPrice(decimals);
195
+ // If the feed exists, return its non-zero price.
196
+ if (feed != IJBPriceFeed(address(0))) {
197
+ uint256 price = feed.currentUnitPrice(decimals);
198
+ if (price == 0) {
199
+ revert JBPrices_ZeroPrice({
200
+ projectId: projectId, pricingCurrency: pricingCurrency, unitCurrency: unitCurrency, feed: feed
201
+ });
202
+ }
203
+ return price;
204
+ }
196
205
 
197
206
  // Try getting the inverse feed.
198
207
  feed = priceFeedFor[projectId][unitCurrency][pricingCurrency];
@@ -202,7 +211,13 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
202
211
  // is in the range of ~1e9 to ~1e27 (for 18 decimals). Extreme prices outside this range may lose
203
212
  // significant precision due to fixed-point division truncation.
204
213
  if (feed != IJBPriceFeed(address(0))) {
205
- return mulDiv({x: 10 ** decimals, y: 10 ** decimals, denominator: feed.currentUnitPrice(decimals)});
214
+ uint256 inversePrice = feed.currentUnitPrice(decimals);
215
+ if (inversePrice == 0) {
216
+ revert JBPrices_ZeroPrice({
217
+ projectId: projectId, pricingCurrency: unitCurrency, unitCurrency: pricingCurrency, feed: feed
218
+ });
219
+ }
220
+ return mulDiv({x: 10 ** decimals, y: 10 ** decimals, denominator: inversePrice});
206
221
  }
207
222
 
208
223
  // Check for a default feed (project ID 0) if not found.
package/src/JBSplits.sol CHANGED
@@ -173,14 +173,30 @@ contract JBSplits is JBControlled, IJBSplits {
173
173
  uint256 numberOfCurrentSplits = currentSplits.length;
174
174
 
175
175
  // Check to see if all locked splits are included in the array of splits which is being set.
176
+ // Duplicate locked splits must be preserved with the same multiplicity. Otherwise a table with two identical
177
+ // locked splits could be collapsed into one matching split and one unrelated split while each old split sees
178
+ // the same new match.
176
179
  for (uint256 i; i < numberOfCurrentSplits;) {
177
180
  // If not locked, continue.
178
- if (
179
- // forge-lint: disable-next-line(block-timestamp)
180
- block.timestamp < currentSplits[i].lockedUntil
181
- && !_includesLockedSplits({splits: splits, lockedSplit: currentSplits[i]})
182
- ) {
183
- revert JBSplits_PreviousLockedSplitsNotIncluded({projectId: projectId, rulesetId: rulesetId});
181
+ // forge-lint: disable-next-line(block-timestamp)
182
+ if (block.timestamp < currentSplits[i].lockedUntil) {
183
+ uint256 requiredCount;
184
+ for (uint256 j; j < numberOfCurrentSplits;) {
185
+ if (
186
+ // forge-lint: disable-next-line(block-timestamp)
187
+ block.timestamp < currentSplits[j].lockedUntil
188
+ && _isLockedSplitIncluded({split: currentSplits[j], lockedSplit: currentSplits[i]})
189
+ ) {
190
+ ++requiredCount;
191
+ }
192
+ unchecked {
193
+ ++j;
194
+ }
195
+ }
196
+
197
+ if (_includedLockedSplitCount({splits: splits, lockedSplit: currentSplits[i]}) < requiredCount) {
198
+ revert JBSplits_PreviousLockedSplitsNotIncluded({projectId: projectId, rulesetId: rulesetId});
199
+ }
184
200
  }
185
201
  unchecked {
186
202
  ++i;
@@ -332,31 +348,38 @@ contract JBSplits is JBControlled, IJBSplits {
332
348
  return splits;
333
349
  }
334
350
 
335
- /// @notice Determine if the provided splits array includes the locked split.
351
+ /// @notice Count the splits in the provided array that include the locked split.
336
352
  /// @param splits The array of splits to check within.
337
353
  /// @param lockedSplit The locked split.
338
- /// @return A flag indicating if the `lockedSplit` is contained in the `splits`.
339
- function _includesLockedSplits(JBSplit[] memory splits, JBSplit memory lockedSplit) internal pure returns (bool) {
354
+ /// @return count The number of matching splits.
355
+ function _includedLockedSplitCount(
356
+ JBSplit[] memory splits,
357
+ JBSplit memory lockedSplit
358
+ )
359
+ internal
360
+ pure
361
+ returns (uint256 count)
362
+ {
340
363
  // Keep a reference to the number of splits.
341
364
  uint256 numberOfSplits = splits.length;
342
365
 
343
366
  for (uint256 i; i < numberOfSplits;) {
344
- // Set the split being iterated on.
345
- JBSplit memory split = splits[i];
346
-
347
- // Check for sameness.
348
- if (
349
- // Allow the lock to be extended.
350
- split.percent == lockedSplit.percent && split.beneficiary == lockedSplit.beneficiary
351
- && split.hook == lockedSplit.hook && split.projectId == lockedSplit.projectId
352
- && split.preferAddToBalance == lockedSplit.preferAddToBalance
353
- && split.lockedUntil >= lockedSplit.lockedUntil
354
- ) return true;
367
+ if (_isLockedSplitIncluded({split: splits[i], lockedSplit: lockedSplit})) ++count;
355
368
  unchecked {
356
369
  ++i;
357
370
  }
358
371
  }
372
+ }
359
373
 
360
- return false;
374
+ /// @notice Determine if a split satisfies the locked split's immutable fields and lock length.
375
+ /// @param split The split to check.
376
+ /// @param lockedSplit The locked split.
377
+ /// @return A flag indicating whether the split includes the locked split.
378
+ function _isLockedSplitIncluded(JBSplit memory split, JBSplit memory lockedSplit) internal pure returns (bool) {
379
+ // Allow the lock to be extended.
380
+ return split.percent == lockedSplit.percent && split.beneficiary == lockedSplit.beneficiary
381
+ && split.hook == lockedSplit.hook && split.projectId == lockedSplit.projectId
382
+ && split.preferAddToBalance == lockedSplit.preferAddToBalance
383
+ && split.lockedUntil >= lockedSplit.lockedUntil;
361
384
  }
362
385
  }
@@ -23,12 +23,16 @@ interface IJBFeelessAddresses {
23
23
  /// @dev `address(0)` means no hook is set.
24
24
  function feelessHook() external view returns (IJBFeelessHook);
25
25
 
26
- /// @notice Returns whether the specified address is feeless for a specific project, considering the wildcard
27
- /// (projectId 0) feeless status, the project-specific feeless status, and the feeless hook (if set).
26
+ /// @notice Returns whether the specified address is feeless for a specific project, providing the outer caller
27
+ /// of the fee-bearing operation so hooks can scope grants by who initiated the action.
28
+ /// @dev Static admin-set mappings are op-agnostic and always apply. The hook (if set) receives `caller` and may
29
+ /// use it to scope its grant (e.g. an ecosystem router can be feeless only when it itself is the caller).
28
30
  /// @param addr The address to check.
29
31
  /// @param projectId The ID of the project to check.
30
- /// @return A flag indicating whether the address is feeless (globally, for the project, or per the hook).
31
- function isFeelessFor(address addr, uint256 projectId) external view returns (bool);
32
+ /// @param caller The outer caller (typically the terminal's `_msgSender()`). Pass `address(0)` for lookups
33
+ /// without caller context.
34
+ /// @return A flag indicating whether the address is feeless.
35
+ function isFeelessFor(address addr, uint256 projectId, address caller) external view returns (bool);
32
36
 
33
37
  /// @notice Sets whether an address is feeless globally (for all projects).
34
38
  /// @param addr The address to set the feeless status of.
@@ -10,9 +10,16 @@ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
10
10
  /// so a broken hook cannot brick the fee path in terminals.
11
11
  interface IJBFeelessHook is IERC165 {
12
12
  /// @notice Returns whether the address should be treated as feeless for the project.
13
+ /// @dev `caller` is the outer caller that triggered the fee-bearing terminal operation (as resolved by the
14
+ /// terminal's `_msgSender()`, so ERC-2771 meta-tx forwarders are unwrapped). It is `address(0)` when the registry
15
+ /// is queried without a caller hint (e.g. a direct off-chain `eth_call` to the registry). Use `caller` together
16
+ /// with `addr` to scope grants: e.g. an ecosystem router can be feeless ONLY when it is itself the entity calling
17
+ /// the terminal (so it gets fee-free cash-outs when zapping, but does NOT get feeless status when it merely
18
+ /// appears as a split recipient of someone else's payout).
13
19
  /// @param projectId The ID of the project the fee would be charged on behalf of.
14
20
  /// @param addr The address being checked (typically a payout recipient, surplus allowance beneficiary, or
15
21
  /// cash-out beneficiary).
22
+ /// @param caller The outer caller that initiated the terminal operation, or `address(0)` if not provided.
16
23
  /// @return A flag indicating whether the address is feeless for the project under the hook's custom logic.
17
- function isFeeless(uint256 projectId, address addr) external view returns (bool);
24
+ function isFeeless(uint256 projectId, address addr, address caller) external view returns (bool);
18
25
  }
@@ -30,22 +30,15 @@ library JBMetadataResolver {
30
30
  error JBMetadataResolver_MetadataTooLong(uint256 offset, uint256 maxOffset);
31
31
  error JBMetadataResolver_MetadataTooShort(uint256 metadataLength, uint256 minMetadataLength);
32
32
 
33
- // The various sizes used in bytes.
34
- uint256 constant ID_SIZE = 4;
33
+ // Constants alphabetized per STYLE_GUIDE; trailing comments document derivation.
35
34
  uint256 constant ID_OFFSET_SIZE = 1;
35
+ uint256 constant ID_SIZE = 4;
36
+ uint256 constant MIN_METADATA_LENGTH = 37; // RESERVED_SIZE + ID_SIZE + ID_OFFSET_SIZE
37
+ uint256 constant NEXT_ID_OFFSET = 9; // TOTAL_ID_SIZE + ID_SIZE
38
+ uint256 constant RESERVED_SIZE = 32; // 1 * WORD_SIZE — protocol-reserved leading word
39
+ uint256 constant TOTAL_ID_SIZE = 5; // ID_SIZE + ID_OFFSET_SIZE
36
40
  uint256 constant WORD_SIZE = 32;
37
41
 
38
- // The size that an ID takes in the lookup table (Identifier + Offset).
39
- uint256 constant TOTAL_ID_SIZE = 5; // ID_SIZE + ID_OFFSET_SIZE;
40
-
41
- // The amount of bytes to go forward to get to the offset of the next ID (aka. the end of the offset of the current
42
- // ID).
43
- uint256 constant NEXT_ID_OFFSET = 9; // TOTAL_ID_SIZE + ID_SIZE;
44
-
45
- // 1 word (32B) is reserved for the protocol .
46
- uint256 constant RESERVED_SIZE = 32; // 1 * WORD_SIZE;
47
- uint256 constant MIN_METADATA_LENGTH = 37; // RESERVED_SIZE + ID_SIZE + ID_OFFSET_SIZE;
48
-
49
42
  /// @notice Add an {id: data} entry to an existing metadata. This is an append-only mechanism.
50
43
  /// @param originalMetadata The original metadata
51
44
  /// @param idToAdd The id to add
@@ -8,6 +8,9 @@ import {JBRulesetMetadata} from "./../structs/JBRulesetMetadata.sol";
8
8
  /// encodes: reservedPercent, cashOutTaxRate, baseCurrency, 14 boolean flags (pausePay, allowOwnerMinting, etc.),
9
9
  /// a data hook address, and 14 bits of custom metadata. Used throughout the protocol to read ruleset configuration
10
10
  /// without storing each field separately.
11
+ /// @dev Getter functions are intentionally laid out in bit-position order (low → high), not alphabetical, so that
12
+ /// the source reads as a visual key for the bit layout written by `packRulesetMetadata`. This is the documented
13
+ /// exception to STYLE_GUIDE function ordering for libraries.
11
14
  library JBRulesetMetadataResolver {
12
15
  function reservedPercent(JBRuleset memory ruleset) internal pure returns (uint16) {
13
16
  return uint16(ruleset.metadata >> 4);
@@ -4,6 +4,7 @@ pragma solidity ^0.8.0;
4
4
  import {JBCurrencyAmount} from "./JBCurrencyAmount.sol";
5
5
 
6
6
  /// @notice Defines how much a project can withdraw from a specific terminal and token each funding cycle.
7
+ /// @dev A ruleset configuration should include at most one group for each `(terminal, token)` pair.
7
8
  /// @dev Example — payout limit of 5 USD in an ETH terminal: the project can distribute up to 5 USD worth of ETH to
8
9
  /// its splits per cycle. Example — surplus allowance of 5 USD: the project owner can pull up to 5 USD worth of ETH
9
10
  /// from the surplus (balance above payout limits).
@@ -37,6 +37,15 @@ contract JBTest is Test {
37
37
  vm.expectCall(_where, _encodedCall);
38
38
  }
39
39
 
40
+ /// @notice Calldata for `IJBFeelessAddresses.isFeelessFor(address,uint256,address)`.
41
+ /// @dev `isFeelessFor` is overloaded on the registry; `abi.encodeCall(IJBFeelessAddresses.isFeelessFor, ...)` is
42
+ /// ambiguous after the caller-aware overload was added. Terminals call the 3-arg variant forwarding
43
+ /// `_msgSender()`, so unit tests mocking the registry must encode the 3-arg selector with the expected caller.
44
+ function feelessCalldata(address addr, uint256 projectId, address caller) public pure returns (bytes memory) {
45
+ return
46
+ abi.encodeWithSelector(bytes4(keccak256("isFeelessFor(address,uint256,address)")), addr, projectId, caller);
47
+ }
48
+
40
49
  // Multiple calls with different return values
41
50
  function mockExpectSubsequent(address _where, bytes memory _encodedCall, bytes[] memory _returns) public {
42
51
  // Mocks calls with different return values
@@ -30,7 +30,7 @@ contract MockFeelessHook is ERC165, IJBFeelessHook {
30
30
  _allowed[projectId][addr] = flag;
31
31
  }
32
32
 
33
- function isFeeless(uint256 projectId, address addr) external view override returns (bool) {
33
+ function isFeeless(uint256 projectId, address addr, address) external view override returns (bool) {
34
34
  if (mode == 1) revert Nope();
35
35
  if (mode == 2) require(false, "nope");
36
36
  if (mode == 3) return _allowed[projectId][addr];
@@ -46,14 +46,14 @@ contract MockFeelessHook is ERC165, IJBFeelessHook {
46
46
  /// @dev Used to test the `setFeelessHook` validation guard. Has the `isFeeless` selector so the
47
47
  /// call shape matches, but its `supportsInterface` advertises only IERC165, not IJBFeelessHook.
48
48
  contract MockNonConformingFeelessHook is ERC165 {
49
- function isFeeless(uint256, address) external pure returns (bool) {
49
+ function isFeeless(uint256, address, address) external pure returns (bool) {
50
50
  return true;
51
51
  }
52
52
  }
53
53
 
54
54
  /// @notice "Hook" with no `supportsInterface` at all — `setFeelessHook` should revert when this is passed.
55
55
  contract MockEoaLikeFeelessHook {
56
- function isFeeless(uint256, address) external pure returns (bool) {
56
+ function isFeeless(uint256, address, address) external pure returns (bool) {
57
57
  return true;
58
58
  }
59
59
  }