@bananapus/router-terminal-v6 0.0.8 → 0.0.9

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.
@@ -0,0 +1,116 @@
1
+ # Administration
2
+
3
+ Admin privileges and their scope in nana-router-terminal-v6.
4
+
5
+ ## Roles
6
+
7
+ ### 1. Registry Owner (Ownable)
8
+
9
+ **Contract**: `JBRouterTerminalRegistry`
10
+ **Assigned via**: Constructor parameter `owner`, transferable via `Ownable.transferOwnership()`.
11
+ **Scope**: Global -- controls which router terminals can be used by any project and sets the system-wide default terminal.
12
+
13
+ ### 2. Project Owner / SET_ROUTER_TERMINAL Delegate
14
+
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).
17
+ **Scope**: Per-project -- controls which router terminal a specific project uses, and can permanently lock that choice.
18
+
19
+ ### 3. Router Terminal Owner (Ownable)
20
+
21
+ **Contract**: `JBRouterTerminal`
22
+ **Assigned via**: Constructor parameter `owner`, transferable via `Ownable.transferOwnership()`.
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
+
25
+ ### 4. Credit Cashout Payer (Implicit)
26
+
27
+ **Contract**: `JBRouterTerminal`
28
+ **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
+ **Scope**: Per-transaction. Required only when using the `cashOutSource` metadata key to route payments through credit cashouts.
30
+
31
+ ## Privileged Functions
32
+
33
+ ### JBRouterTerminalRegistry
34
+
35
+ | Function | Required Role | Permission ID | Scope | What It Does |
36
+ |----------|--------------|---------------|-------|--------------|
37
+ | `allowTerminal(terminal)` | Registry Owner | `onlyOwner` | Global | Adds a terminal to the allowlist (`isTerminalAllowed[terminal] = true`). Projects can only use allowlisted terminals. |
38
+ | `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
+ | `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.** |
42
+
43
+ ### JBRouterTerminal
44
+
45
+ | Function | Required Role | Permission ID | Scope | What It Does |
46
+ |----------|--------------|---------------|-------|--------------|
47
+ | `transferOwnership(newOwner)` | Owner | `onlyOwner` (inherited) | Global | Transfers contract ownership. No functions currently gated by ownership. |
48
+ | `renounceOwnership()` | Owner | `onlyOwner` (inherited) | Global | Renounces contract ownership. No functions currently gated by ownership. |
49
+
50
+ ### Implicit Permission Requirements (not `onlyOwner`, but enforced by external contracts)
51
+
52
+ | Operation | Required By | Permission | What Happens |
53
+ |-----------|------------|------------|--------------|
54
+ | Credit cashout via `cashOutSource` metadata | Payer | `TRANSFER_CREDITS` (13) granted to router terminal | `TOKENS.transferCreditsFrom()` pulls credits from payer. Reverts if payer has not granted the permission. |
55
+ | Cashout execution | Router terminal (as holder) | None (terminal holds the tokens) | `IJBCashOutTerminal.cashOutTokensOf()` is called with the router as the holder. The router already holds the tokens from the credit transfer or prior cashout step. |
56
+
57
+ ## Immutable Configuration
58
+
59
+ The following values are set at deploy time and cannot be changed:
60
+
61
+ ### JBRouterTerminal
62
+
63
+ | Property | Set At | Mutable? | Description |
64
+ |----------|--------|----------|-------------|
65
+ | `DIRECTORY` | Constructor | No | JB directory for terminal/controller lookups |
66
+ | `PROJECTS` | Constructor | No | JB project NFT registry |
67
+ | `TOKENS` | Constructor | No | JB token manager for credit transfers and project token lookups |
68
+ | `FACTORY` | Constructor | No | Uniswap V3 factory for pool discovery and callback verification |
69
+ | `POOL_MANAGER` | Constructor | No | Uniswap V4 PoolManager (can be `address(0)` to disable V4) |
70
+ | `PERMIT2` | Constructor | No | Permit2 contract for gasless approvals |
71
+ | `WETH` | Constructor | No | Wrapped ETH contract |
72
+ | `DEFAULT_TWAP_WINDOW` | Compile-time constant | No | 10 minutes (600 seconds) |
73
+ | `SLIPPAGE_DENOMINATOR` | Compile-time constant | No | 10,000 (basis points) |
74
+ | `_FEE_TIERS` | Storage (initialized) | No | `[3000, 500, 10000, 100]` -- V3 fee tiers |
75
+ | `_V4_FEES` / `_V4_TICK_SPACINGS` | Storage (initialized) | No | V4 pool parameters |
76
+ | `_MAX_CASHOUT_ITERATIONS` | Compile-time constant | No | 20 iterations |
77
+
78
+ ### JBRouterTerminalRegistry
79
+
80
+ | Property | Set At | Mutable? | Description |
81
+ |----------|--------|----------|-------------|
82
+ | `PROJECTS` | Constructor | No | JB project NFT registry |
83
+ | `PERMIT2` | Constructor | No | Permit2 contract for gasless approvals |
84
+
85
+ ### JBSwapLib
86
+
87
+ | Constant | Value | Description |
88
+ |----------|-------|-------------|
89
+ | `MAX_SLIPPAGE` | 8,800 (88%) | Maximum slippage tolerance ceiling |
90
+ | `IMPACT_PRECISION` | 1e18 | Precision for impact calculations |
91
+ | `SIGMOID_K` | 5e16 | Sigmoid curve shape parameter |
92
+
93
+ ## Admin Boundaries
94
+
95
+ What admins **cannot** do:
96
+
97
+ ### Registry Owner Cannot:
98
+ - **Unlock a locked terminal.** `lockTerminalFor` is irreversible -- there is no `unlockTerminalFor` function.
99
+ - **Override a project's locked terminal choice.** Once locked, the terminal is permanently stored in `_terminalOf[projectId]` and `hasLockedTerminal[projectId]` is permanently `true`.
100
+ - **Force a project to use a specific terminal.** Only the project owner (or delegate) can call `setTerminalFor`.
101
+ - **Access project funds.** The registry is a pass-through; it holds funds transiently during forwarding only.
102
+ - **Modify swap parameters, slippage, or routing logic.** These are controlled by the `JBRouterTerminal` contract, not the registry.
103
+ - **Pause payments.** There is no pause mechanism.
104
+
105
+ ### Router Terminal Owner Cannot:
106
+ - **Modify swap slippage parameters.** The TWAP window, sigmoid constants, fee tiers, and max slippage are all immutable.
107
+ - **Redirect funds.** The terminal is stateless between transactions and routes payments to whichever terminal the JB directory specifies.
108
+ - **Change the Uniswap factory or PoolManager.** These are immutable constructor parameters.
109
+ - **Override user-provided quotes.** The `quoteForSwap` metadata is decoded and used as-is.
110
+ - **Prevent specific users from paying.** There is no blocklist mechanism.
111
+ - **Extract stuck funds.** There is no sweep or rescue function. The terminal relies on completing all token movements within a single transaction.
112
+
113
+ ### Project Owner / Delegate Cannot:
114
+ - **Change the terminal after locking.** The `setTerminalFor` function reverts with `TerminalLocked` if the terminal is locked.
115
+ - **Set a disallowed terminal.** `setTerminalFor` reverts with `TerminalNotAllowed` if the terminal is not on the registry owner's allowlist.
116
+ - **Affect other projects' routing.** Permission checks are scoped to the specific `projectId`.
@@ -0,0 +1,49 @@
1
+ # nana-router-terminal-v6 — Architecture
2
+
3
+ ## Purpose
4
+
5
+ Payment routing terminal for Juicebox V6. Accepts any token and dynamically discovers what each destination project accepts, then routes payment via direct forwarding, Uniswap swap (V3 or V4), JB token cashout, or a combination.
6
+
7
+ ## Contract Map
8
+
9
+ ```
10
+ src/
11
+ ├── JBRouterTerminal.sol — Payment routing: swap + forward to destination terminal
12
+ ├── JBRouterTerminalRegistry.sol — Registry mapping projects to router terminal configs
13
+ ├── interfaces/
14
+ │ ├── IJBRouterTerminal.sol
15
+ │ └── IWETH9.sol
16
+ ├── libraries/
17
+ │ └── JBSwapLib.sol — Uniswap V3/V4 swap helpers, pool discovery
18
+ └── structs/
19
+ └── PoolInfo.sol — Cached pool configuration
20
+ ```
21
+
22
+ ## Key Data Flow
23
+
24
+ ### Payment Routing
25
+ ```
26
+ Payer → JBRouterTerminal.pay(projectId, token, amount)
27
+ → Discover destination project's accepted token
28
+ → If same token: forward directly to project's terminal
29
+ → If different token:
30
+ → Compare V3 and V4 pool quotes
31
+ → Swap via better pool
32
+ → Forward swapped tokens to project's terminal
33
+ → Return token count from destination payment
34
+ ```
35
+
36
+ ## Extension Points
37
+
38
+ | Point | Interface | Purpose |
39
+ |-------|-----------|---------|
40
+ | Terminal | `IJBTerminal` | Acts as a terminal that routes payments |
41
+ | Registry | `IJBRouterTerminalRegistry` | Maps projects to routing configs |
42
+ | Permit | `IJBPermitTerminal` | Permit2 token approval support |
43
+
44
+ ## Dependencies
45
+ - `@bananapus/core-v6` — Terminal, directory, permissions
46
+ - `@uniswap/v3-core` + `v3-periphery` — V3 swap routing
47
+ - `@uniswap/v4-core` — V4 pool manager
48
+ - `@uniswap/permit2` — Token approvals
49
+ - `@openzeppelin/contracts` — SafeERC20, ERC2771, Ownable
package/RISKS.md ADDED
@@ -0,0 +1,281 @@
1
+ # nana-router-terminal-v6 -- Risks
2
+
3
+ Deep implementation-level risk analysis.
4
+
5
+ ## Trust Assumptions
6
+
7
+ 1. **Uniswap V3/V4 pools** -- Swap execution depends on available liquidity and pool integrity. The terminal discovers pools at call time across 8 pools (4 V3 fee tiers + 4 V4 fee/tickSpacing pairs) and selects the one with the highest in-range liquidity.
8
+ 2. **JBDirectory** -- The terminal trusts `DIRECTORY.primaryTerminalOf()` and `DIRECTORY.terminalsOf()` to return correct terminal addresses. A compromised directory could redirect funds.
9
+ 3. **Destination terminals** -- After routing, funds are forwarded to the destination project's terminal via `terminal.pay()` or `terminal.addToBalanceOf()`. The router trusts these terminals to behave correctly.
10
+ 4. **JBTokens** -- Credit cashout paths call `TOKENS.transferCreditsFrom()` and `TOKENS.projectIdOf()`. A compromised token registry could misroute tokens.
11
+ 5. **Registry owner** -- Controls which terminals are allowlisted and sets the global default. A malicious owner could allowlist a malicious terminal, though project owners must still opt in via `setTerminalFor()`.
12
+ 6. **OracleLibrary** -- V3 TWAP quotes rely on Uniswap's `OracleLibrary.consult()`. Assumes the oracle history is populated and not stale.
13
+ 7. **V4 PoolManager** -- V4 swap execution uses `POOL_MANAGER.unlock()` with the router as `IUnlockCallback`. The callback is verified by checking `msg.sender == address(POOL_MANAGER)` (line 417).
14
+
15
+ ## Risk Analysis
16
+
17
+ ### HIGH SEVERITY
18
+
19
+ #### H-1: V4 Spot Price Manipulation (M-3 Finding)
20
+
21
+ **Location**: `JBRouterTerminal._getV4SpotQuote()` (lines 1119-1153)
22
+ **Severity**: HIGH
23
+ **Tested**: YES -- `RouterTerminalSandwichFork.t.sol` (test_fork_v4SpotPrice_manipulation), `M3_V4SpotPriceSlippage.t.sol`
24
+
25
+ V4 vanilla pools have no built-in TWAP oracle. The terminal reads the instantaneous spot tick from `POOL_MANAGER.getSlot0(id)` (line 1131), which can be manipulated within the same block via sandwich attacks or flash loans.
26
+
27
+ **Impact**: An attacker can manipulate the V4 pool's spot price before a victim's transaction, causing the sigmoid slippage formula to compute a `minAmountOut` based on a distorted price. The attacker then reverses the manipulation after the victim's swap, extracting value.
28
+
29
+ **Mitigations in place**:
30
+ 1. Sigmoid slippage floor: `JBSwapLib.getSlippageTolerance()` enforces a minimum 2% slippage (200 bps), bounding worst-case loss to ~2% for small swaps in deep pools. The ceiling is 88% (line 17 of JBSwapLib.sol).
31
+ 2. User-provided quote: When `quoteForSwap` metadata is present, `_pickPoolAndQuote()` (line 1239) uses the user's value directly, bypassing spot-based calculation entirely.
32
+ 3. Pool discovery preference: `_discoverPool()` selects by highest in-range liquidity. If a V3 pool has more liquidity, it wins and gets the TWAP-protected path.
33
+
34
+ **Residual risk**: Without a user-provided quote, a V4 swap is exposed to up to sigmoid-slippage% loss per trade. The 2% floor means even in the best case, an attacker can extract ~2% from each V4 swap. Front-ends MUST supply `quoteForSwap` metadata for V4 swaps.
35
+
36
+ ---
37
+
38
+ #### H-2: Stuck Funds on Revert After Partial Swap State
39
+
40
+ **Location**: `JBRouterTerminal._handleSwap()` (lines 1162-1202), `_route()` (lines 1316-1368)
41
+ **Severity**: HIGH (theoretical -- mitigated by atomic execution)
42
+ **Tested**: YES -- Fork tests verify zero leftover balances after every swap.
43
+
44
+ If a swap succeeds but the subsequent `terminal.pay()` or `terminal.addToBalanceOf()` reverts, the swapped tokens remain in the router terminal with no recovery mechanism. There is no sweep or rescue function.
45
+
46
+ **Mitigation**: The entire flow (accept -> route -> swap -> forward) executes atomically within a single transaction. If the destination terminal reverts, the entire transaction reverts, including the swap. However, if the destination terminal accepts the tokens but misbehaves (e.g., does not revert but does not credit the project), the tokens are lost.
47
+
48
+ **Test coverage**: Fork tests (`RouterTerminalFork.t.sol`) assert `address(routerTerminal).balance == 0` and `IERC20(...).balanceOf(address(routerTerminal)) == 0` after every operation.
49
+
50
+ ---
51
+
52
+ ### MEDIUM SEVERITY
53
+
54
+ #### M-1: TWAP Oracle Stale or Insufficient History
55
+
56
+ **Location**: `JBRouterTerminal._getV3TwapQuote()` (lines 1052-1096)
57
+ **Severity**: MEDIUM
58
+ **Tested**: NO -- No test directly exercises the `oldestObservation < twapWindow` fallback path.
59
+
60
+ The TWAP window defaults to 10 minutes (600 seconds). If the pool's oldest observation is younger than 10 minutes (`oldestObservation < twapWindow`, line 1067), the window is silently capped to the available history. A newly created pool or one with minimal activity could have only seconds of history, making the "TWAP" functionally equivalent to a spot price and vulnerable to manipulation.
61
+
62
+ **Impact**: Short TWAP windows (e.g., 30 seconds) provide minimal manipulation resistance.
63
+
64
+ **Additional check**: If `oldestObservation == 0`, the function reverts with `JBRouterTerminal_NoObservationHistory()` (line 1064). This prevents swaps against pools with zero history.
65
+
66
+ ---
67
+
68
+ #### M-2: Cashout Loop -- Circular Token Dependencies
69
+
70
+ **Location**: `JBRouterTerminal._cashOutLoop()` (lines 603-668)
71
+ **Severity**: MEDIUM
72
+ **Tested**: YES -- `L30_CashOutLoopLimit.t.sol`
73
+
74
+ If JB project tokens form a circular dependency (token A cashes out to token B, token B cashes out to token A), the `_cashOutLoop` iterates until hitting the 20-iteration cap (`_MAX_CASHOUT_ITERATIONS`, line 591), then reverts with `JBRouterTerminal_CashOutLoopLimit()`.
75
+
76
+ **Impact**: The transaction reverts cleanly (no fund loss), but the payment path is blocked. Gas is wasted up to the iteration limit.
77
+
78
+ **Tested**: `L30_CashOutLoopLimitTest.test_cashOutLoop_revertsOnCircularDependency()` verifies the revert, and `test_cashOutLoop_succeedsWithinLimit()` verifies non-circular paths succeed.
79
+
80
+ ---
81
+
82
+ #### M-3: Registry Default Terminal Change Affects Unlocked Projects
83
+
84
+ **Location**: `JBRouterTerminalRegistry.terminalOf()` (lines 155-158), `setDefaultTerminal()` (line 355)
85
+ **Severity**: MEDIUM
86
+ **Tested**: YES -- `RouterTerminalRegistry.t.sol` (test_terminalOf_fallsBackToDefault)
87
+
88
+ Projects that have not explicitly set a terminal via `setTerminalFor()` use `defaultTerminal`. If the registry owner changes the default, all unlocked projects without explicit terminal assignments are silently migrated to the new default.
89
+
90
+ **Impact**: The registry owner can redirect payments for all unlocked projects by changing the default terminal. Projects can protect against this by calling `setTerminalFor()` or `lockTerminalFor()`.
91
+
92
+ **Mitigation**: `lockTerminalFor()` snapshots the current default into `_terminalOf[projectId]` (lines 279-283), insulating the project from future default changes.
93
+
94
+ ---
95
+
96
+ #### M-4: Fee-on-Transfer Token Partial Support
97
+
98
+ **Location**: `JBRouterTerminal._acceptFundsFor()` (lines 527-533)
99
+ **Severity**: MEDIUM
100
+ **Tested**: NO -- No test exercises fee-on-transfer tokens.
101
+
102
+ The `_acceptFundsFor` function measures `balanceBefore` and `balanceAfter` (lines 527, 533) to handle fee-on-transfer tokens. However, `JBRouterTerminalRegistry._acceptFundsFor()` (lines 395-433) does NOT use balance-delta accounting -- it returns the user-supplied `amount` directly. If a fee-on-transfer token is used through the registry, the forwarded amount will exceed the actual tokens received, causing a later transfer to revert or underpay.
103
+
104
+ **Impact**: Payments via the registry with fee-on-transfer tokens will revert or behave incorrectly.
105
+
106
+ ---
107
+
108
+ #### M-5: uint160 Truncation in Permit2 Transfer
109
+
110
+ **Location**: `JBRouterTerminal._transferFrom()` (line 1415), `JBRouterTerminalRegistry._transferFrom()` (line 470)
111
+ **Severity**: MEDIUM
112
+ **Tested**: NO -- No test exercises amounts > `type(uint160).max`.
113
+
114
+ The `PERMIT2.transferFrom()` call casts `amount` to `uint160`: `PERMIT2.transferFrom(from, to, uint160(amount), token)`. If `amount > type(uint160).max`, the cast silently truncates, transferring fewer tokens than expected.
115
+
116
+ **Practical risk**: LOW. ERC-20 token supplies rarely approach `type(uint160).max` (~1.46e48). However, the code does not validate the cast.
117
+
118
+ ---
119
+
120
+ ### LOW SEVERITY
121
+
122
+ #### L-1: Lock Terminal Race Condition Protection
123
+
124
+ **Location**: `JBRouterTerminalRegistry.lockTerminalFor()` (lines 269-293)
125
+ **Severity**: LOW (mitigated)
126
+ **Tested**: YES -- `L29_LockTerminalRace.t.sol`
127
+
128
+ The `expectedTerminal` parameter prevents a race condition where the default terminal changes between transaction submission and mining. If the resolved terminal does not match `expectedTerminal`, the function reverts with `TerminalMismatch` (line 287).
129
+
130
+ **Tested**: Three test cases cover correct expected, wrong expected (default fallback), and wrong expected (explicit terminal set).
131
+
132
+ ---
133
+
134
+ #### L-2: V3 Callback Verification via Factory
135
+
136
+ **Location**: `JBRouterTerminal.uniswapV3SwapCallback()` (lines 390-411)
137
+ **Severity**: LOW (standard pattern)
138
+ **Tested**: YES -- Exercised by all fork swap tests.
139
+
140
+ The callback reads `IUniswapV3Pool(msg.sender).fee()` (line 399) and verifies via `FACTORY.getPool()` (line 400). If the caller is not a legitimate pool, it reverts with `CallerNotPool`. This is the standard V3 verification pattern.
141
+
142
+ **Note**: The fee is read from the caller (`msg.sender`), so a malicious contract could return any fee value. However, the factory lookup with the actual token pair and that fee must match `msg.sender`, which is not forgeable.
143
+
144
+ ---
145
+
146
+ #### L-3: Empty `receive()` Function
147
+
148
+ **Location**: `JBRouterTerminal.receive()` (line 1423)
149
+ **Severity**: LOW
150
+ **Tested**: Implicitly -- fork tests verify zero balance after operations.
151
+
152
+ The terminal accepts ETH from any sender via an empty `receive()` function. This is necessary for WETH unwraps, cashout reclaims, and V4 PoolManager takes. However, if someone accidentally sends ETH directly, it cannot be recovered.
153
+
154
+ **Impact**: Accidental ETH sent directly to the terminal is permanently stuck.
155
+
156
+ ---
157
+
158
+ #### L-4: No Deadline Parameter
159
+
160
+ **Location**: `JBRouterTerminal.pay()` (lines 345-383), `addToBalanceOf()` (lines 294-328)
161
+ **Severity**: LOW
162
+ **Tested**: NO
163
+
164
+ Neither `pay()` nor `addToBalanceOf()` accepts a deadline parameter. A transaction could sit in the mempool for an extended period and execute at a stale price. The TWAP-based or spot-based `minAmountOut` is computed at execution time, not submission time, which partially mitigates this risk (the quote is always fresh). However, market conditions can change between when the user decides to pay and when the transaction is mined.
165
+
166
+ **Mitigation**: The user-provided `quoteForSwap` metadata effectively acts as a deadline mechanism by specifying a minimum output.
167
+
168
+ ---
169
+
170
+ #### L-5: Pool Discovery Gas Cost
171
+
172
+ **Location**: `JBRouterTerminal._discoverPool()` (lines 766-808), `_discoverAcceptedToken()` (lines 716-759)
173
+ **Severity**: LOW
174
+ **Tested**: YES -- Fork tests execute full discovery paths.
175
+
176
+ Pool discovery at call time searches up to 8 pools (4 V3 + 4 V4) per token pair. `_discoverAcceptedToken()` iterates all terminals and all accounting contexts for a project, calling `_bestPoolLiquidity()` for each candidate. For projects with many terminals and accepted tokens, this can become gas-intensive.
177
+
178
+ **Impact**: Elevated gas costs, not a correctness issue.
179
+
180
+ ---
181
+
182
+ ## MEV / Sandwich Attack Vectors
183
+
184
+ ### V3 Path: TWAP-Protected
185
+
186
+ The V3 swap path computes `minAmountOut` from a 10-minute TWAP oracle (`OracleLibrary.consult()`, line 1070). Same-block spot price manipulation does NOT affect the TWAP.
187
+
188
+ **Tested**: `RouterTerminalSandwichFork.t.sol`:
189
+ - `test_fork_v3Sandwich_varyingAttackSizes()` -- Simulates sandwich attacks at 0.5-100 ETH against a 1 ETH victim. Attacker pays 2x pool fees (0.05% each way) for no profit.
190
+ - `test_fork_v3Sandwich_twapResistance()` -- Proves the TWAP tick is identical before and after a 100 ETH same-block manipulation. The 10-minute observation window makes single-block attacks futile.
191
+ - `test_fork_v3Sandwich_withUserQuote()` -- Shows that a tight user quote (0.5% slippage) blocks attacks that the wider TWAP tolerance (~2%) would allow.
192
+
193
+ **Residual risk**: Multi-block TWAP manipulation is theoretically possible but requires sustained capital over many blocks, making it economically infeasible for typical swap sizes.
194
+
195
+ ### V4 Path: Spot Price Vulnerable
196
+
197
+ V4 vanilla pools lack a TWAP oracle. The router reads `getSlot0()` spot price, which is manipulable within the same block.
198
+
199
+ **Tested**: `RouterTerminalSandwichFork.t.sol`:
200
+ - `test_fork_v4SpotPrice_manipulation()` -- Creates a fresh V4 pool, demonstrates that 10-100 ETH attacks can move the spot price significantly (documented as M-3 risk).
201
+
202
+ **Tested**: `M3_V4SpotPriceSlippage.t.sol`:
203
+ - 14 unit tests verifying sigmoid properties: floor enforcement, monotonicity, bounded range, fuzz tests.
204
+ - `test_v4QuoteSimulation_sigmoidFloorEnforcesMinOutput()` -- Proves `minAmountOut >= 98%` of spot for small swaps in deep pools.
205
+ - `test_userQuote_overrides_sigmoidCalculation()` -- Shows user quote bypasses the sigmoid path.
206
+
207
+ **Recommendation**: Front-ends MUST supply `quoteForSwap` metadata for V4 swaps. Without it, the sigmoid slippage floor (~2%) is the only protection.
208
+
209
+ ### Leftover Return as Refund Vector
210
+
211
+ **Location**: `_handleSwap()` (lines 1196-1201)
212
+
213
+ After a swap, leftover input tokens (from partial fills where the sqrtPriceLimit was hit) are returned to `_msgSender()`. This uses a balance-delta approach (`balanceAfter - balanceBefore`), which is safe against reentrancy because the leftover is measured after the swap completes.
214
+
215
+ **Tested**: Fork tests assert zero leftover after every swap.
216
+
217
+ ## Reentrancy Analysis
218
+
219
+ ### No Explicit Reentrancy Guard
220
+
221
+ Neither `JBRouterTerminal` nor `JBRouterTerminalRegistry` uses OpenZeppelin's `ReentrancyGuard`. Instead, the contracts rely on state ordering and atomic execution.
222
+
223
+ ### JBRouterTerminal
224
+
225
+ | Entry Point | External Calls Made | Reentrancy Risk | Analysis |
226
+ |-------------|-------------------|----------------|----------|
227
+ | `pay()` | `_acceptFundsFor()` -> `_route()` -> `_convert()` -> Uniswap swap -> `terminal.pay()` | LOW | Funds are accepted first, then routed atomically. The terminal is stateless between calls -- no storage is updated between external calls that could be exploited. |
228
+ | `addToBalanceOf()` | Same as `pay()` but ends with `terminal.addToBalanceOf()` | LOW | Same analysis as `pay()`. |
229
+ | `uniswapV3SwapCallback()` | `WETH.deposit()`, `IERC20.safeTransfer()` | LOW | Only called by verified V3 pools (factory check). Wraps ETH and transfers input tokens to the pool. |
230
+ | `unlockCallback()` | `POOL_MANAGER.swap()`, `_settleV4()`, `_takeV4()` | LOW | Only called by V4 PoolManager (address check). The PoolManager's unlock pattern prevents reentrancy by design. |
231
+ | `_cashOutLoop()` | `cashOutTerminal.cashOutTokensOf()` (in loop) | MEDIUM | Makes up to 20 external calls in a loop. Each call to `cashOutTokensOf()` could trigger arbitrary code in the cashout terminal. However, the loop only processes one token type at a time and the terminal is stateless. |
232
+
233
+ ### JBRouterTerminalRegistry
234
+
235
+ | Entry Point | External Calls Made | Reentrancy Risk | Analysis |
236
+ |-------------|-------------------|----------------|----------|
237
+ | `pay()` | `_acceptFundsFor()` -> `terminal.pay()` | LOW | Accepts funds, then forwards. No state updates between these calls that could be exploited. |
238
+ | `addToBalanceOf()` | `_acceptFundsFor()` -> `terminal.addToBalanceOf()` | LOW | Same as `pay()`. |
239
+
240
+ ### Key Observation
241
+
242
+ The router terminal is **stateless** between transactions. It holds no persistent balances, no queued operations, no pending state. All token movements (accept, swap, forward) happen within a single transaction and complete or revert atomically. This eliminates the primary vector for reentrancy attacks (corrupting intermediate state).
243
+
244
+ The only reentrancy concern is in `_cashOutLoop()`, where a malicious cashout terminal could re-enter `pay()`. However, since the router has no state to corrupt, re-entrant calls would be independent transactions with their own accept/route/forward flow.
245
+
246
+ ## Test Coverage Summary
247
+
248
+ ### Test Files (8 total)
249
+
250
+ | Test File | Type | What It Tests | Tests |
251
+ |-----------|------|---------------|-------|
252
+ | `RouterTerminal.t.sol` | Unit (mocked) | Core routing: direct forwarding, swap paths, cashout paths, V4 routing, pool discovery, TWAP quoting, error conditions | ~35 tests |
253
+ | `RouterTerminalRegistry.t.sol` | Unit (mocked) | Registry: allow/disallow, set/lock terminal, forwarding, permissions | ~12 tests |
254
+ | `RouterTerminalFork.t.sol` | Fork (mainnet) | End-to-end swaps against real Uniswap V3 pools: ETH->USDC, USDC->ETH, ETH->DAI, direct forwarding, addToBalance, quote metadata, slippage reverts | ~12 tests |
255
+ | `RouterTerminalFeeCashOutFork.t.sol` | Fork (mainnet) | Fee routing: project 3 payouts -> fee in project 2's token -> cashout -> ETH -> project 1 | 1 test |
256
+ | `RouterTerminalSandwichFork.t.sol` | Fork (mainnet) | MEV/sandwich resistance: V3 TWAP resistance, V4 spot manipulation, user quote protection | 4 tests |
257
+ | `L29_LockTerminalRace.t.sol` | Unit (regression) | Race condition in `lockTerminalFor()` with `expectedTerminal` parameter | 3 tests |
258
+ | `L30_CashOutLoopLimit.t.sol` | Unit (regression) | Circular cashout dependency cap at 20 iterations | 2 tests |
259
+ | `M3_V4SpotPriceSlippage.t.sol` | Unit + fuzz (regression) | Sigmoid slippage math: floor, ceiling, monotonicity, bounded range, user quote override | 14 tests |
260
+
261
+ ### Coverage Gaps
262
+
263
+ | Area | Status | Gap Description |
264
+ |------|--------|-----------------|
265
+ | Fee-on-transfer tokens | NOT TESTED | No test exercises tokens with transfer fees. `JBRouterTerminal._acceptFundsFor()` uses balance-delta but `JBRouterTerminalRegistry._acceptFundsFor()` does not. |
266
+ | Short TWAP windows | NOT TESTED | No test verifies behavior when `oldestObservation` is very small (e.g., 10 seconds), which weakens TWAP protection. |
267
+ | uint160 truncation in Permit2 | NOT TESTED | No test exercises amounts exceeding `type(uint160).max`. |
268
+ | Deadline/stale transactions | NOT TESTED | No test simulates a transaction executing after an extended mempool delay. |
269
+ | Multi-hop routing | NOT TESTED | No test exercises a cashout chain longer than 1 step (A -> B -> C). The 20-iteration cap is tested but only for the circular case. |
270
+ | V4 with hooks | NOT TESTED | All V4 tests use `hooks: IHooks(address(0))`. No test exercises V4 pools with custom hook contracts, which could alter swap behavior. |
271
+ | Concurrent operations | NOT TESTED | No test simulates multiple simultaneous pay/addToBalance calls that might interfere. (Mitigated by stateless design.) |
272
+ | `addToBalanceOf` routing | PARTIALLY TESTED | Fork test covers ETH->USDC via `addToBalanceOf`, but not the cashout or credit-based paths. |
273
+ | Credit cashout path | PARTIALLY TESTED | Unit test mocks the path but no fork test exercises real credit transfers. |
274
+
275
+ ## Privileged Roles
276
+
277
+ | Role | Permission | Scope | Risk |
278
+ |------|-----------|-------|------|
279
+ | Registry Owner | `allowTerminal`, `disallowTerminal`, `setDefaultTerminal` | Global | Can redirect all unlocked projects by changing the default terminal. Cannot affect locked projects. |
280
+ | Project Owner / Delegate | `SET_ROUTER_TERMINAL` (28) -- set and lock terminal | Per-project | Can permanently lock routing. Locking is irreversible. |
281
+ | Router Terminal Owner | `transferOwnership`, `renounceOwnership` (inherited, unused) | Global | No operational impact currently. Future subclasses could add `onlyOwner` functions. |