@bananapus/core-v6 0.0.43 → 0.0.45

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.43",
3
+ "version": "0.0.45",
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.22",
41
+ "@bananapus/permission-ids-v6": "^0.0.23",
42
42
  "@chainlink/contracts": "1.5.0",
43
43
  "@openzeppelin/contracts": "5.6.1",
44
44
  "@prb/math": "4.1.1",
@@ -155,6 +155,8 @@ The core Juicebox V6 protocol on EVM: a modular system for launching treasury-ba
155
155
 
156
156
  | Function | What it does |
157
157
  |----------|--------------|
158
- | `setFeelessAddress(address addr, bool flag)` | Adds or removes an address from the fee exemption list. Owner-only. (`JBFeelessAddresses`) |
158
+ | `setFeelessAddress(address addr, bool flag)` | Adds or removes an address from the global (all-project) fee exemption list. Owner-only. (`JBFeelessAddresses`) |
159
+ | `setFeelessAddressFor(uint256 projectId, address addr, bool flag)` | Adds or removes an address from a project's fee exemption list. `projectId = 0` = global wildcard. Owner-only. (`JBFeelessAddresses`) |
160
+ | `isFeelessFor(address addr, uint256 projectId)` | Returns whether an address is feeless for a project (checks both wildcard and project-specific). (`JBFeelessAddresses`) |
159
161
  | `setControllerAllowed(uint256 projectId)` | Returns whether a project's controller can currently be set. (`IJBDirectoryAccessControl`) |
160
162
  | `setTerminalsAllowed(uint256 projectId)` | Returns whether a project's terminals can currently be set. (`IJBDirectoryAccessControl`) |
@@ -8,7 +8,7 @@ Use this file when you need deeper protocol reference material after the repo-lo
8
8
  |-------------|------------|---------|
9
9
  | `JBRuleset` | `cycleNumber (uint48)`, `id (uint48)`, `basedOnId (uint48)`, `start (uint48)`, `duration (uint32)`, `weight (uint112)`, `weightCutPercent (uint32)`, `approvalHook`, `metadata (uint256)` | `currentOf()`, `recordPaymentFrom()`, `recordCashOutFor()` return values |
10
10
  | `JBRulesetConfig` | `mustStartAtOrAfter (uint48)`, `duration (uint32)`, `weight (uint112)`, `weightCutPercent (uint32)`, `approvalHook`, `metadata (JBRulesetMetadata)`, `splitGroups[]`, `fundAccessLimitGroups[]` | `launchProjectFor()`, `queueRulesetsOf()` input |
11
- | `JBRulesetMetadata` | `reservedPercent (uint16)`, `cashOutTaxRate (uint16)`, `baseCurrency (uint32)`, `pausePay`, `pauseCreditTransfers`, `allowOwnerMinting`, `allowSetCustomToken`, `allowTerminalMigration`, `allowSetTerminals`, `allowSetController`, `allowAddAccountingContext`, `allowAddPriceFeed`, `ownerMustSendPayouts`, `holdFees`, `useTotalSurplusForCashOuts`, `useDataHookForPay`, `useDataHookForCashOut`, `dataHook (address)`, `metadata (uint16)` | Packed into `JBRuleset.metadata` |
11
+ | `JBRulesetMetadata` | `reservedPercent (uint16)`, `cashOutTaxRate (uint16)`, `baseCurrency (uint32)`, `pausePay`, `pauseCreditTransfers`, `allowOwnerMinting`, `allowSetCustomToken`, `allowTerminalMigration`, `allowSetTerminals`, `allowSetController`, `allowAddAccountingContext`, `allowAddPriceFeed`, `ownerMustSendPayouts`, `holdFees`, `scopeCashOutsToLocalBalances`, `useDataHookForPay`, `useDataHookForCashOut`, `dataHook (address)`, `metadata (uint16)` | Packed into `JBRuleset.metadata` |
12
12
  | `JBSplit` | `percent (uint32)`, `projectId (uint64)`, `beneficiary (address payable)`, `preferAddToBalance`, `lockedUntil (uint48)`, `hook (IJBSplitHook)` | `splitsOf()`, `setSplitGroupsOf()` |
13
13
  | `JBSplitGroup` | `groupId (uint256)`, `splits (JBSplit[])` | `JBRulesetConfig.splitGroups`, `setSplitGroupsOf()` |
14
14
  | `JBAccountingContext` | `token (address)`, `decimals (uint8)`, `currency (uint32)` | Terminal token accounting, surplus/reclaim calculations |
@@ -28,7 +28,7 @@ Use this file when you need deeper protocol reference material after the repo-lo
28
28
  | Struct | Key Fields | Used In |
29
29
  |--------|------------|---------|
30
30
  | `JBBeforePayRecordedContext` | `terminal`, `payer`, `amount (JBTokenAmount)`, `projectId`, `rulesetId`, `beneficiary`, `weight`, `reservedPercent`, `metadata` | `IJBRulesetDataHook.beforePayRecordedWith()` input |
31
- | `JBBeforeCashOutRecordedContext` | `terminal`, `holder`, `projectId`, `rulesetId`, `cashOutCount`, `totalSupply`, `surplus (JBTokenAmount)`, `useTotalSurplus`, `cashOutTaxRate`, `beneficiaryIsFeeless`, `metadata` | `IJBRulesetDataHook.beforeCashOutRecordedWith()` input |
31
+ | `JBBeforeCashOutRecordedContext` | `terminal`, `holder`, `projectId`, `rulesetId`, `cashOutCount`, `totalSupply`, `surplus (JBTokenAmount)`, `scopeCashOutsToLocalBalances`, `cashOutTaxRate`, `beneficiaryIsFeeless`, `metadata` | `IJBRulesetDataHook.beforeCashOutRecordedWith()` input |
32
32
  | `JBAfterPayRecordedContext` | `payer`, `projectId`, `rulesetId`, `amount (JBTokenAmount)`, `forwardedAmount (JBTokenAmount)`, `weight`, `newlyIssuedTokenCount`, `beneficiary`, `hookMetadata`, `payerMetadata` | `IJBPayHook.afterPayRecordedWith()` input |
33
33
  | `JBAfterCashOutRecordedContext` | `holder`, `projectId`, `rulesetId`, `cashOutCount`, `reclaimedAmount (JBTokenAmount)`, `forwardedAmount (JBTokenAmount)`, `cashOutTaxRate`, `beneficiary`, `hookMetadata`, `cashOutMetadata` | `IJBCashOutHook.afterCashOutRecordedWith()` input |
34
34
  | `JBPayHookSpecification` | `hook (IJBPayHook)`, `noop (bool)`, `amount`, `metadata` | Returned by data hook; specifies which pay hooks to call and how much to forward. `noop = true` means informational-only (callback skipped, amount must be 0). |
@@ -188,7 +188,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
188
188
  {
189
189
  // Enforce permissions.
190
190
  _requirePermissionFrom({
191
- account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.ADD_PRICE_FEED
191
+ account: _ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.ADD_PRICE_FEED
192
192
  });
193
193
 
194
194
  JBRuleset memory ruleset = _currentRulesetOf(projectId);
@@ -317,7 +317,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
317
317
  {
318
318
  // Enforce permissions.
319
319
  _requirePermissionFrom({
320
- account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.DEPLOY_ERC20
320
+ account: _ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.DEPLOY_ERC20
321
321
  });
322
322
 
323
323
  // If a salt is provided, use it.
@@ -453,27 +453,25 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
453
453
  address sender = _msgSender();
454
454
 
455
455
  // Enforce permissions.
456
+ bool isOmnichainOperator = sender == OMNICHAIN_RULESET_OPERATOR;
456
457
  _requirePermissionAllowingOverrideFrom({
457
- account: PROJECTS.ownerOf(projectId),
458
+ account: _ownerOf(projectId),
458
459
  projectId: projectId,
459
460
  permissionId: JBPermissionIds.LAUNCH_RULESETS,
460
- alsoGrantAccessIf: sender == OMNICHAIN_RULESET_OPERATOR
461
+ alsoGrantAccessIf: isOmnichainOperator
461
462
  });
462
-
463
- // Enforce permissions.
464
463
  _requirePermissionAllowingOverrideFrom({
465
- account: PROJECTS.ownerOf(projectId),
464
+ account: _ownerOf(projectId),
466
465
  projectId: projectId,
467
466
  permissionId: JBPermissionIds.SET_TERMINALS,
468
- alsoGrantAccessIf: sender == OMNICHAIN_RULESET_OPERATOR
467
+ alsoGrantAccessIf: isOmnichainOperator
469
468
  });
470
-
471
469
  if (bytes(projectUri).length > 0) {
472
470
  _requirePermissionAllowingOverrideFrom({
473
- account: PROJECTS.ownerOf(projectId),
471
+ account: _ownerOf(projectId),
474
472
  projectId: projectId,
475
473
  permissionId: JBPermissionIds.SET_PROJECT_URI,
476
- alsoGrantAccessIf: sender == OMNICHAIN_RULESET_OPERATOR
474
+ alsoGrantAccessIf: isOmnichainOperator
477
475
  });
478
476
  }
479
477
 
@@ -559,7 +557,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
559
557
  // Minting is restricted to: the project's owner, addresses with permission to `MINT_TOKENS`, the project's
560
558
  // terminals, and the project's data hook.
561
559
  _requirePermissionAllowingOverrideFrom({
562
- account: PROJECTS.ownerOf(projectId),
560
+ account: _ownerOf(projectId),
563
561
  projectId: projectId,
564
562
  permissionId: JBPermissionIds.MINT_TOKENS,
565
563
  alsoGrantAccessIf: senderIsTerminalOrDataHook || senderHasDataHookMintPermission
@@ -626,7 +624,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
626
624
 
627
625
  // Enforce permissions.
628
626
  _requirePermissionAllowingOverrideFrom({
629
- account: PROJECTS.ownerOf(projectId),
627
+ account: _ownerOf(projectId),
630
628
  projectId: projectId,
631
629
  permissionId: JBPermissionIds.QUEUE_RULESETS,
632
630
  alsoGrantAccessIf: _msgSender() == OMNICHAIN_RULESET_OPERATOR
@@ -665,7 +663,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
665
663
  {
666
664
  // Enforce permissions.
667
665
  _requirePermissionFrom({
668
- account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_SPLIT_GROUPS
666
+ account: _ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_SPLIT_GROUPS
669
667
  });
670
668
 
671
669
  // Set the split groups.
@@ -679,7 +677,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
679
677
  function setTokenFor(uint256 projectId, IJBToken token) external override {
680
678
  // Enforce permissions.
681
679
  _requirePermissionFrom({
682
- account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_TOKEN
680
+ account: _ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_TOKEN
683
681
  });
684
682
 
685
683
  // Get a reference to the current ruleset.
@@ -703,7 +701,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
703
701
  function setTokenMetadataOf(uint256 projectId, string calldata name, string calldata symbol) external override {
704
702
  // Enforce permissions.
705
703
  _requirePermissionFrom({
706
- account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_TOKEN_METADATA
704
+ account: _ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_TOKEN_METADATA
707
705
  });
708
706
 
709
707
  TOKENS.setTokenMetadataFor({projectId: projectId, name: name, symbol: symbol});
@@ -718,7 +716,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
718
716
  function setUriOf(uint256 projectId, string calldata uri) external override {
719
717
  // Enforce permissions.
720
718
  _requirePermissionFrom({
721
- account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_PROJECT_URI
719
+ account: _ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.SET_PROJECT_URI
722
720
  });
723
721
 
724
722
  // Set the project's metadata URI.
@@ -1197,7 +1195,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
1197
1195
  JBRuleset memory ruleset = _currentRulesetOf(projectId);
1198
1196
 
1199
1197
  // Get a reference to the project's owner.
1200
- address owner = PROJECTS.ownerOf(projectId);
1198
+ address owner = _ownerOf(projectId);
1201
1199
 
1202
1200
  // Reset the pending reserved token balance.
1203
1201
  pendingReservedTokenBalanceOf[projectId] = 0;
@@ -1324,6 +1322,13 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
1324
1322
  return ERC2771Context._msgSender();
1325
1323
  }
1326
1324
 
1325
+ /// @notice The owner of a project.
1326
+ /// @param projectId The ID of the project to get the owner of.
1327
+ /// @return The owner of the project.
1328
+ function _ownerOf(uint256 projectId) internal view returns (address) {
1329
+ return PROJECTS.ownerOf(projectId);
1330
+ }
1331
+
1327
1332
  /// @notice The project's upcoming ruleset.
1328
1333
  /// @param projectId The ID of the project to check.
1329
1334
  /// @return The project's upcoming ruleset.
@@ -7,19 +7,17 @@ import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
7
7
  import {IJBFeelessAddresses} from "./interfaces/IJBFeelessAddresses.sol";
8
8
 
9
9
  /// @notice A registry of addresses exempt from the protocol's 2.5% fee. Feeless addresses don't incur fees on
10
- /// payouts they receive, surplus allowance they use, or cash outs where they are the beneficiary. Managed by the
11
- /// contract owner (typically the protocol multisig).
10
+ /// payouts they receive, surplus allowance they use, or cash outs where they are the beneficiary.
11
+ /// @dev All feeless status is managed by the contract owner (typically the protocol multisig).
12
+ /// @dev `projectId = 0` is the wildcard — an address feeless for project 0 is feeless for ALL projects.
12
13
  contract JBFeelessAddresses is Ownable, IJBFeelessAddresses, IERC165 {
13
14
  //*********************************************************************//
14
- // --------------------- public stored properties -------------------- //
15
+ // -------------------- internal stored properties ------------------- //
15
16
  //*********************************************************************//
16
17
 
17
- /// @notice Check if the specified address is feeless.
18
- /// @dev Feeless addresses can receive payouts without incurring a fee.
19
- /// @dev Feeless addresses can use the surplus allowance without incurring a fee.
20
- /// @dev Feeless addresses can be the beneficiary of cash outs without incurring a fee.
21
- /// @custom:param addr The address to check.
22
- mapping(address addr => bool) public override isFeeless;
18
+ /// @notice Raw feeless status per project per address.
19
+ /// @dev `projectId = 0` stores the global (all-project) feeless status.
20
+ mapping(uint256 projectId => mapping(address addr => bool)) internal _isFeelessFor;
23
21
 
24
22
  //*********************************************************************//
25
23
  // -------------------------- constructor ---------------------------- //
@@ -32,21 +30,42 @@ contract JBFeelessAddresses is Ownable, IJBFeelessAddresses, IERC165 {
32
30
  // ---------------------- external transactions ---------------------- //
33
31
  //*********************************************************************//
34
32
 
35
- /// @notice Add or remove an address from the fee-exempt list. Feeless addresses don't pay the 2.5% protocol fee
36
- /// on payouts received, surplus allowance used, or cash outs where they're the beneficiary.
33
+ /// @notice Add or remove an address from the global (all-project) fee-exempt list.
34
+ /// @dev Equivalent to `setFeelessAddressFor(0, addr, flag)`.
37
35
  /// @dev Can only be called by this contract's owner (typically the protocol multisig).
38
36
  /// @param addr The address to set as feeless or not feeless.
39
37
  /// @param flag Whether the address should be feeless (`true`) or not feeless (`false`).
40
38
  function setFeelessAddress(address addr, bool flag) external virtual override onlyOwner {
41
- isFeeless[addr] = flag;
39
+ _isFeelessFor[0][addr] = flag;
42
40
 
43
- emit SetFeelessAddress({addr: addr, isFeeless: flag, caller: _msgSender()});
41
+ emit SetFeelessAddress({projectId: 0, addr: addr, isFeeless: flag, caller: _msgSender()});
42
+ }
43
+
44
+ /// @notice Add or remove an address from a project's fee-exempt list.
45
+ /// @dev Can only be called by this contract's owner (typically the protocol multisig).
46
+ /// @dev Use `projectId = 0` to set the global (all-project) feeless status.
47
+ /// @param projectId The ID of the project. 0 means all projects.
48
+ /// @param addr The address to set as feeless or not feeless for the project.
49
+ /// @param flag Whether the address should be feeless for the project (`true`) or not (`false`).
50
+ function setFeelessAddressFor(uint256 projectId, address addr, bool flag) external virtual override onlyOwner {
51
+ _isFeelessFor[projectId][addr] = flag;
52
+
53
+ emit SetFeelessAddress({projectId: projectId, addr: addr, isFeeless: flag, caller: _msgSender()});
44
54
  }
45
55
 
46
56
  //*********************************************************************//
47
57
  // -------------------------- public views --------------------------- //
48
58
  //*********************************************************************//
49
59
 
60
+ /// @notice Returns whether the specified address is feeless for a specific project, considering both the wildcard
61
+ /// (projectId 0) and project-specific feeless status.
62
+ /// @param addr The address to check.
63
+ /// @param projectId The ID of the project to check.
64
+ /// @return A flag indicating whether the address is feeless (globally or for the project).
65
+ function isFeelessFor(address addr, uint256 projectId) external view override returns (bool) {
66
+ return _isFeelessFor[0][addr] || _isFeelessFor[projectId][addr];
67
+ }
68
+
50
69
  /// @notice Indicates whether this contract adheres to the specified interface.
51
70
  /// @dev See {IERC165-supportsInterface}.
52
71
  /// @param interfaceId The ID of the interface to check for adherence to.
@@ -334,7 +334,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
334
334
 
335
335
  // This payout is eligible for a fee since the funds are leaving this contract and the split hook isn't a
336
336
  // feeless address.
337
- if (!_isFeeless(address(split.hook))) {
337
+ if (!_isFeeless({addr: address(split.hook), projectId: projectId})) {
338
338
  unchecked {
339
339
  netPayoutAmount -= _feeAmountFrom(amount);
340
340
  }
@@ -377,7 +377,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
377
377
  // the fee model taxes value leaving the protocol ecosystem, not internal rebalancing.
378
378
  // This payout is eligible for a fee if the funds are leaving this contract and the receiving terminal isn't
379
379
  // a feeless address.
380
- if (terminal != this && !_isFeeless(address(terminal))) {
380
+ if (terminal != this && !_isFeeless({addr: address(terminal), projectId: projectId})) {
381
381
  unchecked {
382
382
  netPayoutAmount -= _feeAmountFrom(amount);
383
383
  }
@@ -440,7 +440,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
440
440
 
441
441
  // This payout is eligible for a fee since the funds are leaving this contract and the recipient isn't a
442
442
  // feeless address.
443
- if (!_isFeeless(recipient)) {
443
+ if (!_isFeeless({addr: recipient, projectId: projectId})) {
444
444
  unchecked {
445
445
  netPayoutAmount -= _feeAmountFrom(amount);
446
446
  }
@@ -546,7 +546,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
546
546
  // Migration to a non-feeless terminal incurs the standard 2.5% fee, same as any other fund egress.
547
547
  // This also settles any fee-free surplus liability that would otherwise be lost on the new terminal.
548
548
  uint256 feeAmount;
549
- if (!_isFeeless(address(to)) && projectId != _FEE_BENEFICIARY_PROJECT_ID) {
549
+ if (!_isFeeless({addr: address(to), projectId: projectId}) && projectId != _FEE_BENEFICIARY_PROJECT_ID) {
550
550
  feeAmount = _takeFeeFrom({
551
551
  projectId: projectId,
552
552
  token: token,
@@ -904,16 +904,16 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
904
904
  JBCashOutHookSpecification[] memory hookSpecifications
905
905
  )
906
906
  {
907
- (ruleset, reclaimAmount, cashOutTaxRate, hookSpecifications) =
908
- STORE.previewCashOutFrom({
909
- terminal: address(this),
910
- holder: holder,
911
- projectId: projectId,
912
- cashOutCount: cashOutCount,
913
- tokenToReclaim: tokenToReclaim,
914
- beneficiaryIsFeeless: _isFeeless(beneficiary),
915
- metadata: metadata
916
- });
907
+ bool feeless = _isFeeless({addr: beneficiary, projectId: projectId});
908
+ (ruleset, reclaimAmount, cashOutTaxRate, hookSpecifications) = STORE.previewCashOutFrom({
909
+ terminal: address(this),
910
+ holder: holder,
911
+ projectId: projectId,
912
+ cashOutCount: cashOutCount,
913
+ tokenToReclaim: tokenToReclaim,
914
+ beneficiaryIsFeeless: feeless,
915
+ metadata: metadata
916
+ });
917
917
  }
918
918
 
919
919
  /// @notice Simulates a payment without modifying state — use this to preview how many project tokens a payer
@@ -1155,7 +1155,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1155
1155
  uint256 cashOutTaxRate;
1156
1156
 
1157
1157
  // Cache whether the beneficiary is feeless.
1158
- bool beneficiaryIsFeeless = _isFeeless(beneficiary);
1158
+ bool beneficiaryIsFeeless = _isFeeless({addr: beneficiary, projectId: projectId});
1159
1159
 
1160
1160
  {
1161
1161
  // Cache the controller to avoid a redundant external call (also used inside STORE.recordCashOutFor).
@@ -1445,8 +1445,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1445
1445
  }
1446
1446
 
1447
1447
  // Get the fee for the specified amount.
1448
- uint256 specificationAmountFee =
1449
- _isFeeless(address(specification.hook)) ? 0 : _feeAmountFrom(specification.amount);
1448
+ uint256 specificationAmountFee = _isFeeless({addr: address(specification.hook), projectId: projectId})
1449
+ ? 0
1450
+ : _feeAmountFrom(specification.amount);
1450
1451
 
1451
1452
  // Add the specification's amount to the amount eligible for fees.
1452
1453
  if (specificationAmountFee != 0) {
@@ -1842,7 +1843,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1842
1843
  // Send any leftover funds to the project owner and update the fee tracking accordingly.
1843
1844
  if (leftoverPayoutAmount != 0) {
1844
1845
  // Keep a reference to the fee for the leftover payout amount.
1845
- uint256 fee = _isFeeless(projectOwner) ? 0 : _feeAmountFrom(leftoverPayoutAmount);
1846
+ uint256 fee =
1847
+ _isFeeless({addr: projectOwner, projectId: projectId}) ? 0 : _feeAmountFrom(leftoverPayoutAmount);
1846
1848
 
1847
1849
  uint256 netLeftoverPayoutAmount;
1848
1850
  unchecked {
@@ -2025,7 +2027,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
2025
2027
  // Take a fee from the `amountPaidOut`, if needed.
2026
2028
  // The net amount is the final amount withdrawn after the fee has been taken.
2027
2029
  netAmountPaidOut = amountPaidOut
2028
- - (_isFeeless(owner) || _isFeeless(beneficiary)
2030
+ - (_isFeeless({addr: owner, projectId: projectId}) || _isFeeless({addr: beneficiary, projectId: projectId})
2029
2031
  ? 0
2030
2032
  : _takeFeeFrom({
2031
2033
  projectId: projectId,
@@ -2113,9 +2115,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
2113
2115
 
2114
2116
  /// @notice Returns a flag indicating if interacting with an address should not incur fees.
2115
2117
  /// @param addr The address to check.
2116
- /// @return A flag indicating if the address should not incur fees.
2117
- function _isFeeless(address addr) internal view returns (bool) {
2118
- return FEELESS_ADDRESSES.isFeeless(addr);
2118
+ /// @param projectId The ID of the project to check the per-project feeless status for.
2119
+ /// @return A flag indicating if the address should not incur fees (globally or for the project).
2120
+ function _isFeeless(address addr, uint256 projectId) internal view returns (bool) {
2121
+ return FEELESS_ADDRESSES.isFeelessFor({addr: addr, projectId: projectId});
2119
2122
  }
2120
2123
 
2121
2124
  /// @notice The calldata. Preferred to use over `msg.data`.
@@ -847,57 +847,6 @@ contract JBTerminalStore is IJBTerminalStore {
847
847
  // -------------------------- internal views ------------------------- //
848
848
  //*********************************************************************//
849
849
 
850
- /// @notice Computes the surplus relevant for a cash out (total or local, depending on ruleset flag).
851
- /// @dev When `useTotalSurplusForCashOuts` is enabled, surplus is aggregated from ALL registered terminals without
852
- /// validation. Projects MUST only register trusted terminals — an untrusted terminal can over-report surplus and
853
- /// cause the executing terminal to overpay cash-outs.
854
- /// @param terminal The terminal recording the cash out.
855
- /// @param projectId The ID of the project to cash out from.
856
- /// @param tokenToReclaim The token to reclaim.
857
- /// @param ruleset The ruleset during the cash out.
858
- /// @return The surplus amount in the token's native decimals and currency.
859
- function _cashOutSurplusOf(
860
- address terminal,
861
- uint256 projectId,
862
- address tokenToReclaim,
863
- JBRuleset memory ruleset
864
- )
865
- internal
866
- view
867
- returns (uint256)
868
- {
869
- // Look up the accounting context (decimals, currency) for the token being reclaimed at this terminal.
870
- JBAccountingContext memory accountingContext = _accountingContextForTokenOf[terminal][projectId][tokenToReclaim];
871
-
872
- // If the ruleset uses total surplus, aggregate across ALL terminals and ALL tokens.
873
- if (ruleset.useTotalSurplusForCashOuts()) {
874
- return JBSurplus.currentSurplusOf({
875
- projectId: projectId,
876
- // Get every terminal the project has registered.
877
- terminals: DIRECTORY.terminalsOf(projectId),
878
- // Empty tokens array = include all tokens at each terminal.
879
- tokens: new address[](0),
880
- // Express the result in the reclaimed token's decimals and currency.
881
- decimals: accountingContext.decimals,
882
- currency: accountingContext.currency
883
- });
884
- }
885
-
886
- // Otherwise, only account for the specific token's surplus at this terminal.
887
- JBAccountingContext[] memory singleContext = new JBAccountingContext[](1);
888
- singleContext[0] = accountingContext;
889
-
890
- // Compute surplus from only this terminal using only the reclaimed token's balance.
891
- return _surplusFrom({
892
- terminal: terminal,
893
- projectId: projectId,
894
- accountingContexts: singleContext,
895
- ruleset: ruleset,
896
- targetDecimals: accountingContext.decimals,
897
- targetCurrency: accountingContext.currency
898
- });
899
- }
900
-
901
850
  /// @notice Calls the data hook, validates noop specifications, and computes the bonding curve reclaim amount.
902
851
  /// @dev Extracted from `_computeCashOutFrom` to keep it under the EVM stack depth limit (16 slots).
903
852
  /// @param ruleset The current ruleset (used to resolve the data hook address).
@@ -979,14 +928,18 @@ contract JBTerminalStore is IJBTerminalStore {
979
928
  // Get a reference to the project's current ruleset.
980
929
  ruleset = RULESETS.currentOf(projectId);
981
930
 
982
- // Get the project's current surplus for the token being reclaimed.
983
- uint256 surplus = _cashOutSurplusOf({
984
- terminal: terminal, projectId: projectId, tokenToReclaim: tokenToReclaim, ruleset: ruleset
985
- });
986
-
987
931
  // Get the accounting context for the token being reclaimed.
988
932
  JBAccountingContext memory accountingContext = _accountingContextForTokenOf[terminal][projectId][tokenToReclaim];
989
933
 
934
+ // Get the project's current surplus across ALL terminals and ALL tokens.
935
+ uint256 surplus = JBSurplus.currentSurplusOf({
936
+ projectId: projectId,
937
+ terminals: DIRECTORY.terminalsOf(projectId),
938
+ tokens: new address[](0),
939
+ decimals: accountingContext.decimals,
940
+ currency: accountingContext.currency
941
+ });
942
+
990
943
  // Get the total number of outstanding project tokens.
991
944
  uint256 effectiveTotalSupply =
992
945
  IJBController(address(DIRECTORY.controllerOf(projectId))).totalTokenSupplyWithReservedTokensOf(projectId);
@@ -1019,7 +972,7 @@ contract JBTerminalStore is IJBTerminalStore {
1019
972
  decimals: accountingContext.decimals,
1020
973
  currency: accountingContext.currency
1021
974
  });
1022
- context.useTotalSurplus = ruleset.useTotalSurplusForCashOuts();
975
+ context.scopeCashOutsToLocalBalances = ruleset.scopeCashOutsToLocalBalances();
1023
976
  context.cashOutTaxRate = ruleset.cashOutTaxRate();
1024
977
  context.beneficiaryIsFeeless = beneficiaryIsFeeless;
1025
978
  context.metadata = metadata;
@@ -1,21 +1,31 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity ^0.8.0;
3
3
 
4
- /// @notice Tracks addresses that are exempt from fees.
4
+ /// @notice Tracks addresses that are exempt from fees, both globally and on a per-project basis.
5
+ /// @dev `projectId = 0` is the wildcard — an address feeless for project 0 is feeless for ALL projects.
5
6
  interface IJBFeelessAddresses {
6
- /// @notice An address's feeless status was set.
7
+ /// @notice An address's feeless status was set for a project (or globally if projectId is 0).
8
+ /// @param projectId The project the feeless status applies to. 0 means all projects.
7
9
  /// @param addr The address whose feeless status was set.
8
10
  /// @param isFeeless Whether the address is feeless.
9
11
  /// @param caller The address that set the feeless status.
10
- event SetFeelessAddress(address indexed addr, bool indexed isFeeless, address caller);
12
+ event SetFeelessAddress(uint256 indexed projectId, address indexed addr, bool indexed isFeeless, address caller);
11
13
 
12
- /// @notice Returns whether the specified address is feeless.
14
+ /// @notice Returns whether the specified address is feeless for a specific project, considering both the wildcard
15
+ /// (projectId 0) and project-specific feeless status.
13
16
  /// @param addr The address to check.
14
- /// @return A flag indicating whether the address is feeless.
15
- function isFeeless(address addr) external view returns (bool);
17
+ /// @param projectId The ID of the project to check.
18
+ /// @return A flag indicating whether the address is feeless (globally or for the project).
19
+ function isFeelessFor(address addr, uint256 projectId) external view returns (bool);
16
20
 
17
- /// @notice Sets whether an address is feeless.
21
+ /// @notice Sets whether an address is feeless globally (for all projects).
18
22
  /// @param addr The address to set the feeless status of.
19
23
  /// @param flag A flag indicating whether the address should be feeless.
20
24
  function setFeelessAddress(address addr, bool flag) external;
25
+
26
+ /// @notice Sets whether an address is feeless for a specific project.
27
+ /// @param projectId The ID of the project. 0 means all projects (same as `setFeelessAddress`).
28
+ /// @param addr The address to set the feeless status of.
29
+ /// @param flag A flag indicating whether the address should be feeless for the project.
30
+ function setFeelessAddressFor(uint256 projectId, address addr, bool flag) external;
21
31
  }
@@ -67,7 +67,7 @@ library JBRulesetMetadataResolver {
67
67
  return ((ruleset.metadata >> 78) & 1) == 1;
68
68
  }
69
69
 
70
- function useTotalSurplusForCashOuts(JBRuleset memory ruleset) internal pure returns (bool) {
70
+ function scopeCashOutsToLocalBalances(JBRuleset memory ruleset) internal pure returns (bool) {
71
71
  return ((ruleset.metadata >> 79) & 1) == 1;
72
72
  }
73
73
 
@@ -123,8 +123,8 @@ library JBRulesetMetadataResolver {
123
123
  if (rulesetMetadata.ownerMustSendPayouts) packed |= 1 << 77;
124
124
  // hold fees in bit 78.
125
125
  if (rulesetMetadata.holdFees) packed |= 1 << 78;
126
- // useTotalSurplusForCashOuts in bit 79.
127
- if (rulesetMetadata.useTotalSurplusForCashOuts) packed |= 1 << 79;
126
+ // scopeCashOutsToLocalBalances in bit 79.
127
+ if (rulesetMetadata.scopeCashOutsToLocalBalances) packed |= 1 << 79;
128
128
  // use pay data source in bit 80.
129
129
  if (rulesetMetadata.useDataHookForPay) packed |= 1 << 80;
130
130
  // use cash out data source in bit 81.
@@ -154,7 +154,7 @@ library JBRulesetMetadataResolver {
154
154
  allowAddPriceFeed: allowAddPriceFeed(ruleset),
155
155
  ownerMustSendPayouts: ownerMustSendPayouts(ruleset),
156
156
  holdFees: holdFees(ruleset),
157
- useTotalSurplusForCashOuts: useTotalSurplusForCashOuts(ruleset),
157
+ scopeCashOutsToLocalBalances: scopeCashOutsToLocalBalances(ruleset),
158
158
  useDataHookForPay: useDataHookForPay(ruleset),
159
159
  useDataHookForCashOut: useDataHookForCashOut(ruleset),
160
160
  dataHook: dataHook(ruleset),
@@ -14,7 +14,8 @@ import {JBTokenAmount} from "./JBTokenAmount.sol";
14
14
  /// @custom:member surplus The surplus amount used for the calculation, as a fixed point number with 18 decimals.
15
15
  /// Includes the token of the surplus, the surplus value, the number of decimals
16
16
  /// included, and the currency of the surplus.
17
- /// @custom:member useTotalSurplus If true, use surplus across all of a project's terminals when calculating cash outs.
17
+ /// @custom:member scopeCashOutsToLocalBalances If true, omnichain hooks should use only local chain balances for cash
18
+ /// outs (skip cross-chain aggregation).
18
19
  /// @custom:member cashOutTaxRate The cash out tax rate of the ruleset the cash out is made during, out of
19
20
  /// `JBConstants.MAX_CASH_OUT_TAX_RATE`.
20
21
  /// @custom:member beneficiaryIsFeeless Whether the cash out's beneficiary is a feeless address. Useful for data hooks
@@ -29,7 +30,7 @@ struct JBBeforeCashOutRecordedContext {
29
30
  uint256 cashOutCount;
30
31
  uint256 totalSupply;
31
32
  JBTokenAmount surplus;
32
- bool useTotalSurplus;
33
+ bool scopeCashOutsToLocalBalances;
33
34
  uint256 cashOutTaxRate;
34
35
  bool beneficiaryIsFeeless;
35
36
  bytes metadata;
@@ -21,8 +21,8 @@ pragma solidity ^0.8.0;
21
21
  /// @custom:member allowAddPriceFeed If `true`, the project can register new price feeds in `JBPrices`.
22
22
  /// @custom:member ownerMustSendPayouts If `true`, only the project owner can trigger payout distribution.
23
23
  /// @custom:member holdFees If `true`, fees are accumulated but not processed until a future ruleset (or manually).
24
- /// @custom:member useTotalSurplusForCashOuts If `true`, cash-out calculations use surplus across all terminals (not
25
- /// just the one to cash out from).
24
+ /// @custom:member scopeCashOutsToLocalBalances If `true`, omnichain cash-out calculations use only the local chain's
25
+ /// balances (not cross-chain aggregates).
26
26
  /// @custom:member useDataHookForPay If `true`, the data hook is called before recording payments.
27
27
  /// @custom:member useDataHookForCashOut If `true`, the data hook is called before recording cash outs.
28
28
  /// @custom:member dataHook Contract called before pay/cash-out to potentially override token counts or add hooks.
@@ -42,7 +42,7 @@ struct JBRulesetMetadata {
42
42
  bool allowAddPriceFeed;
43
43
  bool ownerMustSendPayouts;
44
44
  bool holdFees;
45
- bool useTotalSurplusForCashOuts;
45
+ bool scopeCashOutsToLocalBalances;
46
46
  bool useDataHookForPay;
47
47
  bool useDataHookForCashOut;
48
48
  address dataHook;
@@ -43,7 +43,7 @@ contract JBTest is Test {
43
43
  allowAddAccountingContext: true,
44
44
  allowAddPriceFeed: true,
45
45
  holdFees: false,
46
- useTotalSurplusForCashOuts: true,
46
+ scopeCashOutsToLocalBalances: true,
47
47
  useDataHookForPay: false,
48
48
  useDataHookForCashOut: false,
49
49
  dataHook: address(0),
@@ -81,7 +81,7 @@ contract JBTest is Test {
81
81
  allowAddAccountingContext: true,
82
82
  allowAddPriceFeed: false,
83
83
  holdFees: false,
84
- useTotalSurplusForCashOuts: false,
84
+ scopeCashOutsToLocalBalances: false,
85
85
  useDataHookForPay: false,
86
86
  useDataHookForCashOut: false,
87
87
  dataHook: address(0),
@@ -105,7 +105,7 @@ contract JBTest is Test {
105
105
  allowAddAccountingContext: false,
106
106
  allowAddPriceFeed: false,
107
107
  holdFees: true,
108
- useTotalSurplusForCashOuts: true,
108
+ scopeCashOutsToLocalBalances: true,
109
109
  useDataHookForPay: false,
110
110
  useDataHookForCashOut: false,
111
111
  dataHook: address(0),