@bananapus/core-v6 0.0.27 → 0.0.29

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/ADMINISTRATION.md CHANGED
@@ -123,7 +123,7 @@ Admin privileges and their scope in nana-core-v6.
123
123
  |----------|--------------|---------------|-------|-------------|
124
124
  | `addAccountingContextsFor` | Project owner, operator, or the project's controller | ADD_ACCOUNTING_CONTEXTS (20) | Per project | Adds tokens that the terminal will accept for a project. Requires the ruleset's `allowAddAccountingContext` flag (if a ruleset exists). |
125
125
  | `cashOutTokensOf` | Token holder or operator | CASH_OUT_TOKENS (4) | Per project | Cashes out project tokens for a share of the project's surplus. Fees are charged unless the beneficiary is feeless or the cash out tax rate is zero. |
126
- | `migrateBalanceOf` | Project owner or operator | MIGRATE_TERMINAL (6) | Per project | Migrates a project's balance from this terminal to another. The destination terminal must accept the same token. The ruleset must have `allowTerminalMigration` enabled (checked in JBTerminalStore). |
126
+ | `migrateBalanceOf` | Project owner or operator | MIGRATE_TERMINAL (6) | Per project | Migrates a project's balance from this terminal to another. The destination terminal must accept the same token. The ruleset must have `allowTerminalMigration` enabled (checked in JBTerminalStore). The standard 2.5% protocol fee is charged when migrating to a non-feeless terminal. |
127
127
  | `sendPayoutsOf` | Anyone (unless `ownerMustSendPayouts` is set) | SEND_PAYOUTS (5) if `ownerMustSendPayouts` | Per project | Sends payouts to the project's payout split group up to the payout limit. Anyone can call unless the ruleset has `ownerMustSendPayouts` enabled, which requires the project owner or an operator with SEND_PAYOUTS permission. |
128
128
  | `useAllowanceOf` | Project owner or operator | USE_ALLOWANCE (17) | Per project | Withdraws funds from the project's surplus up to the surplus allowance. Fees are charged unless the owner or beneficiary is feeless. |
129
129
  | `pay` | Anyone | N/A | N/A | Pays a project with tokens. No permission required. |
package/ARCHITECTURE.md CHANGED
@@ -22,8 +22,8 @@ src/
22
22
  ├── JBERC20.sol — Cloneable ERC20Votes+Permit token
23
23
  ├── JBFeelessAddresses.sol — Fee-exempt address registry
24
24
  ├── JBDeadline.sol — Approval hook requiring minimum delay
25
- ├── JBChainlinkV3PriceFeed.sol — Chainlink v3 price feed with staleness check
26
- ├── JBChainlinkV3SequencerPriceFeed.sol — L2 sequencer-aware price feed
25
+ ├── JBChainlinkV3PriceFeed.sol — Chainlink v3 price feed with staleness + answeredInRound check
26
+ ├── JBChainlinkV3SequencerPriceFeed.sol — L2 sequencer-aware price feed (answer != 0 = down)
27
27
  ├── abstract/
28
28
  │ ├── JBPermissioned.sol — Base for permission-checked contracts
29
29
  │ └── JBControlled.sol — Base for controller-gated contracts
@@ -136,7 +136,7 @@ Owner -> JBMultiTerminal.sendPayoutsOf()
136
136
  | Data Hook (cashout) | `IJBRulesetDataHook.beforeCashOutRecordedWith` | JBTerminalStore |
137
137
  | Pay Hook | `IJBPayHook.afterPayRecordedWith` | JBMultiTerminal |
138
138
  | Cash Out Hook | `IJBCashOutHook.afterCashOutRecordedWith` | JBMultiTerminal |
139
- | Split Hook | `IJBSplitHook.processSplitWith` | JBMultiTerminal, JBController |
139
+ | Split Hook | `IJBSplitHook.processSplitWith` | JBMultiTerminal, JBController (try-catch; reverts emit `SplitHookReverted`) |
140
140
  | Approval Hook | `IJBRulesetApprovalHook.approvalStatusOf` | JBRulesets |
141
141
 
142
142
  ## Dependencies
@@ -88,7 +88,7 @@ Holder -> JBMultiTerminal.cashOutTokensOf()
88
88
  -> Fee skipped if beneficiary is feeless
89
89
  ```
90
90
 
91
- **Critical note**: The beneficiary receives the reclaim amount BEFORE cash out hooks execute. Fees are taken AFTER hooks. `_feeFreeSurplusOf[projectId][token]` accumulates the value of fee-free intra-terminal payouts. After any outflow (payouts, `useAllowanceOf`, non-zero-tax or feeless cashouts), the counter is capped at the remaining balance non-fee-free funds leave first. During zero-tax cashout, the 2.5% fee applies only up to this surplus amount (then depletes it), preventing a round-trip fee bypass. Cleared on terminal migration.
91
+ **Critical note**: The beneficiary receives the reclaim amount BEFORE cash out hooks execute. Fees are taken AFTER hooks. `_feeFreeSurplusOf[projectId][token]` accumulates the value of fee-free intra-terminal payouts. After any outflow (payouts, `useAllowanceOf`, non-zero-tax or feeless cashouts), the counter is capped at the remaining balance -- non-fee-free funds leave first. During zero-tax cashout, the 2.5% fee applies only up to this surplus amount (then depletes it), preventing a round-trip fee bypass. Cleared on terminal migration (the migration fee settles this liability).
92
92
 
93
93
  ### Payout Flow (`sendPayoutsOf`)
94
94
 
package/CHANGE_LOG.md CHANGED
@@ -48,6 +48,34 @@ Also in this release:
48
48
  - **`via_ir = true`**: Added to `foundry.toml` to enable the Solidity IR optimizer pipeline, reducing deployed bytecode size (EIP-170 compliance).
49
49
  - Internal helpers extracted: `_accountingContextOf` and `_tokenAmountOf` on `JBMultiTerminal`, `_splitTokenCount` on `JBController`.
50
50
 
51
+ ### 0.6 JBMultiTerminal -- Migration Fee
52
+
53
+ `migrateBalanceOf` now charges the standard 2.5% protocol fee when migrating to a non-feeless terminal, consistent with all other fund egress. This also settles any `_feeFreeSurplusOf` liability that would otherwise be lost on the new terminal. The fee is deducted from the migrated balance before transfer. Feeless terminals are exempt.
54
+
55
+ ### 0.8 JBDirectory -- ADD_TERMINALS Permission on Implicit Addition
56
+
57
+ `setPrimaryTerminalOf` now requires the `ADD_TERMINALS` permission when the specified terminal is not already in the project's terminal list. Previously, `setPrimaryTerminalOf` would implicitly add the terminal to the project's terminal list without any permission check beyond `SET_PRIMARY_TERMINAL`. Now, if `isTerminalOf(projectId, terminal)` returns false, the caller (or the account they're acting on behalf of) must also hold the `ADD_TERMINALS` permission for the project. Terminals already in the list are unaffected.
58
+
59
+ ### 0.9 JBController -- Split Hook Try-Catch
60
+
61
+ `_sendReservedTokensToSplitsOf` now wraps the `split.hook.processSplitWith(...)` call in a try-catch. If a split hook reverts, the tokens already transferred to the hook remain with the hook (they were transferred via `safeTransfer` before the `processSplitWith` call), and the controller emits a `SplitHookReverted(projectId, hook, reason)` event instead of propagating the revert. This prevents a single reverting split hook from blocking the entire reserved token distribution. A new event `SplitHookReverted` was added to `IJBController`.
62
+
63
+ ### 0.10 JBChainlinkV3SequencerPriceFeed -- Sequencer Down Check
64
+
65
+ The sequencer status check changed from `answer == 1` to `answer != 0`. The Chainlink sequencer uptime feed returns `0` for "up" and `1` for "down", but may return other non-zero values in future feed versions. The new check treats any non-zero answer as sequencer-down, which is the safer default.
66
+
67
+ ### 0.11 JBMultiTerminal -- forceApprove
68
+
69
+ `_beforeSendFor` now uses `forceApprove` (from OpenZeppelin's `SafeERC20`) instead of `safeIncreaseAllowance` when setting token allowances for outbound ERC-20 transfers. `safeIncreaseAllowance` could fail if a previous allowance was partially consumed and a non-standard token (e.g., USDT) requires the allowance to be zero before setting a new value. `forceApprove` resets the allowance to zero first if needed, then sets it to the desired amount.
70
+
71
+ ### 0.12 JBChainlinkV3PriceFeed -- answeredInRound Check
72
+
73
+ `currentUnitPrice` now checks `answeredInRound < roundId` after retrieving `latestRoundData()`. If `answeredInRound` is less than `roundId`, the answer was carried over from a previous round and the current round is incomplete. This reverts with `JBChainlinkV3PriceFeed_IncompleteRound()`, complementing the existing `updatedAt == 0` check to catch additional incomplete round scenarios.
74
+
75
+ ### 0.7 JBMultiTerminal -- Self-Pay Revert
76
+
77
+ `_pay` now reverts with `JBMultiTerminal_MintNotAllowed()` when `payer == address(this)`. This prevents same-project intra-terminal payout splits (where `preferAddToBalance == false`) from minting tokens against existing balance without new funds entering the system. The try-catch in `JBPayoutSplitGroupLib` catches this revert and restores the balance via `recordAddedBalanceFor`. Projects that want to mint should do so explicitly via the controller.
78
+
51
79
  ---
52
80
 
53
81
  ## 1. Breaking Changes
@@ -158,6 +186,7 @@ Parameters changed from `memory` to `calldata` for gas efficiency.
158
186
  |----------|-------|
159
187
  | `IJBTokens` | `SetTokenMetadata(uint256 indexed projectId, string name, string symbol, address caller)` |
160
188
  | `IJBPermitTerminal` | `Permit2AllowanceFailed(address indexed token, address indexed owner, bytes reason)` |
189
+ | `IJBController` | `SplitHookReverted(uint256 indexed projectId, address hook, bytes reason)` |
161
190
 
162
191
  ### 2.3 New Errors
163
192
 
@@ -182,6 +211,13 @@ Parameters changed from `memory` to `calldata` for gas efficiency.
182
211
 
183
212
  ## 3. Event Changes
184
213
 
214
+ ### 3.0 Indexer Notes
215
+
216
+ For subgraph migrations, this repo is the protocol-level anchor:
217
+ - when an event signature gains parameters, prefer widening the existing entity schema instead of treating it as an unrelated event stream;
218
+ - preview/noop behavior in core-v6 means some routing diagnostics now come from returned hook specs rather than only from emitted callback events;
219
+ - if your v5 graph correlated protocol actions to hook callbacks only, re-check those assumptions against v6 preview/noop patterns.
220
+
185
221
  ### 3.1 New Events
186
222
 
187
223
  See section 2.2 above.
@@ -313,6 +349,7 @@ No changes.
313
349
  | **Migration lifecycle** | `afterReceiveMigrationFrom` added — called by directory after migration completes (validates caller is directory). |
314
350
  | **`launchRulesetsFor` permission** | Changed from `QUEUE_RULESETS` to `LAUNCH_RULESETS`. |
315
351
  | **Split token transfer assertion** | `assert(allowance == 0)` replaced with explicit `revert JBController_TerminalTokensNotTransferred()`. |
352
+ | **Split hook try-catch** | `processSplitWith` in `_sendReservedTokensToSplitsOf` is now wrapped in a try-catch. A reverting split hook emits `SplitHookReverted` instead of propagating. Transferred tokens stay with the hook. |
316
353
  | **Code organization** | External views moved after external transactions. Internal views moved to end of file. |
317
354
 
318
355
  ### 8.2 JBMultiTerminal
@@ -326,6 +363,7 @@ No changes.
326
363
  | **Split payout documentation** | Failed split payouts documented as consuming payout limit by design. |
327
364
  | **Fee-free cashout bypass prevention** | New `_feeFreeSurplusOf` mapping tracks cumulative fee-free intra-terminal payouts per project/token. Capped at remaining balance after outflows including non-zero-tax/feeless cashouts (non-fee-free funds leave first). During zero-tax cashouts, fees are charged up to this tracked amount (then decremented). Cleared on migration. See Section 0.2. |
328
365
  | **beneficiaryIsFeeless passthrough** | `cashOutTokensOf` now passes `_isFeeless(beneficiary)` to `recordCashOutFor`, which forwards it to data hooks via `JBBeforeCashOutRecordedContext.beneficiaryIsFeeless`. |
366
+ | **forceApprove** | `_beforeSendFor` now uses `forceApprove` instead of `safeIncreaseAllowance` for outbound ERC-20 token approvals, avoiding reverts on tokens (e.g. USDT) that require zero allowance before re-approval. |
329
367
 
330
368
  ### 8.3 JBRulesets
331
369
 
@@ -341,6 +379,7 @@ No changes.
341
379
  | Change | Description |
342
380
  |--------|-------------|
343
381
  | **Migration ordering** | `setControllerOf` now calls `migrate()` on the old controller BEFORE updating `controllerOf` in storage (so `migrate()` runs while the directory still points to the old controller). After updating storage, it calls `afterReceiveMigrationFrom` on the new controller. |
382
+ | **ADD_TERMINALS permission on implicit addition** | `setPrimaryTerminalOf` now requires the `ADD_TERMINALS` permission when the terminal is not already in the project's terminal list. Previously, implicit addition had no permission check beyond `SET_PRIMARY_TERMINAL`. |
344
383
 
345
384
  ### 8.5 JBSplits
346
385
 
@@ -365,6 +404,7 @@ No changes.
365
404
  | Change | Description |
366
405
  |--------|-------------|
367
406
  | **Incomplete round check order** | The check for `updatedAt == 0` (incomplete round) now runs BEFORE the stale price check, avoiding false stale errors on incomplete rounds. |
407
+ | **answeredInRound check** | `currentUnitPrice` now checks `answeredInRound < roundId` and reverts with `IncompleteRound` if the answer was carried over from a previous round. |
368
408
 
369
409
  ### 8.9 JBChainlinkV3SequencerPriceFeed
370
410
 
@@ -372,6 +412,7 @@ No changes.
372
412
  |--------|-------------|
373
413
  | **Typo fix** | Error parameter `gradePeriodTime` corrected to `gracePeriodTime` in `JBChainlinkV3SequencerPriceFeed_SequencerDown`. |
374
414
  | **Threshold docs** | Constructor parameter `threshold` documentation corrected from "blocks" to "seconds". |
415
+ | **Sequencer down check** | Changed `answer == 1` to `answer != 0` in the sequencer uptime check. Any non-zero answer is now treated as sequencer-down, which is the safer default for future Chainlink feed versions. |
375
416
 
376
417
  ### 8.10 Solidity Version
377
418
 
package/README.md CHANGED
@@ -147,7 +147,7 @@ graph TD;
147
147
  | Contract | Description |
148
148
  |----------|-------------|
149
149
  | `JBERC20` | Cloneable ERC-20 with ERC20Votes and ERC20Permit. Deployed by `JBTokens` via `Clones.clone()`. Owned by `JBTokens`. Name and symbol can be updated by the project owner via `JBController.setTokenMetadataOf`. |
150
- | `JBChainlinkV3PriceFeed` | `IJBPriceFeed` backed by a Chainlink `AggregatorV3Interface` with staleness threshold. Rejects negative/zero prices and incomplete rounds. |
150
+ | `JBChainlinkV3PriceFeed` | `IJBPriceFeed` backed by a Chainlink `AggregatorV3Interface` with staleness threshold. Rejects negative/zero prices, incomplete rounds (`updatedAt == 0`), and stale answers carried from previous rounds (`answeredInRound < roundId`). |
151
151
  | `JBChainlinkV3SequencerPriceFeed` | Extends `JBChainlinkV3PriceFeed` with L2 sequencer uptime validation and grace period for Optimism/Arbitrum. |
152
152
  | `JBMatchingPriceFeed` | Returns 1:1 price (e.g., ETH/NATIVE_TOKEN on applicable chains). Lives in `src/periphery/`. |
153
153
 
@@ -196,7 +196,7 @@ This section summarizes known risks and design trade-offs. None of these are unm
196
196
 
197
197
  ### Reentrancy
198
198
 
199
- The protocol does not use an explicit `ReentrancyGuard`. Instead, it relies on **state ordering**: all storage writes (balance updates, token mints/burns, payout limit usage) are completed before any external calls (hooks, split payouts, fee processing). This means re-entering a function sees already-updated state, preventing double-spends and double-payouts. The `try-catch` pattern around external calls ensures that hook failures do not leave the protocol in an inconsistent state -- failed calls return funds to the project balance.
199
+ The protocol does not use an explicit `ReentrancyGuard`. Instead, it relies on **state ordering**: all storage writes (balance updates, token mints/burns, payout limit usage) are completed before any external calls (hooks, split payouts, fee processing). This means re-entering a function sees already-updated state, preventing double-spends and double-payouts. The `try-catch` pattern around external calls ensures that hook failures do not leave the protocol in an inconsistent state -- failed calls return funds to the project balance. This includes reserved token split hooks: `processSplitWith` is wrapped in try-catch, and a reverting hook emits `SplitHookReverted` instead of blocking distribution.
200
200
 
201
201
  ### Unbounded Arrays
202
202
 
package/RISKS.md CHANGED
@@ -50,15 +50,16 @@ No `ReentrancyGuard` is used. The system relies on state ordering and the `Inade
50
50
  | `_cashOutTokensOf` | `STORE.recordCashOutFor` (balance decremented), `controller.burnTokensOf` (tokens burned), `_transferFrom` (beneficiary paid) | Cash out hooks via `_fulfillCashOutHookSpecificationsFor`, then `_takeFeeFrom` | MEDIUM -- beneficiary receives funds before hooks execute; hooks run before fees are taken |
51
51
  | `executePayout` | `STORE.recordPayoutFor` already consumed payout limit | `split.hook.processSplitWith`, `terminal.pay/addToBalance` | MEDIUM -- split hook receives funds and can re-enter; payout limit already consumed prevents double-payout |
52
52
  | `processHeldFeesOf` | `delete _heldFeesOf[...][currentIndex]`, `_nextHeldFeeIndexOf` incremented | `_processFee` -> `this.executeProcessFee` -> `terminal.pay` | LOW -- index advanced before external call; re-reads from storage each iteration |
53
- | `_sendReservedTokensToSplitsOf` | `pendingReservedTokenBalanceOf` zeroed, tokens minted to controller | Split hooks, terminal payments | LOW -- pending balance cleared before minting prevents double-distribution |
53
+ | `_sendReservedTokensToSplitsOf` | `pendingReservedTokenBalanceOf` zeroed, tokens minted to controller | Split hooks (try-catch), terminal payments | LOW -- pending balance cleared before minting prevents double-distribution. Split hook `processSplitWith` is wrapped in try-catch; a reverting hook emits `SplitHookReverted` but does not block distribution. Tokens already transferred to the hook via `safeTransfer` remain with the hook. |
54
54
  | `_useAllowanceOf` | `STORE.recordUsedAllowanceOf` (allowance consumed, balance decremented) | `_takeFeeFrom` (fee payment/holding), `_transferFrom` (beneficiary) | LOW -- allowance consumed before calls |
55
- | `migrateBalanceOf` | `STORE.recordTerminalMigration` (balance zeroed) | `to.addToBalanceOf` | LOW -- balance zeroed before transfer |
55
+ | `migrateBalanceOf` | `STORE.recordTerminalMigration` (balance zeroed), `_takeFeeFrom` (if non-feeless destination) | `to.addToBalanceOf` | LOW -- balance zeroed before transfer, fee deducted before transfer |
56
56
 
57
57
  ### Cross-Function Reentrancy to Explore
58
58
 
59
59
  - **Pay hook -> `cashOutTokensOf`**: After `_pay` mints tokens, a pay hook could call `cashOutTokensOf`. The cash out sees post-payment balance and post-mint supply. Not profitable after fees in tested scenarios, but verify with data hooks that modify weights.
60
60
  - **Cash out hook -> `pay`**: During `_cashOutTokensOf`, after tokens are burned and beneficiary is paid, a cash out hook could call `pay()` adding to the balance. Fees haven't been taken yet at this point. Verify the fee calculation on `amountEligibleForFees` isn't affected.
61
- - **Split hook -> `pay` on same project**: During `sendPayoutsOf`, a split hook receives funds and calls `pay()` on the same project. Payout limit is consumed, but the payment increases balance and mints tokens. The funds came from the project's own balance, so no value creation -- but verify the accounting.
61
+ - **Split hook -> `pay` on same project**: During `sendPayoutsOf`, a split hook receives funds and calls `pay()` on the same project. Payout limit is consumed, but the payment increases balance and mints tokens. The funds came from the project's own balance, so no value creation -- but verify the accounting. Note: `_pay` now reverts with `MintNotAllowed` when `payer == address(this)` to prevent same-project intra-terminal payout splits from minting tokens against existing balance. The try-catch in the split group lib catches this and restores the balance.
62
+ - **Reserved token split hook -> re-entry**: During `_sendReservedTokensToSplitsOf`, a split hook's `processSplitWith` is now wrapped in try-catch. A reverting hook emits `SplitHookReverted` and does not block distribution. Tokens transferred to the hook before the call remain with it. A hook that re-enters during `processSplitWith` sees post-mint state (pending reserved balance already zeroed).
62
63
  - **Fee processing -> any re-entry**: `_processFee` uses `this.executeProcessFee` (external call via try-catch). Inside, it calls `terminal.pay()` on project #1. If project #1 has a pay hook that calls back, the fee amount is already deducted.
63
64
 
64
65
  ### Key Backstop
@@ -74,10 +75,14 @@ No `ReentrancyGuard` is used. The system relies on state ordering and the `Inade
74
75
  - **Empty permission arrays pass `hasPermissions`.** By design (vacuous truth). Any caller that expects "the operator has at least one of these permissions" must validate the array is non-empty.
75
76
  - **`OMNICHAIN_RULESET_OPERATOR` bypass.** This immutable address can `launchRulesetsFor`, `queueRulesetsOf`, and set terminals for any project without owner permission. The trust assumption is that this operator only queues rulesets that the omnichain deployer's logic permits. If this address is an EOA or an upgradeable contract, it is a single point of failure for all projects.
76
77
 
78
+ ### Directory Terminal Addition
79
+
80
+ - **`setPrimaryTerminalOf` implicit terminal addition** now requires the `ADD_TERMINALS` permission when the terminal is not already in the project's terminal list. This closes a gap where `SET_PRIMARY_TERMINAL` alone could silently add a new terminal without the `ADD_TERMINALS` permission check. If the terminal is already in the list, no additional permission is needed.
81
+
77
82
  ### Migration
78
83
 
79
84
  - **Controller migration** requires `allowSetController` in the current ruleset. During migration, `JBController.migrate()` reverts if there are pending reserved tokens. An attacker cannot front-run migration to inflate pending reserves (they'd need mint permission), but a project with organic pending reserves must distribute them first.
80
- - **Terminal migration** requires `allowTerminalMigration` in the current ruleset. Held fees are intentionally NOT migrated -- they belong to project #1. Verify that a project owner cannot use migration to escape held fee obligations.
85
+ - **Terminal migration** requires `allowTerminalMigration` in the current ruleset. Held fees are intentionally NOT migrated -- they belong to project #1. Migration to a non-feeless terminal charges the standard 2.5% protocol fee on the full balance, settling any `_feeFreeSurplusOf` liability.
81
86
  - **Directory updates** (`setTerminalsOf`, `setControllerOf`) are gated by `IJBDirectoryAccessControl` checks that read from the current ruleset's metadata flags. If the current ruleset allows these changes, anyone with the appropriate permission can redirect all of a project's fund flows.
82
87
 
83
88
  ### Ruleset Queuing
@@ -100,8 +105,8 @@ No `ReentrancyGuard` is used. The system relies on state ordering and the `Inade
100
105
 
101
106
  ### Price Feed Reverts
102
107
 
103
- - If a Chainlink feed is stale beyond its threshold, `JBChainlinkV3PriceFeed` reverts. This blocks all multi-currency operations for projects using that feed: `pay`, `cashOutTokensOf`, `sendPayoutsOf`, `useAllowanceOf`.
104
- - L2 sequencer downtime triggers `JBChainlinkV3SequencerPriceFeed` to revert during downtime + grace period.
108
+ - If a Chainlink feed is stale beyond its threshold, `JBChainlinkV3PriceFeed` reverts. This blocks all multi-currency operations for projects using that feed: `pay`, `cashOutTokensOf`, `sendPayoutsOf`, `useAllowanceOf`. The feed also reverts with `IncompleteRound` when `answeredInRound < roundId` (answer carried from a previous round).
109
+ - L2 sequencer downtime triggers `JBChainlinkV3SequencerPriceFeed` to revert during downtime + grace period. The sequencer check uses `answer != 0` (any non-zero value = down), which is forward-compatible with future Chainlink feed versions that may use values other than `1` for the down state.
105
110
  - Single-currency projects (where `amount.currency == ruleset.baseCurrency()`) are unaffected.
106
111
  - Price feeds are immutable once set in `JBPrices` -- a broken feed cannot be replaced.
107
112
 
package/SKILLS.md CHANGED
@@ -21,8 +21,8 @@ The core Juicebox V6 protocol on EVM: a modular system for launching treasury-ba
21
21
  | `JBPrices` | Price feed registry with project-specific and protocol-wide default feeds. Immutable once set. |
22
22
  | `JBERC20` | Cloneable ERC-20 with Votes + Permit. Owned by `JBTokens`. Deployed via `Clones.clone()`. |
23
23
  | `JBFeelessAddresses` | Allowlist for fee-exempt addresses. |
24
- | `JBChainlinkV3PriceFeed` | Chainlink AggregatorV3 price feed with staleness threshold. Rejects negative/zero prices. |
25
- | `JBChainlinkV3SequencerPriceFeed` | L2 sequencer-aware Chainlink feed (Optimism/Arbitrum) with grace period after restart. |
24
+ | `JBChainlinkV3PriceFeed` | Chainlink AggregatorV3 price feed with staleness threshold. Rejects negative/zero prices, incomplete rounds (`updatedAt == 0`), and stale answers carried from previous rounds (`answeredInRound < roundId`). |
25
+ | `JBChainlinkV3SequencerPriceFeed` | L2 sequencer-aware Chainlink feed (Optimism/Arbitrum) with grace period after restart. Treats any non-zero sequencer answer as down (`answer != 0`). |
26
26
  | `JBDeadline` | Approval hook: rejects rulesets queued within `DURATION` seconds of start. Ships as `JBDeadline3Hours`, `JBDeadline1Day`, `JBDeadline3Days`, `JBDeadline7Days`. |
27
27
  | `JBMatchingPriceFeed` | Always returns 1:1. For equivalent currencies (e.g. ETH/NATIVE_TOKEN). |
28
28
 
@@ -121,7 +121,7 @@ The core Juicebox V6 protocol on EVM: a modular system for launching treasury-ba
121
121
  | `isTerminalOf(uint256 projectId, IJBTerminal terminal)` | Checks if a terminal belongs to a project. |
122
122
  | `setControllerOf(uint256 projectId, IERC165 controller)` | Sets the project's controller. |
123
123
  | `setTerminalsOf(uint256 projectId, IJBTerminal[] terminals)` | Sets the project's terminals. |
124
- | `setPrimaryTerminalOf(uint256 projectId, address token, IJBTerminal terminal)` | Sets the primary terminal for a token. |
124
+ | `setPrimaryTerminalOf(uint256 projectId, address token, IJBTerminal terminal)` | Sets the primary terminal for a token. Requires `ADD_TERMINALS` permission if the terminal is not already in the project's terminal list (implicit addition). |
125
125
  | `setIsAllowedToSetFirstController(address addr, bool flag)` | Allows/disallows an address to set a project's first controller. Owner-only. |
126
126
 
127
127
  ### JBPrices
@@ -288,7 +288,7 @@ Quick-reference for the most common `JBPermissionIds` values (from `@bananapus/p
288
288
  | `13` | `TRANSFER_CREDITS` | `JBController.transferCreditsFrom` |
289
289
  | `14` | `SET_CONTROLLER` | `JBDirectory.setControllerOf` |
290
290
  | `15` | `SET_TERMINALS` | `JBDirectory.setTerminalsOf` (can remove primary terminal) |
291
- | `16` | `SET_PRIMARY_TERMINAL` | `JBDirectory.setPrimaryTerminalOf` |
291
+ | `16` | `SET_PRIMARY_TERMINAL` | `JBDirectory.setPrimaryTerminalOf` (also requires `ADD_TERMINALS` if the terminal is not already in the project's list) |
292
292
  | `17` | `USE_ALLOWANCE` | `JBMultiTerminal.useAllowanceOf` |
293
293
  | `18` | `SET_SPLIT_GROUPS` | `JBController.setSplitGroupsOf` |
294
294
  | `19` | `ADD_PRICE_FEED` | `JBController.addPriceFeedFor` |
@@ -359,6 +359,7 @@ The most important events for indexing and off-chain monitoring. Indexed params
359
359
  | `BurnTokens` | `IJBController` | `holder*`, `projectId*`, `tokenCount` |
360
360
  | `SendReservedTokensToSplits` | `IJBController` | `rulesetId*`, `rulesetCycleNumber*`, `projectId*`, `owner`, `tokenCount`, `leftoverAmount` |
361
361
  | `SendReservedTokensToSplit` | `IJBController` | `projectId*`, `rulesetId*`, `groupId*`, `split`, `tokenCount` |
362
+ | `SplitHookReverted` | `IJBController` | `projectId*`, `hook`, `reason` |
362
363
  | `LaunchProject` | `IJBController` | `rulesetId`, `projectId`, `projectUri` |
363
364
  | `QueueRulesets` | `IJBController` | `rulesetId`, `projectId` |
364
365
  | `DeployERC20` | `IJBController` | `projectId*`, `deployer*`, `salt`, `saltHash`, `caller` |
package/USER_JOURNEYS.md CHANGED
@@ -300,15 +300,18 @@ All user paths through the Juicebox V6 core protocol. For each journey: entry po
300
300
  - `to` -- Destination terminal
301
301
 
302
302
  **State changes**:
303
- 1. `JBTerminalStore.balanceOf[oldTerminal][projectId][token]` set to 0
304
- 2. Funds transferred to destination terminal via `to.addToBalanceOf()`
305
- 3. Destination terminal records the added balance
303
+ 1. `_feeFreeSurplusOf[projectId][token]` cleared
304
+ 2. `JBTerminalStore.balanceOf[oldTerminal][projectId][token]` set to 0
305
+ 3. If destination is non-feeless: 2.5% protocol fee deducted from balance via `_takeFeeFrom`
306
+ 4. Remaining funds transferred to destination terminal via `to.addToBalanceOf()`
307
+ 5. Destination terminal records the added balance
306
308
 
307
309
  **Events**: `MigrateTerminal(projectId, token, to, amount, caller)`
308
310
 
309
311
  **Edge cases**:
310
312
  - Requires `allowTerminalMigration` in current ruleset
311
313
  - Destination terminal must have accounting context for the token (validated via `accountingContextForTokenOf`)
314
+ - **Standard 2.5% protocol fee** is charged when migrating to a non-feeless terminal, consistent with all other fund egress. This also settles any `_feeFreeSurplusOf` liability.
312
315
  - **Held fees are NOT transferred** -- they remain in the old terminal. Held fees belong to the fee beneficiary (project #1), not the migrating project.
313
316
  - If balance is 0, no transfer occurs
314
317
  - This only migrates one token's balance. Must be called once per token.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/core-v6",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,14 +26,14 @@
26
26
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-core-v6'"
27
27
  },
28
28
  "dependencies": {
29
- "@bananapus/address-registry-v6": "^0.0.15",
29
+ "@bananapus/address-registry-v6": "^0.0.16",
30
30
  "@bananapus/permission-ids-v6": "^0.0.14",
31
- "@chainlink/contracts": "^1.3.0",
31
+ "@chainlink/contracts": "^1.5.0",
32
32
  "@openzeppelin/contracts": "^5.6.1",
33
33
  "@prb/math": "^4.1.1",
34
34
  "@uniswap/permit2": "github:Uniswap/permit2"
35
35
  },
36
36
  "devDependencies": {
37
- "@sphinx-labs/plugins": "^0.33.2"
37
+ "@sphinx-labs/plugins": "^0.33.3"
38
38
  }
39
39
  }
@@ -147,31 +147,34 @@ contract DeployPeriphery is Script, Sphinx {
147
147
  }
148
148
  require(address(feed) != address(0), "Invalid price feed");
149
149
 
150
- core.prices
150
+ try core.prices
151
151
  .addPriceFeedFor({
152
152
  projectId: 0,
153
153
  pricingCurrency: JBCurrencyIds.USD,
154
154
  unitCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
155
155
  feed: feed
156
- });
156
+ }) {}
157
+ catch {}
157
158
 
158
159
  // WARN: We are using the same price feed as the native token for the USD price feed. Which is only valid on
159
160
  // chains where Ether is the native asset. We *NEED* to update this when we deploy to a non-ether chain!
160
- core.prices
161
+ try core.prices
161
162
  .addPriceFeedFor({
162
163
  projectId: 0, pricingCurrency: JBCurrencyIds.USD, unitCurrency: JBCurrencyIds.ETH, feed: feed
163
- });
164
+ }) {}
165
+ catch {}
164
166
 
165
167
  // If the native asset for this chain is ether, then the conversion from native asset to ether is 1:1.
166
168
  // NOTE: We need to refactor this the moment we add a chain where its native token is *NOT* ether.
167
169
  // As otherwise prices for the `NATIVE_TOKEN` will be incorrect!
168
- core.prices
170
+ try core.prices
169
171
  .addPriceFeedFor({
170
172
  projectId: 0,
171
173
  pricingCurrency: JBCurrencyIds.ETH,
172
174
  unitCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
173
175
  feed: matchingPriceFeed
174
- });
176
+ }) {}
177
+ catch {}
175
178
 
176
179
  // Deploy the USDC/USD price feed.
177
180
  _deployUSDCFeed(L2GracePeriod);
@@ -279,14 +282,12 @@ contract DeployPeriphery is Script, Sphinx {
279
282
  require(usdc.code.length > 0, "Invalid USDC address");
280
283
  require(address(usdcFeed) != address(0), "Invalid USDC price feed");
281
284
 
282
- core.prices
285
+ // forge-lint: disable-next-line(unsafe-typecast)
286
+ try core.prices
283
287
  .addPriceFeedFor({
284
- projectId: 0,
285
- pricingCurrency: JBCurrencyIds.USD,
286
- // forge-lint: disable-next-line(unsafe-typecast)
287
- unitCurrency: uint32(uint160(usdc)),
288
- feed: usdcFeed
289
- });
288
+ projectId: 0, pricingCurrency: JBCurrencyIds.USD, unitCurrency: uint32(uint160(usdc)), feed: usdcFeed
289
+ }) {}
290
+ catch {}
290
291
  }
291
292
 
292
293
  /// @dev This helper predicts addresses using the Arachnid CREATE2 deployer, but actual deployments go through
@@ -50,12 +50,15 @@ contract JBChainlinkV3PriceFeed is IJBPriceFeed {
50
50
  function currentUnitPrice(uint256 decimals) public view virtual override returns (uint256) {
51
51
  // Get the latest round information from the feed.
52
52
  // slither-disable-next-line unused-return
53
- (, int256 price,, uint256 updatedAt,) = FEED.latestRoundData();
53
+ (uint80 roundId, int256 price,, uint256 updatedAt, uint80 answeredInRound) = FEED.latestRoundData();
54
54
 
55
55
  // Make sure the round is finished (check before stale price to avoid false stale on incomplete rounds).
56
56
  // slither-disable-next-line incorrect-equality
57
57
  if (updatedAt == 0) revert JBChainlinkV3PriceFeed_IncompleteRound();
58
58
 
59
+ // Make sure the answer was provided in the current round.
60
+ if (answeredInRound < roundId) revert JBChainlinkV3PriceFeed_IncompleteRound();
61
+
59
62
  // Make sure the price's update threshold is met.
60
63
  if (block.timestamp > THRESHOLD + updatedAt) {
61
64
  revert JBChainlinkV3PriceFeed_StalePrice(block.timestamp, THRESHOLD, updatedAt);
@@ -64,7 +64,7 @@ contract JBChainlinkV3SequencerPriceFeed is JBChainlinkV3PriceFeed {
64
64
  if (startedAt == 0) revert JBChainlinkV3SequencerPriceFeed_InvalidRound();
65
65
 
66
66
  // Revert if sequencer has too recently restarted or is currently down.
67
- if (block.timestamp <= GRACE_PERIOD_TIME + startedAt || answer == 1) {
67
+ if (block.timestamp <= GRACE_PERIOD_TIME + startedAt || answer != 0) {
68
68
  revert JBChainlinkV3SequencerPriceFeed_SequencerDownOrRestarting(
69
69
  block.timestamp, GRACE_PERIOD_TIME, startedAt
70
70
  );
@@ -1024,7 +1024,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
1024
1024
  });
1025
1025
 
1026
1026
  // slither-disable-next-line reentrancy-events
1027
- split.hook
1027
+ try split.hook
1028
1028
  .processSplitWith(
1029
1029
  JBSplitHookContext({
1030
1030
  token: address(token),
@@ -1034,7 +1034,11 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
1034
1034
  groupId: groupId,
1035
1035
  split: split
1036
1036
  })
1037
- );
1037
+ ) {}
1038
+ catch (bytes memory reason) {
1039
+ // If the hook reverts, the tokens already transferred to it stay with the hook.
1040
+ emit SplitHookReverted({projectId: projectId, hook: address(split.hook), reason: reason});
1041
+ }
1038
1042
  // If the split has a project ID, try to pay the project. If that fails, pay the beneficiary.
1039
1043
  } else {
1040
1044
  // Pay the project using the split's beneficiary if one was provided. Otherwise, use the message
@@ -185,6 +185,13 @@ contract JBDirectory is JBPermissioned, Ownable, IJBDirectory {
185
185
  revert JBDirectory_TokenNotAccepted(projectId, token, terminal);
186
186
  }
187
187
 
188
+ // If the terminal is not already in the project's terminal list, require ADD_TERMINALS permission.
189
+ if (!isTerminalOf({projectId: projectId, terminal: terminal})) {
190
+ _requirePermissionFrom({
191
+ account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.ADD_TERMINALS
192
+ });
193
+ }
194
+
188
195
  // Implicit terminal addition is by design. A primary terminal must be in the terminals list;
189
196
  // implicit addition avoids requiring a separate addTerminalsOf call.
190
197
  _addTerminalIfNeeded({projectId: projectId, terminal: terminal});
@@ -59,6 +59,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
59
59
  //*********************************************************************//
60
60
 
61
61
  error JBMultiTerminal_FeeTerminalNotFound(address token);
62
+ error JBMultiTerminal_MintNotAllowed();
62
63
  error JBMultiTerminal_NoMsgValueAllowed(uint256 value);
63
64
  error JBMultiTerminal_OverflowAlert(uint256 value, uint256 limit);
64
65
  error JBMultiTerminal_PermitAllowanceNotEnough(uint256 amount, uint256 allowance);
@@ -386,6 +387,15 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
386
387
  metadata: metadata
387
388
  });
388
389
  } else {
390
+ // Revert if this is a self-referencing payout (project paying itself via a split).
391
+ // Same-project pay splits would mint tokens against existing balance without new funds entering.
392
+ // Projects that want to mint should do so explicitly via the controller.
393
+ // Cross-project pay splits on the same terminal are allowed (different project receives the funds).
394
+ // The try-catch in the split group lib catches this revert and restores the balance.
395
+ if (terminal == this && split.projectId == projectId) {
396
+ revert JBMultiTerminal_MintNotAllowed();
397
+ }
398
+
389
399
  // Keep a reference to the beneficiary of the payment.
390
400
  address beneficiary = split.beneficiary != address(0) ? split.beneficiary : originalMessageSender;
391
401
 
@@ -397,6 +407,14 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
397
407
  beneficiary: beneficiary,
398
408
  metadata: metadata
399
409
  });
410
+
411
+ // Cap fee-free surplus at remaining balance.
412
+ // Why: _feeFreeSurplusOf was incremented by the full netPayoutAmount above, but if the
413
+ // destination project's data hook forwarded part of the payment to pay hooks, the store
414
+ // only recorded a partial balance increase. Without this cap, _feeFreeSurplusOf can exceed
415
+ // STORE.balanceOf, causing users to be overcharged fees on zero-tax cashouts.
416
+ // slither-disable-next-line reentrancy-eth
417
+ _capFeeFreeSurplus({projectId: split.projectId, token: token});
400
418
  }
401
419
  } else {
402
420
  // If there's a beneficiary, send the funds directly to the beneficiary.
@@ -492,12 +510,14 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
492
510
  revert JBMultiTerminal_TerminalTokensIncompatible({projectId: projectId, token: token, terminal: to});
493
511
  }
494
512
 
495
- // Clear fee-free surplus tracking before migration. The new terminal has no fee-free history.
496
- // This prevents stale fee-free surplus from persisting if the project later migrates back.
513
+ // Clear fee-free surplus tracking the fee-free liability is settled by the migration fee below.
497
514
  delete _feeFreeSurplusOf[projectId][token];
498
515
 
499
516
  // Terminal migration intentionally does not transfer held fees. Held fees belong to the
500
517
  // fee beneficiary (project #1), not the migrating project. They unlock after 28 days regardless of terminal.
518
+ // After migration, `processHeldFeesOf()` on this terminal still works — it reads from `_heldFeesOf` and
519
+ // sends fees to the fee project terminal. The migrated project's balance on this terminal is zero, but held
520
+ // fees are backed by the terminal's own token balance (not the project's recorded balance).
501
521
  // Record the migration in the store.
502
522
  // slither-disable-next-line reentrancy-events
503
523
  balance = STORE.recordTerminalMigration({projectId: projectId, token: token});
@@ -506,17 +526,33 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
506
526
 
507
527
  // Transfer the balance if needed.
508
528
  if (balance != 0) {
529
+ // Migration to a non-feeless terminal incurs the standard 2.5% fee, same as any other fund egress.
530
+ // This also settles any fee-free surplus liability that would otherwise be lost on the new terminal.
531
+ uint256 feeAmount;
532
+ if (!_isFeeless(address(to)) && projectId != _FEE_BENEFICIARY_PROJECT_ID) {
533
+ feeAmount = _takeFeeFrom({
534
+ projectId: projectId,
535
+ token: token,
536
+ amount: balance,
537
+ beneficiary: payable(_ownerOf(projectId)),
538
+ shouldHoldFees: false
539
+ });
540
+ }
541
+
542
+ // Transfer the balance minus the fee to the new terminal.
543
+ uint256 migrationAmount = balance - feeAmount;
544
+
509
545
  // Trigger any inherited pre-transfer logic.
510
546
  // If this terminal's token is the native token, send it in `msg.value`.
511
547
  // slither-disable-next-line reentrancy-events
512
- uint256 payValue = _beforeTransferTo({to: address(to), token: token, amount: balance});
548
+ uint256 payValue = _beforeTransferTo({to: address(to), token: token, amount: migrationAmount});
513
549
 
514
- // Withdraw the balance to transfer to the new terminal;
550
+ // Withdraw the balance to transfer to the new terminal.
515
551
  // slither-disable-next-line reentrancy-events
516
552
  to.addToBalanceOf{value: payValue}({
517
553
  projectId: projectId,
518
554
  token: token,
519
- amount: balance,
555
+ amount: migrationAmount,
520
556
  shouldReturnHeldFees: false,
521
557
  memo: "",
522
558
  metadata: bytes("")
@@ -584,13 +620,13 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
584
620
  /// @notice Process any fees that are being held for the project.
585
621
  /// @dev Reentrancy safety: the loop re-reads `_nextHeldFeeIndexOf` from storage each iteration and advances the
586
622
  /// index before the external `_processFee` call, so a reentrant call cannot double-process the same fee entry.
587
- /// @dev Phantom balance risk: after a terminal migration, held fees remain in this terminal but the backing tokens
588
- /// have been transferred to the new terminal via `migrateBalanceOf`. If `_processFee` reverts (e.g. the fee
589
- /// terminal rejects the payment), the catch block calls `_recordAddedBalanceFor`, which credits the project's
590
- /// recorded balance without any actual tokens arriving creating a phantom balance. This is an accepted
591
- /// trade-off:
592
- /// the alternative (losing the fee amount entirely on revert) is worse. Callers should be aware that processing
593
- /// held fees post-migration may inflate the project's recorded balance if any fee payments revert.
623
+ /// @dev Held fees after migration: held fees remain in this terminal after `migrateBalanceOf` because their backing
624
+ /// tokens are not part of `balanceOf` they were already deducted from the recorded balance during the payout
625
+ /// that
626
+ /// created them. The actual fee-backing tokens remain in this terminal's token holdings. If `_processFee` reverts
627
+ /// (e.g. the fee terminal rejects the payment), the catch block calls `_recordAddedBalanceFor` to credit the fee
628
+ /// amount back to the project. This credit is backed by the tokens that failed to transfer out. No phantom balance
629
+ /// is created because the tokens never left.
594
630
  /// @dev The index-increment-before-`_processFee` pattern is intentional: locked (not-yet-unlocked) fees are skipped
595
631
  /// via the `unlockTimestamp` check, and advancing the index before the external call prevents reentrancy from
596
632
  /// reprocessing the same fee entry.
@@ -1047,7 +1083,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1047
1083
  if (token == JBConstants.NATIVE_TOKEN) return amount;
1048
1084
 
1049
1085
  // Otherwise, set the allowance, and the payValue should be 0.
1050
- IERC20(token).safeIncreaseAllowance({spender: to, value: amount});
1086
+ IERC20(token).forceApprove({spender: to, value: amount});
1051
1087
  return 0;
1052
1088
  }
1053
1089
 
@@ -1113,8 +1149,6 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1113
1149
  // Non-zero tax: fees apply to the full reclaim amount.
1114
1150
  amountEligibleForFees += reclaimAmount;
1115
1151
  reclaimAmount -= JBFees.feeAmountFrom({amountBeforeFee: reclaimAmount, feePercent: FEE});
1116
- // Cap fee-free surplus at remaining balance (non-fee-free funds leave first).
1117
- _reduceFeeFreeSurplus({projectId: projectId, token: tokenToReclaim});
1118
1152
  } else {
1119
1153
  // Zero tax: fees apply only up to the fee-free surplus (round-trip prevention).
1120
1154
  uint256 feeFreeSurplus = _feeFreeSurplusOf[projectId][tokenToReclaim];
@@ -1125,9 +1159,6 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1125
1159
  reclaimAmount -= JBFees.feeAmountFrom({amountBeforeFee: feeableAmount, feePercent: FEE});
1126
1160
  }
1127
1161
  }
1128
- } else {
1129
- // Feeless beneficiary: fee logic skipped, but still cap fee-free surplus at remaining balance.
1130
- _reduceFeeFreeSurplus({projectId: projectId, token: tokenToReclaim});
1131
1162
  }
1132
1163
 
1133
1164
  // Subtract the fee from the reclaim amount.
@@ -1154,6 +1185,16 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1154
1185
  });
1155
1186
  }
1156
1187
 
1188
+ // Cap fee-free surplus at remaining balance.
1189
+ // Why: this single call replaces per-branch calls so that EVERY cashout path (non-zero tax,
1190
+ // zero tax, and feeless beneficiary) gets the cap. Without it, the zero-tax path would use
1191
+ // a stale _feeFreeSurplusOf that may exceed STORE.balanceOf (e.g. if pay hooks reduced the
1192
+ // balance during a prior inbound payout), overcharging fees on round-trip prevention.
1193
+ // Placed after hook fulfillment so any further balance reductions from cashout hooks are
1194
+ // also accounted for.
1195
+ // slither-disable-next-line reentrancy-eth
1196
+ _capFeeFreeSurplus({projectId: projectId, token: tokenToReclaim});
1197
+
1157
1198
  // Take the fee from all outbound reclaimings.
1158
1199
  if (amountEligibleForFees != 0) {
1159
1200
  _takeFeeFrom({
@@ -1674,7 +1715,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1674
1715
  STORE.recordPayoutFor({projectId: projectId, token: token, amount: amount, currency: currency});
1675
1716
 
1676
1717
  // Cap fee-free surplus at remaining balance. Non-fee-free funds leave first.
1677
- _reduceFeeFreeSurplus({projectId: projectId, token: token});
1718
+ _capFeeFreeSurplus({projectId: projectId, token: token});
1678
1719
 
1679
1720
  // Get a reference to the project's owner.
1680
1721
  // The owner will receive tokens minted by paying the platform fee and receive any leftover funds not sent to
@@ -1875,7 +1916,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1875
1916
  STORE.recordUsedAllowanceOf({projectId: projectId, token: token, amount: amount, currency: currency});
1876
1917
 
1877
1918
  // Cap fee-free surplus at remaining balance. Non-fee-free funds leave first.
1878
- _reduceFeeFreeSurplus({projectId: projectId, token: token});
1919
+ _capFeeFreeSurplus({projectId: projectId, token: token});
1879
1920
 
1880
1921
  // Take a fee from the `amountPaidOut`, if needed.
1881
1922
  // The net amount is the final amount withdrawn after the fee has been taken.
@@ -1917,11 +1958,11 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1917
1958
  /// and then cashing out without incurring fees.
1918
1959
  /// @param projectId The ID of the project.
1919
1960
  /// @param token The token whose fee-free surplus to cap.
1920
- function _reduceFeeFreeSurplus(uint256 projectId, address token) internal {
1961
+ function _capFeeFreeSurplus(uint256 projectId, address token) internal {
1921
1962
  // Get the current fee-free surplus for this project/token pair.
1922
1963
  uint256 feeFreeSurplus = _feeFreeSurplusOf[projectId][token];
1923
1964
 
1924
- // Nothing to reduce if there's no fee-free surplus tracked.
1965
+ // Nothing to cap if there's no fee-free surplus tracked.
1925
1966
  if (feeFreeSurplus == 0) return;
1926
1967
 
1927
1968
  // Get the project's remaining balance (already decremented by the store's record call).