@bananapus/router-terminal-v6 0.0.16 → 0.0.18

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.
Files changed (39) hide show
  1. package/ADMINISTRATION.md +21 -3
  2. package/ARCHITECTURE.md +61 -10
  3. package/AUDIT_INSTRUCTIONS.md +87 -12
  4. package/CHANGE_LOG.md +13 -1
  5. package/README.md +34 -13
  6. package/RISKS.md +13 -3
  7. package/SKILLS.md +43 -23
  8. package/STYLE_GUIDE.md +2 -2
  9. package/USER_JOURNEYS.md +7 -6
  10. package/foundry.toml +1 -1
  11. package/package.json +3 -3
  12. package/script/Deploy.s.sol +1 -1
  13. package/script/helpers/RouterTerminalDeploymentLib.sol +1 -1
  14. package/src/JBRouterTerminal.sol +60 -19
  15. package/src/JBRouterTerminalRegistry.sol +47 -9
  16. package/src/interfaces/IJBPayerTracker.sol +11 -0
  17. package/src/interfaces/IJBRouterTerminalRegistry.sol +3 -1
  18. package/src/libraries/JBSwapLib.sol +1 -1
  19. package/test/RouterTerminal.t.sol +1 -1
  20. package/test/RouterTerminalCashOutFork.t.sol +1 -1
  21. package/test/RouterTerminalCreditCashout.t.sol +1 -1
  22. package/test/RouterTerminalERC2771.t.sol +1 -1
  23. package/test/RouterTerminalFeeCashOutFork.t.sol +1 -1
  24. package/test/RouterTerminalFork.t.sol +1 -1
  25. package/test/RouterTerminalMultihopFork.t.sol +1 -1
  26. package/test/RouterTerminalPreviewFork.t.sol +1 -1
  27. package/test/RouterTerminalReentrancy.t.sol +1 -1
  28. package/test/RouterTerminalRegistry.t.sol +8 -4
  29. package/test/RouterTerminalSandwichFork.t.sol +1 -1
  30. package/test/TestAuditGaps.sol +298 -8
  31. package/test/audit/CodexRegistryAddToBalancePartialFill.t.sol +463 -0
  32. package/test/audit/PayerTrackerRefund.t.sol +177 -0
  33. package/test/audit/Permit2AllowanceFailed.t.sol +158 -0
  34. package/test/fork/V4QuoteAndSettlementFork.t.sol +1 -1
  35. package/test/invariant/RouterTerminalInvariant.t.sol +1 -1
  36. package/test/regression/CashOutLoopLimit.t.sol +1 -1
  37. package/test/regression/LockTerminalRace.t.sol +1 -1
  38. package/test/regression/RouterTerminalEdgeCases.t.sol +465 -0
  39. package/test/regression/V4SpotPriceSlippage.t.sol +1 -1
package/ADMINISTRATION.md CHANGED
@@ -13,7 +13,7 @@ Admin privileges and their scope in nana-router-terminal-v6.
13
13
  ### 2. Project Owner / SET_ROUTER_TERMINAL Delegate
14
14
 
15
15
  **Contract**: `JBRouterTerminalRegistry`
16
- **Assigned via**: Ownership of the project's ERC-721 NFT (via `JBProjects.ownerOf(projectId)`), or delegation via `JBPermissions` with permission ID `SET_ROUTER_TERMINAL` (28).
16
+ **Assigned via**: Ownership of the project's ERC-721 NFT (via `JBProjects.ownerOf(projectId)`), or delegation via `JBPermissions` with permission ID `SET_ROUTER_TERMINAL` (29).
17
17
  **Scope**: Per-project -- controls which router terminal a specific project uses, and can permanently lock that choice.
18
18
 
19
19
  ### 3. Router Terminal Owner (Ownable)
@@ -22,12 +22,26 @@ Admin privileges and their scope in nana-router-terminal-v6.
22
22
  **Assigned via**: Constructor parameter `owner`, transferable via `Ownable.transferOwnership()`.
23
23
  **Scope**: Currently unused. `JBRouterTerminal` inherits `Ownable` but does not gate any functions behind `onlyOwner`. The owner exists for potential future use or subclass extensions. The `Ownable.renounceOwnership()` and `Ownable.transferOwnership()` functions are inherited but have no practical effect on the terminal's operation.
24
24
 
25
+ **Risk note:** While the owner has no current powers over the terminal's operation, the `Ownable` inheritance means a future code change or subclass could introduce `onlyOwner`-gated functions. If the terminal is deployed with a specific owner address, that address retains transfer rights indefinitely.
26
+
25
27
  ### 4. Credit Cashout Payer (Implicit)
26
28
 
27
29
  **Contract**: `JBRouterTerminal`
28
30
  **Required permission**: `TRANSFER_CREDITS` (permission ID 13) -- must be granted by the payer to the router terminal address for the source project via `JBPermissions`.
29
31
  **Scope**: Per-transaction. Required only when using the `cashOutSource` metadata key to route payments through credit cashouts.
30
32
 
33
+ ## Terminal Resolution
34
+
35
+ When a payment is forwarded through the registry, the terminal is resolved as follows:
36
+
37
+ 1. If the project has called `setTerminalFor(projectId, terminal)`, that explicit terminal is used.
38
+ 2. If no explicit terminal is set, the registry's `defaultTerminal` is used.
39
+ 3. If neither exists, the forwarding reverts.
40
+
41
+ **Lock semantics:** When `lockTerminalFor()` is called on a project with no explicit terminal, the current default is snapshot into `_terminalOf[projectId]` before locking. The project becomes independent of future default changes.
42
+
43
+ **Disallow interaction:** If the registry owner calls `disallowTerminal()` on the current default terminal, the `defaultTerminal` is automatically cleared (set to `address(0)`). Projects relying on the default (without locking) would lose their terminal resolution until a new default is set. Projects should lock their terminal to avoid disruption.
44
+
31
45
  ## Privileged Functions
32
46
 
33
47
  ### JBRouterTerminalRegistry
@@ -37,8 +51,8 @@ Admin privileges and their scope in nana-router-terminal-v6.
37
51
  | `allowTerminal(terminal)` | Registry Owner | `onlyOwner` | Global | Adds a terminal to the allowlist (`isTerminalAllowed[terminal] = true`). Projects can only use allowlisted terminals. |
38
52
  | `disallowTerminal(terminal)` | Registry Owner | `onlyOwner` | Global | Removes a terminal from the allowlist. Also clears `defaultTerminal` if it matches the disallowed terminal. Does NOT affect projects that have already locked their terminal. |
39
53
  | `setDefaultTerminal(terminal)` | Registry Owner | `onlyOwner` | Global | Sets the default terminal for all projects that have not set a project-specific terminal. Also auto-allows the terminal. |
40
- | `setTerminalFor(projectId, terminal)` | Project Owner or Delegate | `SET_ROUTER_TERMINAL` (28) | Per-project | Routes a specific project to a specific allowed terminal. Reverts if the terminal is not allowlisted or if the project's terminal is locked. |
41
- | `lockTerminalFor(projectId, expectedTerminal)` | Project Owner or Delegate | `SET_ROUTER_TERMINAL` (28) | Per-project | Permanently locks the terminal choice for a project. If no explicit terminal is set, snapshots the current default into `_terminalOf[projectId]`. Reverts with `TerminalMismatch` if the resolved terminal differs from `expectedTerminal` (race condition protection). **Irreversible.** |
54
+ | `setTerminalFor(projectId, terminal)` | Project Owner or Delegate | `SET_ROUTER_TERMINAL` (29) | Per-project | Routes a specific project to a specific allowed terminal. Reverts if the terminal is not allowlisted or if the project's terminal is locked. |
55
+ | `lockTerminalFor(projectId, expectedTerminal)` | Project Owner or Delegate | `SET_ROUTER_TERMINAL` (29) | Per-project | Permanently locks the terminal choice for a project. If no explicit terminal is set, snapshots the current default into `_terminalOf[projectId]`. Reverts with `TerminalMismatch` if the resolved terminal differs from `expectedTerminal` (race condition protection). **Irreversible.** |
42
56
 
43
57
  ### JBRouterTerminal
44
58
 
@@ -62,6 +76,8 @@ The following values are set at deploy time and cannot be changed:
62
76
 
63
77
  | Property | Set At | Mutable? | Description |
64
78
  |----------|--------|----------|-------------|
79
+ | `PERMISSIONS` | Constructor | No | JB permissions registry for permission checks |
80
+ | `Trusted forwarder` | Constructor | No | ERC-2771 trusted forwarder for meta-transactions |
65
81
  | `DIRECTORY` | Constructor | No | JB directory for terminal/controller lookups |
66
82
  | `PROJECTS` | Constructor | No | JB project NFT registry |
67
83
  | `TOKENS` | Constructor | No | JB token manager for credit transfers and project token lookups |
@@ -79,6 +95,8 @@ The following values are set at deploy time and cannot be changed:
79
95
 
80
96
  | Property | Set At | Mutable? | Description |
81
97
  |----------|--------|----------|-------------|
98
+ | `PERMISSIONS` | Constructor | No | JB permissions registry for permission checks |
99
+ | `Trusted forwarder` | Constructor | No | ERC-2771 trusted forwarder for meta-transactions |
82
100
  | `PROJECTS` | Constructor | No | JB project NFT registry |
83
101
  | `PERMIT2` | Constructor | No | Permit2 contract for gasless approvals |
84
102
 
package/ARCHITECTURE.md CHANGED
@@ -11,6 +11,7 @@ src/
11
11
  ├── JBRouterTerminal.sol — Payment routing: swap + forward to destination terminal
12
12
  ├── JBRouterTerminalRegistry.sol — Registry mapping projects to router terminal configs
13
13
  ├── interfaces/
14
+ │ ├── IJBPayerTracker.sol
14
15
  │ ├── IJBRouterTerminal.sol
15
16
  │ ├── IJBRouterTerminalRegistry.sol
16
17
  │ └── IWETH9.sol
@@ -25,13 +26,47 @@ src/
25
26
  ### Payment Routing
26
27
  ```
27
28
  Payer → JBRouterTerminal.pay(projectId, token, amount)
28
- → Discover destination project's accepted token
29
- If same token: forward directly to project's terminal
30
- → If different token:
31
- Compare V3 and V4 pool quotes
32
- Swap via better pool
33
- Forward swapped tokens to project's terminal
34
- → Return token count from destination payment
29
+
30
+ ├─ Accept funds (msg.value for native, Permit2 pull for ERC-20, or credit transfer)
31
+
32
+ ├─ If input is a JB project token (credit or ERC-20):
33
+ Cash out recursively via _cashOutLoop (up to 20 hops)
34
+ Reclaimed token becomes the new tokenIn
35
+
36
+ ├─ Resolve tokenOut: what does the destination project accept?
37
+ │ 1. Metadata override ("routeTokenOut")
38
+ │ 2. Direct acceptance — project already accepts tokenIn
39
+ │ 3. NATIVE ↔ WETH equivalence check
40
+ │ 4. Dynamic discovery — iterate project terminals, find swappable token
41
+
42
+ ├─ Convert tokenIn → tokenOut via _convert:
43
+ │ ├─ Same token: no-op
44
+ │ ├─ NATIVE ↔ WETH: wrap (WETH.deposit) or unwrap (WETH.withdraw)
45
+ │ └─ Different tokens: Uniswap swap
46
+ │ │
47
+ │ ├─ If native ETH input: held as raw ETH until V3 callback
48
+ │ │ wraps (WETH.deposit) only the amount the pool consumes
49
+ │ │
50
+ │ ├─ Pool discovery (_discoverPool):
51
+ │ │ Search V3 pools across 4 fee tiers (0.3%, 0.05%, 1%, 0.01%)
52
+ │ │ Search V4 pools across same fee tiers (if PoolManager deployed)
53
+ │ │ Compare in-range liquidity across all candidates
54
+ │ │ Select the single pool with highest liquidity
55
+ │ │
56
+ │ ├─ Quote & slippage (_pickPoolAndQuote):
57
+ │ │ 1. User-provided quote (metadata "quoteForSwap") — used as-is
58
+ │ │ 2. V3 fallback: 10-min TWAP via OracleLibrary.consult()
59
+ │ │ 3. V4 fallback: spot price from getSlot0() (no built-in TWAP)
60
+ │ │ Apply sigmoid slippage: minSlippage + range * impact/(impact+K)
61
+ │ │
62
+ │ ├─ Execute swap via V3 pool.swap() or V4 POOL_MANAGER.unlock()
63
+ │ │
64
+ │ ├─ If native ETH input: wrap any remaining raw ETH (partial fills)
65
+ │ ├─ If native ETH output: unwrap WETH → ETH (WETH.withdraw)
66
+ │ └─ Return leftover input tokens via _resolveRefundTo (checks msg.sender's IJBPayerTracker.originalPayer() via try-catch, falls back to beneficiary/msgSender)
67
+
68
+ ├─ Approve destination terminal for output tokens (or set msg.value for native)
69
+ └─ Forward to destTerminal.pay() → return beneficiary token count
35
70
  ```
36
71
 
37
72
  ### Preview Routing
@@ -48,14 +83,30 @@ Caller → JBRouterTerminal.previewPayFor(projectId, token, amount)
48
83
  |-------|-----------|---------|
49
84
  | Terminal | `IJBTerminal` | Acts as a terminal that routes payments |
50
85
  | Registry | `IJBRouterTerminalRegistry` | Maps projects to routing configs |
86
+ | Payer tracker | `IJBPayerTracker` | Exposes the original payer of a forwarded call for refund resolution |
51
87
  | Permit | `IJBPermitTerminal` | Permit2 token approval support |
52
88
 
53
89
  ## Composition Boundary
54
90
 
55
91
  The router terminal exposes the `IJBTerminal` surface because it needs to participate in Juicebox routing, but its
56
- accounting context is intentionally synthetic. `accountingContextForTokenOf()` returns `decimals = 18` for any token,
57
- and the registry forwards that value unchanged. Treat the router layer as a payment router only, not as an
58
- accounting-sensitive terminal source for loan sizing, debt normalization, or any other decimals-dependent logic.
92
+ accounting context is intentionally synthetic. `accountingContextForTokenOf()` returns `decimals = 18` for native
93
+ tokens and probes `IERC20Metadata.decimals()` for ERC-20s (falling back to `18` if the call fails). The registry
94
+ forwards that value unchanged. Treat the router layer as a payment router only, not as an accounting-sensitive
95
+ terminal source for loan sizing, debt normalization, or any other decimals-dependent logic.
96
+
97
+ ## Design Decisions
98
+
99
+ **Why the router is a terminal, not a standalone contract.** By implementing `IJBTerminal`, the router can be set as a project's terminal in the directory. Payers and frontend integrations call the same `pay()` / `addToBalanceOf()` interface they use for any terminal — no special routing code required on the caller side. The router accepts funds, converts them, and forwards to the real destination terminal in a single transaction.
100
+
101
+ **Why both Uniswap V3 and V4 support.** V4 pools may offer deeper liquidity for certain pairs (especially native-ETH pairs that V4 handles natively), while V3 pools have years of established liquidity. The router searches both and picks the pool with the highest in-range liquidity, giving payers the best available execution without requiring them to know which protocol version has the better pool.
102
+
103
+ **Why synthetic accounting contexts.** The router accepts any token and converts it before forwarding. It never holds balances between transactions, so it has no meaningful accounting of its own. `accountingContextForTokenOf()` returns a best-effort context (probing `decimals()` with an 18-decimal fallback) purely so the directory can register it. The real accounting happens at the destination terminal.
104
+
105
+ **Why the registry pattern.** `JBRouterTerminalRegistry` lets project owners lock a specific `JBRouterTerminal` instance for their project and manage Permit2 approvals in one place. This provides a stable entry point: if the router implementation is upgraded, the registry can be pointed to the new instance without changing the project's directory entry. It also gates which router terminals are allowed, preventing untrusted implementations from being set.
106
+
107
+ **Why `IJBPayerTracker` is a separate interface.** The router terminal needs to know the original payer when called through an intermediary so it can return leftover tokens from partial swap fills to the right address. Rather than coupling the router to `IJBRouterTerminalRegistry` specifically, the refund resolution logic (`_resolveRefundTo`) queries `IJBPayerTracker(msg.sender).originalPayer()` via a try-catch. This means any contract that implements `IJBPayerTracker` -- not just the registry -- can act as a forwarding intermediary. The registry inherits `IJBPayerTracker` through `IJBRouterTerminalRegistry`, keeping backward compatibility while opening the door for other intermediary patterns.
108
+
109
+ **Why liquidity-based pool selection instead of quote comparison.** Comparing actual output quotes across V3 and V4 would require executing (or simulating) swaps on both — expensive on-chain and complex for V4 where swaps must go through `PoolManager.unlock()`. Comparing in-range liquidity is a single `liquidity()` or `getLiquidity()` read per pool, is gas-cheap, and strongly correlates with execution quality for typical swap sizes.
59
110
 
60
111
  ## Dependencies
61
112
  - `@bananapus/core-v6` — Terminal, directory, permissions
@@ -12,8 +12,8 @@ Two contracts. One library. One struct.
12
12
 
13
13
  | Contract | Lines | Role |
14
14
  |----------|-------|------|
15
- | `JBRouterTerminal` | ~1,437 | Core routing engine. Accepts any token, discovers destination project's accepted token, converts via wrap/unwrap, Uniswap V3 swap, Uniswap V4 swap, or JB token cashout chain, then forwards to destination terminal. Implements `IJBTerminal`, `IJBPermitTerminal`, `IUniswapV3SwapCallback`, `IUnlockCallback`. |
16
- | `JBRouterTerminalRegistry` | ~481 | Maps projects to their preferred router terminal instance. Owner-managed allowlist of terminals. Default terminal fallback. Lock terminal pattern for immutability. Implements `IJBTerminal`. |
15
+ | `JBRouterTerminal` | ~1,672 | Core routing engine. Accepts any token, discovers destination project's accepted token, converts via wrap/unwrap, Uniswap V3 swap, Uniswap V4 swap, or JB token cashout chain, then forwards to destination terminal. Implements `IJBTerminal`, `IJBPermitTerminal`, `IUniswapV3SwapCallback`, `IUnlockCallback`. |
16
+ | `JBRouterTerminalRegistry` | ~514 | Maps projects to their preferred router terminal instance. Owner-managed allowlist of terminals. Default terminal fallback. Lock terminal pattern for immutability. Implements `IJBTerminal`. |
17
17
  | `JBSwapLib` (library) | ~161 | Continuous sigmoid slippage tolerance calculation. Price impact estimation. `sqrtPriceLimitX96` computation from input/output amounts. |
18
18
  | `PoolInfo` (struct) | ~14 | Tagged union: `{isV4, v3Pool, v4Key}`. Carries the winning pool from discovery. |
19
19
 
@@ -141,18 +141,18 @@ The registry implements a two-phase terminal assignment:
141
141
 
142
142
  ## Fee-on-Transfer Token Risks
143
143
 
144
- **JBRouterTerminal:** Uses balance-delta accounting in `_acceptFundsFor()` (lines 550-556). Measures `balanceBefore` and `balanceAfter` the transfer, returning the actual amount received. This correctly handles fee-on-transfer tokens at the acceptance stage. However, the amount forwarded to the destination terminal is the delta, which may differ from what the destination terminal expects if it also performs balance-delta checks.
144
+ **JBRouterTerminal:** Uses balance-delta accounting in `_acceptFundsFor()` (lines 569-575). Measures `balanceBefore` and `balanceAfter` the transfer, returning the actual amount received. This correctly handles fee-on-transfer tokens at the acceptance stage. However, the amount forwarded to the destination terminal is the delta, which may differ from what the destination terminal expects if it also performs balance-delta checks.
145
145
 
146
- **JBRouterTerminalRegistry:** Does NOT use balance-delta accounting in `_acceptFundsFor()` (line 437). It returns the user-supplied `amount` directly. If a fee-on-transfer token is used through the registry, the forwarded amount will exceed actual tokens received, causing a downstream revert or incorrect accounting.
146
+ **JBRouterTerminalRegistry:** Does NOT use balance-delta accounting in `_acceptFundsFor()` (line 432). It returns the user-supplied `amount` directly. If a fee-on-transfer token is used through the registry, the forwarded amount will exceed actual tokens received, causing a downstream revert or incorrect accounting.
147
147
 
148
- **Audit focus:** Verify that the registry's `_acceptFundsFor` cannot be exploited with fee-on-transfer tokens. The comment on line 433-435 states these are "not supported by design" -- confirm this is documented clearly enough and that no path silently loses funds.
148
+ **Audit focus:** Verify that the registry's `_acceptFundsFor` cannot be exploited with fee-on-transfer tokens. The comment on lines 466-468 states these are "not supported by design" -- confirm this is documented clearly enough and that no path silently loses funds.
149
149
 
150
150
  ## uint160 Permit2 Truncation Risk
151
151
 
152
152
  Both contracts cast `amount` to `uint160` when falling through to `PERMIT2.transferFrom()`:
153
153
 
154
- - `JBRouterTerminal._transferFrom()` line 1425: `if (amount > type(uint160).max) revert JBRouterTerminal_AmountOverflow(amount);`
155
- - `JBRouterTerminalRegistry._transferFrom()` line 477: `if (amount > type(uint160).max) revert JBRouterTerminalRegistry_AmountOverflow();`
154
+ - `JBRouterTerminal._transferFrom()` line 944: `if (amount > type(uint160).max) revert JBRouterTerminal_AmountOverflow(amount);`
155
+ - `JBRouterTerminalRegistry._transferFrom()` line 510: `if (amount > type(uint160).max) revert JBRouterTerminalRegistry_AmountOverflow();`
156
156
 
157
157
  Both contracts now revert before truncation occurs. Verify these overflow checks are complete and that no code path can reach the `uint160()` cast without hitting the guard.
158
158
 
@@ -252,7 +252,7 @@ forge test
252
252
 
253
253
  ### Foundry Configuration
254
254
 
255
- - Solidity 0.8.26, EVM target `cancun`, optimizer 200 runs
255
+ - Solidity ^0.8.28, EVM target `cancun`, optimizer 200 runs
256
256
  - Fuzz runs: 4,096 per test
257
257
  - Invariant runs: 1,024 with depth 100
258
258
  - Fork tests pinned to Ethereum mainnet block 21,700,000 (post-V4 deployment)
@@ -261,14 +261,22 @@ forge test
261
261
 
262
262
  | Test File | Type | Coverage Area | Count |
263
263
  |-----------|------|---------------|-------|
264
- | `RouterTerminal.t.sol` | Unit (mocked) | Core routing, swap paths, cashout, V4, pool discovery, TWAP, errors | ~35 |
265
- | `RouterTerminalRegistry.t.sol` | Unit (mocked) | Allow/disallow, set/lock, forwarding, permissions | ~12 |
264
+ | `RouterTerminal.t.sol` | Unit (mocked) | Core routing, swap paths, cashout, V4, pool discovery, TWAP, errors | ~39 |
265
+ | `RouterTerminalRegistry.t.sol` | Unit (mocked) | Allow/disallow, set/lock, forwarding, permissions | ~18 |
266
266
  | `RouterTerminalFork.t.sol` | Fork (mainnet) | End-to-end swaps: ETH->USDC, USDC->ETH, ETH->DAI, addToBalance, quote metadata | ~12 |
267
+ | `RouterTerminalCashOutFork.t.sol` | Fork (mainnet) | Cashout routing through terminals | ~5 |
268
+ | `RouterTerminalCreditCashout.t.sol` | Unit (mocked) | Credit cashout path | ~6 |
269
+ | `RouterTerminalERC2771.t.sol` | Unit (mocked) | ERC-2771 meta-transaction forwarding | ~3 |
267
270
  | `RouterTerminalFeeCashOutFork.t.sol` | Fork (mainnet) | Fee routing through cashout: project 3 payouts -> fee -> cashout -> project 1 | 1 |
271
+ | `RouterTerminalMultihopFork.t.sol` | Fork (mainnet) | Multi-hop cashout chains across projects | ~3 |
272
+ | `RouterTerminalPreviewFork.t.sol` | Fork (mainnet) | Preview functions for pay and cashout routing | ~4 |
273
+ | `RouterTerminalReentrancy.t.sol` | Unit (mocked) | Reentrancy scenarios through callbacks | ~3 |
268
274
  | `RouterTerminalSandwichFork.t.sol` | Fork (mainnet) | MEV/sandwich: V3 TWAP resistance, V4 spot manipulation, user quote | 4 |
275
+ | `fork/V4QuoteAndSettlementFork.t.sol` | Fork (mainnet) | V4 quote computation and settlement | ~4 |
276
+ | `invariant/RouterTerminalInvariant.t.sol` | Invariant | Zero-balance, callback verification, loop termination (9 invariants) | 9 |
269
277
  | `regression/LockTerminalRace.t.sol` | Unit | Race condition in `lockTerminalFor` with `expectedTerminal` | 3 |
270
278
  | `regression/CashOutLoopLimit.t.sol` | Unit | Circular cashout loop cap at 20 iterations | 2 |
271
- | `regression/V4SpotPriceSlippage.t.sol` | Unit + fuzz | Sigmoid math: floor, ceiling, monotonicity, bounded range, user quote | 14 |
279
+ | `regression/V4SpotPriceSlippage.t.sol` | Unit + fuzz | Sigmoid math: floor, ceiling, monotonicity, bounded range, user quote | ~17 |
272
280
 
273
281
  ### Coverage Gaps Worth Investigating
274
282
 
@@ -276,8 +284,75 @@ forge test
276
284
  |------|--------|----------------|
277
285
  | Fee-on-transfer tokens | NOT TESTED | Registry `_acceptFundsFor` does not use balance-delta |
278
286
  | Short TWAP windows (<60s) | NOT TESTED | Silently degrades to near-spot-price |
279
- | Multi-hop cashout chains (>1 step) | NOT TESTED | Only the circular case and single-step are tested |
287
+ | Multi-hop cashout chains (>1 step) | TESTED | `RouterTerminalMultihopFork.t.sol` covers multi-hop cashout chains (~3 tests) |
280
288
  | V4 pools with custom hooks | NOT TESTED | All V4 tests use `hooks: IHooks(address(0))` |
281
289
  | Concurrent pay + addToBalance | NOT TESTED | Mitigated by stateless design but worth verifying |
282
290
  | Credit cashout path (fork) | NOT TESTED | Only unit-mocked, no fork test with real credits |
283
291
  | addToBalanceOf with cashout routing | PARTIALLY | Fork covers ETH->USDC, not cashout or credit paths |
292
+
293
+ ## Previous Audit Findings
294
+
295
+ No prior formal audit with finding IDs has been conducted on this codebase. All risk analysis is internal. See [RISKS.md](./RISKS.md) for known risks.
296
+
297
+ ## Anti-Patterns to Hunt
298
+
299
+ | Pattern | Where to Look | Why It's Dangerous |
300
+ |---------|--------------|-------------------|
301
+ | V4 spot price as TWAP fallback | `_getV4SpotQuote()` | V4 pools lack on-chain TWAP. Spot price is manipulable within a block. The sigmoid slippage mitigates but doesn't eliminate. |
302
+ | Silent TWAP window degradation | `_getV3TwapQuote()` | If `oldestObservation < twapWindow`, the window is silently capped. A 10-second observation produces a near-spot quote labeled as "TWAP." |
303
+ | Partial swap leftover refund | `_handleSwap()` | Leftover tokens are refunded to `_msgSender()`. If the caller is a contract, the refund must be receivable. Verify ETH refund doesn't fail silently. |
304
+ | Stateless design assumption | All of `JBRouterTerminal` | No persistent balances. If any code path fails to forward or refund tokens, they're stuck permanently. No sweep function exists. |
305
+ | Registry default terminal | `JBRouterTerminalRegistry` | Owner changing `defaultTerminal` redirects all unlocked projects. A compromised owner could redirect payments. |
306
+ | `receive()` is empty | `JBRouterTerminal` | Accepts ETH but has no recovery mechanism. ETH sent directly (not through `pay()`) is permanently lost. |
307
+ | Fee-on-transfer in registry | `JBRouterTerminalRegistry._acceptFundsFor()` | Returns user-supplied `amount`, not actual balance delta. Fee-on-transfer tokens cause downstream accounting mismatch. |
308
+
309
+ ## Error Reference
310
+
311
+ | Error | Contract | Trigger |
312
+ |-------|----------|---------|
313
+ | `JBRouterTerminal_AmountOverflow(uint256)` | JBRouterTerminal | Amount exceeds `type(uint160).max` during Permit2 transfer, or exceeds `type(uint128).max` in V3 TWAP quote computation. |
314
+ | `JBRouterTerminal_CallerNotPool(address)` | JBRouterTerminal | `uniswapV3SwapCallback()` called by an address that does not match the expected V3 pool computed via `FACTORY.getPool()`. |
315
+ | `JBRouterTerminal_CallerNotPoolManager(address)` | JBRouterTerminal | `unlockCallback()` called by an address other than `POOL_MANAGER`. |
316
+ | `JBRouterTerminal_CashOutLoopLimit()` | JBRouterTerminal | `_cashOutLoop()` or `_previewCashOutLoop()` exceeds 20 iterations without resolving to an accepted token. |
317
+ | `JBRouterTerminal_NoCashOutPath(uint256, uint256)` | JBRouterTerminal | `_findCashOutPath()` cannot find any terminal on the source project that reclaims a usable token for the destination. |
318
+ | `JBRouterTerminal_NoLiquidity()` | JBRouterTerminal | V3 or V4 pool selected for quoting has zero in-range liquidity. |
319
+ | `JBRouterTerminal_NoMsgValueAllowed(uint256)` | JBRouterTerminal | `msg.value != 0` when paying with ERC-20 tokens or credits (ETH not expected on these paths). |
320
+ | `JBRouterTerminal_NoObservationHistory()` | JBRouterTerminal | V3 pool's oldest observation is 0 seconds old -- no TWAP data available. |
321
+ | `JBRouterTerminal_NoPoolFound(address, address)` | JBRouterTerminal | `_discoverPool()` or `_pickPoolAndQuote()` found no V3 or V4 pool with non-zero liquidity for the given token pair. |
322
+ | `JBRouterTerminal_NoRouteFound(uint256, address)` | JBRouterTerminal | `_resolveTokenOut()` cannot find any terminal on the destination project that accepts the input token or a swappable alternative. |
323
+ | `JBRouterTerminal_PermitAllowanceNotEnough(uint256, uint256)` | JBRouterTerminal | The Permit2 single-use allowance provided in metadata is less than the payment amount. |
324
+ | `JBRouterTerminal_SlippageExceeded(uint256, uint256)` | JBRouterTerminal | Swap output or cashout reclaim amount is below the required minimum (`minAmountOut` or `minTokensReclaimed`). |
325
+ | `JBRouterTerminalRegistry_AmountOverflow()` | JBRouterTerminalRegistry | Amount exceeds `type(uint160).max` during Permit2 transfer. |
326
+ | `JBRouterTerminalRegistry_NoMsgValueAllowed(uint256)` | JBRouterTerminalRegistry | `msg.value != 0` when paying with ERC-20 tokens through the registry. |
327
+ | `JBRouterTerminalRegistry_PermitAllowanceNotEnough(uint256, uint256)` | JBRouterTerminalRegistry | Permit2 single-use allowance in metadata is less than the payment amount. |
328
+ | `JBRouterTerminalRegistry_TerminalLocked(uint256)` | JBRouterTerminalRegistry | `setTerminalFor()` called for a project whose terminal has already been locked via `lockTerminalFor()`. |
329
+ | `JBRouterTerminalRegistry_TerminalMismatch(IJBTerminal, IJBTerminal)` | JBRouterTerminalRegistry | `lockTerminalFor()` called with an `expectedTerminal` that does not match the project's currently resolved terminal. |
330
+ | `JBRouterTerminalRegistry_TerminalNotAllowed(IJBTerminal)` | JBRouterTerminalRegistry | `setTerminalFor()` called with a terminal not on the owner's allowlist. |
331
+ | `JBRouterTerminalRegistry_TerminalNotSet(uint256)` | JBRouterTerminalRegistry | `lockTerminalFor()` called for a project with no explicit terminal and no default terminal configured. |
332
+ | `JBRouterTerminalRegistry_ZeroAddress()` | JBRouterTerminalRegistry | `setDefaultTerminal()` called with `address(0)`. |
333
+
334
+ ## Compiler and Version Info
335
+
336
+ - **Solidity**: ^0.8.28
337
+ - **EVM target**: Cancun
338
+ - **Optimizer**: via-IR, 200 runs
339
+ - **Dependencies**: OpenZeppelin 5.x, Uniswap V3/V4, nana-core-v6
340
+ - **Build**: `forge build` (Foundry)
341
+
342
+ ## How to Report Findings
343
+
344
+ For each finding:
345
+
346
+ 1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
347
+ 2. **Affected contract(s)** -- exact file path and line numbers
348
+ 3. **Description** -- what is wrong, in plain language
349
+ 4. **Trigger sequence** -- step-by-step, minimal steps to reproduce
350
+ 5. **Impact** -- what an attacker gains, what a user loses (with numbers if possible)
351
+ 6. **Proof** -- code trace showing the exact execution path, or a Foundry test
352
+ 7. **Fix** -- minimal code change that resolves the issue
353
+
354
+ **Severity guide:**
355
+ - **CRITICAL**: Direct fund loss, tokens stuck permanently, or system insolvency.
356
+ - **HIGH**: Conditional fund loss, routing bypass, or broken invariant.
357
+ - **MEDIUM**: Value leakage, suboptimal routing, griefing.
358
+ - **LOW**: Informational, edge-case-only with no material impact.
package/CHANGE_LOG.md CHANGED
@@ -4,6 +4,16 @@ This document describes all changes between `nana-swap-terminal` (v5) and `nana-
4
4
 
5
5
  **Note:** This repo was renamed from `nana-swap-terminal` to `nana-router-terminal` in v6.
6
6
 
7
+ ## Summary
8
+
9
+ This release represents a **philosophical shift from configured to automatic**: the swap terminal required manual pool registration and per-project TWAP configuration, while the router terminal automatically discovers the best route for any payment.
10
+
11
+ - **Renamed `JBSwapTerminal` → `JBRouterTerminal`**: Reflects the broader scope — not just swapping, but full payment routing including JB token cashouts, credit transfers, and multi-hop resolution.
12
+ - **Automatic pool discovery**: Pools are auto-discovered across both Uniswap V3 and V4 by scanning all fee tiers and selecting the highest-liquidity pool. No manual `addDefaultPool()` needed.
13
+ - **Automatic token route discovery**: The terminal dynamically determines what token a destination project accepts by querying terminals and accounting contexts.
14
+ - **JB token cashout routing**: Can recursively cash out JB project tokens (ERC-20 or credits) from source projects before routing the reclaimed tokens to the destination.
15
+ - **Shared `JBSwapLib` library**: Swap math extracted for reuse with `nana-buyback-hook-v6` — includes continuous sigmoid slippage and V4 price limit computation.
16
+
7
17
  ---
8
18
 
9
19
  ## 1. Breaking Changes
@@ -60,7 +70,7 @@ The `SLIPPAGE_DENOMINATOR` constant was kept but changed from `uint160` to `uint
60
70
 
61
71
  ### 1.11 Solidity Version
62
72
  - **v5:** `pragma solidity 0.8.23`
63
- - **v6:** `pragma solidity 0.8.26`
73
+ - **v6:** `pragma solidity ^0.8.28`
64
74
 
65
75
  ---
66
76
 
@@ -295,3 +305,5 @@ v5 contained both `JBSwapTerminal.sol` and `JBSwapTerminal5_1.sol` (a minor revi
295
305
  | `src/interfaces/IWETH9.sol` | `src/interfaces/IWETH9.sol` | **Unchanged** (import path updated to OZ) |
296
306
  | *N/A* | `src/structs/PoolInfo.sol` | **New** |
297
307
  | *N/A* | `src/libraries/JBSwapLib.sol` | **New** |
308
+
309
+ > **Cross-repo impact**: `nana-fee-project-deployer-v6` replaced `SwapTerminalDeploymentLib` with `RouterTerminalDeploymentLib`. `nana-permission-ids-v6` replaced `ADD_SWAP_TERMINAL_POOL`/`ADD_SWAP_TERMINAL_TWAP_PARAMS` with `SET_ROUTER_TERMINAL` (29). `nana-buyback-hook-v6` shares the `JBSwapLib` library for sigmoid slippage and V4 swap math.
package/README.md CHANGED
@@ -15,7 +15,7 @@ _If you're having trouble understanding this contract, take a look at the [core
15
15
  | Contract | Description |
16
16
  |----------|-------------|
17
17
  | `JBRouterTerminal` | Core terminal. Accepts any token via `pay` or `addToBalanceOf`, previews payment routes via `previewPayFor`, discovers the destination project's accepted token, and routes there -- swapping through Uniswap V3 or V4 pools if needed, cashing out JB project tokens if the input is a project token, or forwarding directly if the token is already accepted. Uses TWAP oracle (V3) or spot price (V4) for automatic slippage protection when the caller does not provide a quote. Implements `IJBTerminal`, `IJBPermitTerminal`, `IUniswapV3SwapCallback`, `IUnlockCallback`, and `IJBRouterTerminal`. |
18
- | `JBRouterTerminalRegistry` | A proxy terminal that delegates `pay`, `previewPayFor`, and `addToBalanceOf` to a per-project or default `JBRouterTerminal` instance. Project owners can choose which router terminal they use, and optionally lock that choice permanently. Implements `IJBTerminal` and the extra registry management surface via `IJBRouterTerminalRegistry`. |
18
+ | `JBRouterTerminalRegistry` | A proxy terminal that delegates `pay`, `previewPayFor`, and `addToBalanceOf` to a per-project or default `JBRouterTerminal` instance. Project owners can choose which router terminal they use, and optionally lock that choice permanently. Implements `IJBTerminal`, `IJBPayerTracker`, and the extra registry management surface via `IJBRouterTerminalRegistry`. |
19
19
 
20
20
  ## How It Works
21
21
 
@@ -116,7 +116,7 @@ npm ci && forge install
116
116
 
117
117
  Key `foundry.toml` settings:
118
118
 
119
- - `solc = '0.8.26'`
119
+ - `solc = '0.8.28'`
120
120
  - `evm_version = 'cancun'` (required for Uniswap V4's transient storage)
121
121
  - `optimizer_runs = 200`
122
122
  - `via_ir = true` (required for `JBRouterTerminal` to fit under EIP-170)
@@ -131,8 +131,9 @@ nana-router-terminal-v6/
131
131
  │ ├── JBRouterTerminal.sol # Core router terminal
132
132
  │ ├── JBRouterTerminalRegistry.sol # Per-project terminal routing
133
133
  │ ├── interfaces/
134
+ │ │ ├── IJBPayerTracker.sol # Original-payer tracking for refund resolution
134
135
  │ │ ├── IJBRouterTerminal.sol # Router terminal interface
135
- │ │ ├── IJBRouterTerminalRegistry.sol # Registry interface (extends IJBTerminal)
136
+ │ │ ├── IJBRouterTerminalRegistry.sol # Registry interface (extends IJBTerminal, IJBPayerTracker)
136
137
  │ │ └── IWETH9.sol # WETH wrapper interface
137
138
  │ ├── libraries/
138
139
  │ │ └── JBSwapLib.sol # Slippage tolerance, impact, and price limit math
@@ -146,7 +147,22 @@ nana-router-terminal-v6/
146
147
  ├── RouterTerminal.t.sol # Unit tests (mocked dependencies)
147
148
  ├── RouterTerminalRegistry.t.sol # Registry unit tests
148
149
  ├── RouterTerminalFork.t.sol # Fork tests against mainnet Uniswap pools
149
- └── RouterTerminalPreviewFork.t.sol # Fork parity tests for previewPayFor
150
+ ├── RouterTerminalPreviewFork.t.sol # Fork parity tests for previewPayFor
151
+ ├── RouterTerminalCashOutFork.t.sol # Fork tests for cashout routing
152
+ ├── RouterTerminalCreditCashout.t.sol # Credit cashout unit tests
153
+ ├── RouterTerminalERC2771.t.sol # ERC-2771 meta-transaction tests
154
+ ├── RouterTerminalFeeCashOutFork.t.sol # Fee routing through cashout (fork)
155
+ ├── RouterTerminalMultihopFork.t.sol # Multi-hop routing (fork)
156
+ ├── RouterTerminalReentrancy.t.sol # Reentrancy attack tests
157
+ ├── RouterTerminalSandwichFork.t.sol # MEV/sandwich resistance (fork)
158
+ ├── fork/
159
+ │ └── V4QuoteAndSettlementFork.t.sol # V4 quote and settlement (fork)
160
+ ├── invariant/
161
+ │ └── RouterTerminalInvariant.t.sol # Invariant/fuzz tests
162
+ └── regression/
163
+ ├── CashOutLoopLimit.t.sol # Circular cashout loop cap
164
+ ├── LockTerminalRace.t.sol # Lock terminal race condition
165
+ └── V4SpotPriceSlippage.t.sol # Sigmoid slippage math
150
166
  ```
151
167
 
152
168
  ## Payment Metadata
@@ -181,17 +197,22 @@ If no `quoteForSwap` is provided, the terminal calculates one automatically:
181
197
 
182
198
  ## Supported Chains
183
199
 
184
- The deployment script supports:
200
+ The deployment script (`script/Deploy.s.sol`) supports:
185
201
 
186
- | Chain | WETH | V3 Factory | V4 PoolManager |
187
- |-------|------|------------|----------------|
188
- | Ethereum Mainnet | `0xC02a...6Cc2` | `0x1F98...F984` | `0x0000...8A90` |
189
- | Optimism | `0x4200...0006` | `0x1F98...F984` | `0x0000...8A90` |
190
- | Base | `0x4200...0006` | `0x3312...FDfD` | `0x0000...8A90` |
191
- | Arbitrum | `0x82aF...b1DD` | `0x1F98...F984` | `0x0000...8A90` |
192
- | + Sepolia testnets for each | | | |
202
+ - Ethereum Mainnet
203
+ - Optimism
204
+ - Base
205
+ - Arbitrum
206
+ - Sepolia testnets for each
193
207
 
194
- Permit2 is deployed at `0x000000000022D473030F116dDEE9F6B43aC78BA3` on all chains.
208
+ Each chain is configured with the appropriate WETH, Uniswap V3 Factory, and V4 PoolManager addresses. Permit2 is deployed at `0x000000000022D473030F116dDEE9F6B43aC78BA3` on all chains. See `script/Deploy.s.sol` for the full address list.
209
+
210
+ ## Permissions
211
+
212
+ | Permission | ID | Contract | Purpose |
213
+ |------------|----|----------|---------|
214
+ | `SET_ROUTER_TERMINAL` | 29 | `JBRouterTerminalRegistry` | Allows a project owner or delegate to call `setTerminalFor` (choose which router terminal a project uses) and `lockTerminalFor` (permanently lock that choice). |
215
+ | `TRANSFER_CREDITS` | 13 | `JBRouterTerminal` | Must be granted by the payer to the router terminal address for the source project. Required when using `cashOutSource` metadata to cash out credits -- the router calls `TOKENS.transferCreditsFrom()` to pull credits from the payer. |
195
216
 
196
217
  ## Risks
197
218
 
package/RISKS.md CHANGED
@@ -7,13 +7,14 @@
7
7
  - **JBDirectory.** Terminal resolution trusts `DIRECTORY.primaryTerminalOf()` and `DIRECTORY.terminalsOf()`. A compromised directory can redirect funds.
8
8
  - **PERMIT2.** Used as fallback for token transfers. Permit2 approvals can be exploited if users have stale allowances.
9
9
  - **Owner (Ownable).** Contract owner has no fund access but controls the registry terminal allowlist and default.
10
+ - **`IJBPayerTracker` implementers.** `_resolveRefundTo` in the router terminal queries `IJBPayerTracker(msg.sender).originalPayer()` via try-catch. Any contract that is the `msg.sender` and implements `IJBPayerTracker` can direct leftover refunds to an arbitrary address. This is safe when the caller is a trusted intermediary (e.g. the registry), but a malicious `msg.sender` implementing `IJBPayerTracker` could redirect refunds. The risk is bounded: the caller must have already supplied the funds being routed, so it can only redirect leftovers from its own payment.
10
11
 
11
12
  ## 2. Economic / Manipulation Risks
12
13
 
13
14
  - **V4 spot price manipulation.** `_getV4SpotQuote` reads the instantaneous `getSlot0` tick, which is manipulable via sandwich attacks or flash loans. The sigmoid slippage formula provides a floor (min 2%) but does NOT provide full MEV protection. Without user-supplied `quoteForSwap` metadata, V4 swaps are vulnerable to extraction. Front-ends MUST supply `quoteForSwap` metadata for V4 swaps. Note: `_getV4SpotQuote` normalizes WETH to `address(0)` before calling OracleLibrary, since V4 uses `address(0)` for native ETH -- without this normalization, token sorting would mismatch the pool's currency ordering and produce inverted quotes.
14
15
  - **V3 TWAP manipulation.** Short TWAP windows (falls back to `oldestObservation` if < 10 minutes) reduce manipulation resistance. A newly created pool with minimal history can be manipulated within the TWAP window.
15
- - **Cashout loop value extraction.** `_cashOutLoop` iterates up to 20 times, cashing out JB project tokens recursively. Each cashout incurs bonding curve slippage. `minTokensReclaimed` is only applied to the first step -- subsequent steps have zero slippage protection.
16
- - **Leftover token absorption.** `_handleSwap` wraps all remaining `address(this).balance` as WETH after swaps. Any ETH sent directly to the contract (via `receive()`) is absorbed into the next swap's leftover calculation and routed to the project -- not returned to the sender.
16
+ - **Cashout loop value extraction.** `_cashOutLoop` iterates up to 20 times, cashing out JB project tokens recursively. Each cashout incurs bonding curve slippage. `minTokensReclaimed` is only applied to the first step -- subsequent steps have zero slippage protection. Gas cost: each cashout iteration involves `terminal.cashOutTokensOf` (external call, ~100-200k gas) plus token transfer and balance accounting. At 20 iterations maximum, the worst case is ~4M gas for the loop alone, leaving headroom within a 30M block but consuming a significant portion.
17
+ - **Leftover token absorption.** `_handleSwap` wraps all remaining `address(this).balance` as WETH after swaps. Any ETH sent directly to the contract (via `receive()`) is absorbed into the next swap's leftover calculation and routed to the project -- not returned to the sender. There is no sweep mechanism — ETH absorbed this way is permanently incorporated into the next routing operation. This is by design (the router is stateless and holds no balances across transactions), but users who accidentally send ETH directly to the contract should understand that it is unrecoverable.
17
18
  - **V4 native ETH settlement.** `_settleV4` unwraps WETH to native ETH when settling a `Currency.wrap(address(0))` debt with PoolManager. This is necessary because the router may hold WETH (from ERC-20 transfers or prior wrapping) but V4 native pools require `msg.value` settlement. If `address(this).balance` is already sufficient, no unwrap occurs.
18
19
  - **Pool selection by liquidity.** `_discoverPool` selects the pool with the highest `liquidity()` value. An attacker can deploy a pool with high but concentrated (out-of-range) liquidity to win selection, then manipulate the actual swap execution at worse prices.
19
20
 
@@ -22,7 +23,7 @@
22
23
  - **No access control on `pay` / `addToBalanceOf`.** Anyone can route payments. This is by design but means the contract processes arbitrary token types and amounts.
23
24
  - **Fee-on-transfer tokens unsupported.** `_acceptFundsFor` in the terminal uses balance-delta, but the registry does NOT. Fee-on-transfer tokens through the registry will mismatch.
24
25
  - **Credit cashout path.** `_acceptFundsFor` processes `cashOutSource` metadata to transfer credits from `_msgSender()`. Requires `TRANSFER_CREDITS` permission. If a user has this permission set broadly, any caller through the trusted forwarder could drain their credits.
25
- - **Registry owner.** Controls which terminals are allowlisted and sets the global default. Disallowing a terminal clears the default if it matches but does NOT clear per-project terminal settings already set to the disallowed terminal.
26
+ - **Registry owner.** Controls which terminals are allowlisted and sets the global default. Disallowing the current default terminal now reverts with `CannotDisallowDefaultTerminal` instead of silently clearing it. Per-project terminal settings already set to a disallowed terminal are NOT cleared.
26
27
  - **Synthetic accounting contexts.** `JBRouterTerminal.accountingContextForTokenOf()` uses best-effort decimals for
27
28
  routing discovery: native tokens use `18`, ERC-20s probe `IERC20Metadata.decimals()` when available, and broken or
28
29
  non-standard tokens fall back to `18`. `JBRouterTerminalRegistry` simply forwards that context. This is safe for
@@ -30,6 +31,7 @@
30
31
  non-18-decimal assets. Lending and debt-normalization flows must point at a real terminal, not the router layer.
31
32
  The router now refuses to treat a primary terminal as direct acceptance unless that terminal also exposes non-empty
32
33
  accounting contexts for the project, so router-stack terminals do not win the direct-forward fast path.
34
+ For example, a USDC terminal (6 decimals) routed through the router reports `decimals: 6` correctly. But if the router cannot probe `IERC20Metadata.decimals()` (non-standard token, or reverting `decimals()` function), it falls back to 18 decimals — a 1e12 scaling error. This only affects routing discovery heuristics, not actual fund transfers, but could cause suboptimal pool selection in `_discoverPool`.
33
35
 
34
36
  ## 4. DoS Vectors
35
37
 
@@ -73,3 +75,11 @@ The router terminal has no `ReentrancyGuard` or `_routing` flag. This is a consc
73
75
  - **A reentrancy guard would block legitimate composition.** Projects may have terminal chains where terminal A routes through this router, which cashes out into terminal B, which itself routes through this router for a different project. A blanket reentrancy guard would break such flows.
74
76
 
75
77
  Verified in `RouterTerminalReentrancy.t.sol`: re-entrant calls via both `pay()` and `addToBalanceOf()` succeed without corrupting the outer call's ETH forwarding. The invariant suite (`RouterTerminalInvariant.t.sol`) further confirms that the router holds zero tokens and zero ETH after every operation — including operations that exercise the cashout recursion loop (`_cashOutLoop`, up to `_MAX_CASHOUT_ITERATIONS = 20`).
78
+
79
+ ### 8.2 Router trusts `originalPayer()` from any `msg.sender` that implements it
80
+
81
+ `_resolveRefundTo` calls `IJBPayerTracker(msg.sender).originalPayer()` in a try-catch. If the call succeeds and returns a non-zero address, leftover tokens from partial swap fills are sent to that address instead of the beneficiary or `_msgSender()`. The router does not verify that `msg.sender` is the registry or any specific contract -- it trusts any caller that implements the interface. This is accepted because: (1) the caller (`msg.sender`) is the entity that supplied the funds, so redirecting its own leftovers is a legitimate operation, (2) if the call reverts or returns `address(0)`, the router falls back to the normal beneficiary/`_msgSender()` logic, and (3) decoupling from the registry allows other intermediary contracts (e.g. batch payers, aggregators) to participate in refund routing without requiring changes to the router terminal.
82
+
83
+ ### 8.3 Cashout loop slippage is first-hop only (accepted trade-off)
84
+
85
+ `_cashOutLoop` applies `minTokensReclaimed` only to the first cashout step. Subsequent recursive cashouts (steps 2-20) have zero explicit slippage protection. This is accepted because: (1) adding per-step slippage would require the caller to predict intermediate token amounts across an unknown chain of cashouts, which is impractical, (2) the first-hop slippage check ensures the initial conversion meets the user's expectation, and (3) the recursive path is deterministic — intermediate projects' bonding curves and rulesets are on-chain state that cannot be manipulated between steps within a single transaction. The risk is limited to scenarios where intermediate projects have very low liquidity, causing high bonding-curve slippage on small amounts.