@bananapus/router-terminal-v6 0.0.27 → 0.0.28

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
@@ -11,14 +11,14 @@
11
11
 
12
12
  ## Purpose
13
13
 
14
- `nana-router-terminal-v6` splits administration between a global registry and project-local terminal selection. The router logic itself is mostly immutable; the mutable control plane lives in `JBRouterTerminalRegistry`.
14
+ `nana-router-terminal-v6` splits administration between a global registry and project-local terminal selection. The router logic itself is mostly immutable. The mutable control plane lives in `JBRouterTerminalRegistry`.
15
15
 
16
16
  ## Control Model
17
17
 
18
- - `JBRouterTerminalRegistry` is globally `Ownable`.
19
- - Project owners or delegates choose and can lock their router terminal.
20
- - `JBRouterTerminal` has immutable routing dependencies and no owner-controlled strategy knobs.
21
- - Some transaction paths depend on project-local `JBPermissions`, such as `TRANSFER_CREDITS`.
18
+ - `JBRouterTerminalRegistry` is globally `Ownable`
19
+ - project owners or delegates choose and can lock their router terminal
20
+ - `JBRouterTerminal` has immutable routing dependencies and no owner-controlled strategy knobs
21
+ - some transaction paths depend on project-local `JBPermissions`, such as `TRANSFER_CREDITS`
22
22
 
23
23
  ## Roles
24
24
 
@@ -39,38 +39,38 @@
39
39
 
40
40
  ## Immutable And One-Way
41
41
 
42
- - `lockTerminalFor(...)` is irreversible.
43
- - Constructor dependencies on the router are immutable.
44
- - The current default terminal must move before the old default can be disallowed.
42
+ - `lockTerminalFor(...)` is irreversible
43
+ - constructor dependencies on the router are immutable
44
+ - the current default terminal must move before the old default can be disallowed
45
45
 
46
46
  ## Operational Notes
47
47
 
48
- - Keep the terminal allowlist small and explicit.
49
- - Change the default terminal carefully because unconfigured projects inherit it.
50
- - Encourage projects to lock only after validating the resolved terminal and routing behavior.
51
- - Review credit-cashout routing permissions before relying on that path operationally.
52
- - Distinguish configuration risk from quote-quality risk: some route-discovery paths are best-effort, and some V4 quote paths can rely on weaker spot-style assumptions when robust history is unavailable.
48
+ - keep the terminal allowlist small and explicit
49
+ - change the default terminal carefully because unconfigured projects inherit it
50
+ - encourage projects to lock only after validating the resolved terminal and routing behavior
51
+ - review credit-cashout routing permissions before relying on that path
52
+ - distinguish configuration risk from quote-quality risk
53
53
 
54
54
  ## Machine Notes
55
55
 
56
- - Do not treat registry ownership as authority to override locked project choice.
57
- - Inspect `src/JBRouterTerminalRegistry.sol` and `src/JBRouterTerminal.sol` separately; they govern different control boundaries.
58
- - If the effective terminal resolution and the documented default differ, stop and resolve the registry state before further actions.
59
- - If route previews are falling back to weaker discovery or quote paths, do not describe the router as offering uniform oracle-quality guarantees across all pools and states.
56
+ - do not treat registry ownership as authority to override a locked project choice
57
+ - inspect `src/JBRouterTerminalRegistry.sol` and `src/JBRouterTerminal.sol` separately; they govern different control boundaries
58
+ - if effective terminal resolution and the documented default differ, resolve registry state before further actions
59
+ - if route previews are falling back to weaker discovery or quote paths, do not describe the router as offering uniform oracle-quality guarantees
60
60
 
61
61
  ## Recovery
62
62
 
63
- - Unlocked projects can switch to another allowlisted terminal.
64
- - Locked projects cannot be unlocked by the registry.
65
- - Bad immutable router behavior means replacement infrastructure, not in-place edits.
66
- - Quote-path weakness is usually mitigated operationally with better pool choice, external quoting, or replacement routing infrastructure, not with an owner-only hotfix.
63
+ - unlocked projects can switch to another allowlisted terminal
64
+ - locked projects cannot be unlocked by the registry
65
+ - bad immutable router behavior means replacement infrastructure, not in-place edits
66
+ - quote-path weakness is usually mitigated operationally with better pool choice, external quoting, or replacement routing infrastructure
67
67
 
68
68
  ## Admin Boundaries
69
69
 
70
- - The registry owner cannot unlock or override a locked project terminal.
71
- - Project operators cannot set a terminal that the registry does not allow.
72
- - Router maintainers cannot tune routing heuristics or constructor immutables post-deploy.
73
- - There is no pause surface in the registry or router.
70
+ - the registry owner cannot unlock or override a locked project terminal
71
+ - project operators cannot set a terminal that the registry does not allow
72
+ - router maintainers cannot tune routing heuristics or constructor immutables post-deploy
73
+ - there is no pause surface in the registry or router
74
74
 
75
75
  ## Source Map
76
76
 
package/ARCHITECTURE.md CHANGED
@@ -2,24 +2,26 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- `nana-router-terminal-v6` lets a payer fund a Juicebox project with a token the project does not directly accept. It discovers the destination token, unwraps or wraps native assets when needed, can recursively cash out upstream JB project tokens, and swaps through bounded Uniswap V3 or V4 routes before forwarding value to the destination terminal.
5
+ `nana-router-terminal-v6` lets a payer fund a Juicebox project with a token the project does not directly accept. It discovers the destination token, wraps or unwraps native assets when needed, can recursively cash out upstream JB project tokens, and swaps through bounded Uniswap V3 or V4 routes before forwarding value to the destination terminal.
6
6
 
7
7
  The router is intentionally heuristic. It does not search every possible route for a globally optimal price.
8
8
 
9
9
  ## System Overview
10
10
 
11
- `JBRouterTerminal` is a terminal-shaped adapter, not an accounting source of truth. `JBRouterTerminalRegistry` is both a registry and a stable project-facing proxy surface: projects can point at the registry while the registry resolves, and can later lock, the actual router terminal implementation to use. `JBPayRouteResolver` expands preview candidates without forcing the main router contract to carry all preview complexity inline. Final accounting still occurs in the downstream terminal selected through `nana-core-v6`.
11
+ `JBRouterTerminal` is a terminal-shaped adapter, not an accounting source of truth. `JBRouterTerminalRegistry` is both a registry and a stable project-facing proxy surface: projects can point at the registry while the registry resolves, and can later lock, the actual router terminal implementation to use. `JBPayRouteResolver` expands preview candidates without forcing the main router contract to carry all preview complexity inline.
12
+
13
+ Final accounting still happens in the downstream terminal selected through `nana-core-v6`.
12
14
 
13
15
  ## Core Invariants
14
16
 
15
- - The router's own accounting context is synthetic and must not be treated as the project ledger.
16
- - Preview route discovery and live execution must stay aligned.
17
- - Refund behavior is part of correctness, not UX.
18
- - Registry locking prevents silent migration to untrusted router implementations.
19
- - Final terminal-facing ERC-20 hops only support standard, non-lossy transfers.
20
- - Recursive project-token cashout routing is intentionally bounded; non-converging paths should fail instead of looping.
21
- - Caller reclaim minima only apply to the first cashout hop, because later hops may change token units.
22
- - Circular `router -> registry -> same router` forwarding remains blocked in the registry.
17
+ - the router's own accounting context is synthetic and must not be treated as the project ledger
18
+ - preview route discovery and live execution must stay aligned
19
+ - refund behavior is part of correctness, not just UX
20
+ - registry locking prevents silent migration to untrusted router implementations
21
+ - final terminal-facing ERC-20 hops only support standard, non-lossy transfers
22
+ - recursive project-token cashout routing is intentionally bounded
23
+ - caller reclaim minima only apply to the first cashout hop, because later hops may change token units
24
+ - circular `router -> registry -> same router` forwarding remains blocked in the registry
23
25
 
24
26
  ## Modules
25
27
 
@@ -32,10 +34,10 @@ The router is intentionally heuristic. It does not search every possible route f
32
34
 
33
35
  ## Trust Boundaries
34
36
 
35
- - Final accounting remains in the downstream terminal selected through `JBDirectory`.
36
- - The router trusts Uniswap V3, Uniswap V4, Permit2, and optional payer trackers for routing-side behavior.
37
- - Fee-on-transfer tokens are only tolerated on ingress where received-balance deltas can be reconciled.
38
- - The registry is trusted to resolve and forward into the intended router terminal implementation for a project.
37
+ - final accounting remains in the downstream terminal selected through `JBDirectory`
38
+ - the router trusts Uniswap V3, Uniswap V4, Permit2, and optional payer trackers for routing-side behavior
39
+ - fee-on-transfer tokens are only tolerated on ingress where received-balance deltas can be reconciled
40
+ - the registry is trusted to resolve and forward into the intended router implementation for a project
39
41
 
40
42
  ## Critical Flows
41
43
 
@@ -55,24 +57,23 @@ router pay call
55
57
 
56
58
  The router does not own project balances. It owns transient route accounting: input reconciliation, swap execution, forwarded amount, and refund resolution.
57
59
 
58
- Preview and execution share the same conceptual route shape: optional recursive cashout first, then destination-token resolution, then final conversion and forwarding. `JBPayRouteResolver` narrows candidate tokens and usable external terminals so the live router does not need to brute-force every possibility inline.
60
+ Preview and execution share the same conceptual route shape: optional recursive cashout first, then destination-token resolution, then final conversion and forwarding.
59
61
 
60
62
  ## Security Model
61
63
 
62
- - Native-asset handling and refunds are the most failure-prone paths.
63
- - V3 and V4 discovery must stay synchronized between preview and live execution.
64
- - V4 discovery intentionally considers both vanilla pools and pools using the canonical `UNIV4_HOOK`.
65
- - The router's best route claim is only as strong as its bounded discovery set and external-terminal safety checks. It is not a global optimizer.
66
- - Recursive cashout behavior, preferred-token handling, and one-shot source overrides are tightly coupled; changing one can silently desynchronize preview from execution.
67
- - “Best route” means best under the bounded discovery heuristic, not globally optimal routing.
64
+ - native-asset handling and refunds are the most failure-prone paths
65
+ - V3 and V4 discovery must stay synchronized between preview and live execution
66
+ - V4 discovery intentionally considers both vanilla pools and pools using the canonical `UNIV4_HOOK`
67
+ - the router's "best route" claim is only as strong as its bounded discovery set and external-terminal safety checks
68
+ - recursive cashout behavior, preferred-token handling, and one-shot source overrides are tightly coupled
68
69
 
69
70
  ## Safe Change Guide
70
71
 
71
- - Keep route discovery and route execution semantics paired.
72
- - Be conservative with native wrapping, unwrapping, and refund behavior.
73
- - If recursive cash-out logic changes, review hop limits and failure handling together.
74
- - If metadata semantics change, re-check first-hop reclaim minima, one-shot source overrides, and preferred-token routing together.
75
- - Do not turn the router into a persistent treasury layer.
72
+ - keep route discovery and route execution semantics paired
73
+ - be conservative with native wrapping, unwrapping, and refund behavior
74
+ - if recursive cash-out logic changes, review hop limits and failure handling together
75
+ - if metadata semantics change, re-check first-hop reclaim minima, one-shot source overrides, and preferred-token routing together
76
+ - do not turn the router into a persistent treasury layer
76
77
 
77
78
  ## Canonical Checks
78
79
 
@@ -5,6 +5,7 @@ This repo accepts one token and routes value into whatever token a destination p
5
5
  ## Audit Objective
6
6
 
7
7
  Find issues that:
8
+
8
9
  - route user funds through an incorrect pool or protocol path
9
10
  - under-deliver relative to quoted or minimum-return semantics
10
11
  - refund leftovers to the wrong party or trap them in the router
@@ -14,6 +15,7 @@ Find issues that:
14
15
  ## Scope
15
16
 
16
17
  In scope:
18
+
17
19
  - `src/JBRouterTerminal.sol`
18
20
  - `src/JBRouterTerminalRegistry.sol`
19
21
  - `src/interfaces/`
@@ -22,6 +24,7 @@ In scope:
22
24
  - deployment scripts in `script/`
23
25
 
24
26
  Key dependencies:
27
+
25
28
  - `nana-core-v6`
26
29
  - Uniswap V3 and V4 integration surfaces
27
30
 
@@ -34,7 +37,8 @@ Key dependencies:
34
37
  ## Security Model
35
38
 
36
39
  The router terminal:
37
- - discovers what token a project’s terminal accepts
40
+
41
+ - discovers what token a project's terminal accepts
38
42
  - decides whether to route via wrap/unwrap, V3, V4, Juicebox token cash-out, or a combination
39
43
  - forwards value into the destination terminal
40
44
  - optionally handles Permit2-funded transfers
@@ -59,17 +63,14 @@ The registry chooses which router terminal instance a project uses and whether t
59
63
 
60
64
  ## Critical Invariants
61
65
 
62
- 1. User intent is preserved
63
- The actual destination project, beneficiary, minimum output semantics, and refund recipient must match the request and metadata.
64
-
65
- 2. No leftover value disappears
66
- Partial fills, failed paths, and overfunded inputs must either be forwarded or refunded to the intended party.
67
-
68
- 3. Pool discovery and settlement agree
69
- The quoted path, callback settlement, and final forwarded amount must all describe the same trade.
70
-
71
- 4. Registry controls stay narrow
72
- Default terminals, allowed terminals, and lock semantics must not let an unexpected router instance take over project routing.
66
+ 1. User intent is preserved.
67
+ The actual destination project, beneficiary, minimum output semantics, and refund recipient must match the request and metadata.
68
+ 2. No leftover value disappears.
69
+ Partial fills, failed paths, and overfunded inputs must either be forwarded or refunded to the intended party.
70
+ 3. Pool discovery and settlement agree.
71
+ The quoted path, callback settlement, and final forwarded amount must all describe the same trade.
72
+ 4. Registry controls stay narrow.
73
+ Default terminals, allowed terminals, and lock semantics must not let an unexpected router instance take over project routing.
73
74
 
74
75
  ## Attack Surfaces
75
76
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Juicebox Router Terminal
2
2
 
3
- `@bananapus/router-terminal-v6` is a routing terminal for Juicebox V6. It accepts value in many input tokens, discovers what token the destination project actually accepts, and forwards the payment through the best previewed route it can resolve from the configured candidates.
3
+ `@bananapus/router-terminal-v6` is a routing terminal for Juicebox V6. It accepts value in many input tokens, discovers what token the destination project actually accepts, and forwards the payment through the best route it can resolve from the configured candidates.
4
4
 
5
5
  Docs: <https://docs.juicebox.money>
6
6
  Architecture: [ARCHITECTURE.md](./ARCHITECTURE.md)
@@ -19,7 +19,7 @@ It can route through:
19
19
  - direct forwarding when the destination already accepts the input token
20
20
  - wrapping or unwrapping native ETH and WETH
21
21
  - Uniswap V3 or V4 swaps
22
- - recursive Juicebox token cash outs when the input itself is a project token
22
+ - recursive Juicebox token cash outs when the input is itself a project token
23
23
 
24
24
  Projects can use the registry to choose, and optionally lock, a project-specific router terminal or fall back to the registry's default terminal.
25
25
 
@@ -60,18 +60,18 @@ The shortest useful reading order is:
60
60
 
61
61
  ## Integration Traps
62
62
 
63
- - projects that expose a router terminal still settle into ordinary Juicebox terminals underneath; downstream semantics still matter
64
- - route discovery and route execution are related but not identical, especially when liquidity or metadata-supplied quotes move
63
+ - projects that expose a router terminal still settle into ordinary Juicebox terminals underneath
64
+ - route discovery and route execution are related but not identical, especially when liquidity or caller-supplied quote data moves
65
65
  - using JB project tokens as router input creates recursive path complexity that frontends and integrators should model explicitly
66
- - the registry layer changes who a project routes through, but not what the downstream terminal ultimately is
66
+ - the registry changes which router a project uses, but not what downstream terminal ultimately settles the payment
67
67
 
68
68
  ## Where State Lives
69
69
 
70
- - route-selection logic lives in `JBRouterTerminal`
71
- - per-project router choice and lock status live in `JBRouterTerminalRegistry`
72
- - accepted-token accounting and final balance changes live in the downstream terminal, usually in `nana-core-v6`
70
+ - route-selection logic: `JBRouterTerminal`
71
+ - per-project router choice and lock status: `JBRouterTerminalRegistry`
72
+ - accepted-token accounting and final balance changes: the downstream terminal, usually in `nana-core-v6`
73
73
 
74
- That separation is the reason a successful route can still end in a downstream terminal behavior you did not expect.
74
+ That separation is why a successful route can still end in downstream terminal behavior you did not expect.
75
75
 
76
76
  ## High-Signal Tests
77
77
 
@@ -125,8 +125,8 @@ script/
125
125
  - the router synthesizes accounting context for discovery and should not be treated as an accounting-truth surface
126
126
  - swap previews are best-effort estimates and depend on current pool state plus caller-supplied quote data
127
127
  - recursive cash-out routing increases complexity when the input token is itself a Juicebox project token
128
- - slippage and sandwich resistance depend on the quality of the quote path chosen for the route
129
- - final terminal-facing ERC-20 hops must be standard tokens; lossy terminal pulls are rejected on both router and registry paths
128
+ - slippage and sandwich resistance depend on the quality of the chosen quote path
129
+ - final terminal-facing ERC-20 hops must be standard tokens; lossy terminal pulls are rejected
130
130
 
131
131
  The most common reader mistake here is to stop at the router and forget to inspect the terminal that actually receives the value.
132
132
 
package/RISKS.md CHANGED
@@ -1,118 +1,105 @@
1
1
  # Router Terminal Risk Register
2
2
 
3
- This file focuses on the routing, accounting-context, and liquidity-selection risks in the terminal that accepts arbitrary tokens and forwards them into the destination project's real accounting surface.
3
+ This file covers the routing, accounting-context, and liquidity-selection risks in the terminal that accepts arbitrary tokens and forwards them into a project's real accounting surface.
4
4
 
5
- ## How to use this file
5
+ ## How To Use This File
6
6
 
7
- - Read `Priority risks` first; they explain where routing convenience can diverge from accounting truth.
8
- - Use the detailed sections for token-decimal synthesis, swap-path, and integration reasoning.
9
- - Treat `Accepted Behaviors` as explicit statements about what this terminal does not guarantee.
7
+ - Read `Priority risks` first. They explain where routing convenience can diverge from accounting truth.
8
+ - Use the later sections for token-decimal synthesis, swap-path, and integration reasoning.
9
+ - Treat `Accepted behaviors` as explicit statements about what this terminal does not guarantee.
10
10
 
11
- ## Priority risks
11
+ ## Priority Risks
12
12
 
13
13
  | Priority | Risk | Why it matters | Primary controls |
14
14
  |----------|------|----------------|------------------|
15
- | P0 | Synthetic accounting context misuse | The router synthesizes best-effort decimals and routing context; if downstream systems treat it as accounting truth, they can misprice or mis-lend. | Clear docs, registry scoping, and explicit prohibition on accounting-sensitive reuse. |
16
- | P1 | Wrong-route or low-liquidity execution | The router chooses among direct forwarding, V3, V4, and cash-out paths; a bad route can degrade user execution. | Route selection tests, liquidity checks, and user-specified minimum returns. |
17
- | P1 | Integration fragility with broken tokens | Non-standard ERC-20 metadata or transfer behavior can distort decimal inference or swap execution. | Fallback defaults, defensive probing, receipt checks on terminal-facing hops, and integration testing with hostile token behavior. |
18
-
15
+ | P0 | Synthetic accounting context misuse | The router synthesizes best-effort decimals and routing context. If downstream systems treat it as accounting truth, they can misprice or mis-lend. | Clear docs, registry scoping, and explicit prohibition on accounting-sensitive reuse. |
16
+ | P1 | Wrong-route or low-liquidity execution | The router chooses among direct forwarding, V3, V4, and cash-out paths. A bad route can degrade user execution. | Route-selection tests, liquidity checks, and user-specified minimum returns. |
17
+ | P1 | Integration fragility with broken tokens | Non-standard ERC-20 metadata or transfer behavior can distort decimal inference or swap execution. | Fallback defaults, defensive probing, receipt checks on terminal-facing hops, and hostile-token testing. |
19
18
 
20
19
  ## 1. Trust Assumptions
21
20
 
22
- - **Uniswap V3 Factory / V4 PoolManager.** Pool discovery trusts `FACTORY.getPool()` and `POOL_MANAGER.getSlot0()` to return legitimate pools. If the factory or pool manager is compromised, swaps route through attacker-controlled pools.
23
- - **Canonical V4 hook configuration.** Hooked V4 pool discovery relies on the immutable `UNIV4_HOOK` constructor value. If deployers point it at the wrong hook, the router can miss the intended buyback-hook / LP-split-hook pools and degrade to weaker routes or no route.
24
- - **Trusted forwarder.** ERC-2771 `_msgSender()` trusted for fund transfers. A compromised forwarder can initiate payments/transfers on behalf of any user.
25
- - **JBDirectory.** Terminal resolution trusts `DIRECTORY.primaryTerminalOf()` and `DIRECTORY.terminalsOf()`. A compromised directory can redirect funds.
26
- - **PERMIT2.** Used as fallback for token transfers. Permit2 approvals can be exploited if users have stale allowances.
27
- - **Owner (Ownable).** Contract owner has no fund access but controls the registry terminal allowlist and default.
28
- - **`IJBPayerTracker` implementers.** `_resolveRefundWithBackupRecipient` 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.
29
-
30
- ## 2. Economic / Manipulation Risks
31
-
32
- - **V4 price manipulation.** `_getV4Quote` first attempts a 30-second TWAP from the pool's oracle hook (e.g., `IGeomeanOracle.observe()`). If no oracle hook exists or the call fails, it falls back to 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. This spot-fallback path is an accepted risk for integrations that cannot source external quotes, particularly when routing routine flow through sufficiently deep pools where manipulation cost is expected to dominate likely extractable value. Deep liquidity reduces practical risk but does not eliminate the same-block manipulation surface. Thin pools, fresh pools, and unusually large swaps should not rely on this fallback. Front-ends SHOULD supply `quoteForSwap` metadata for V4 swaps whenever possible. Note: `_getV4Quote` 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.
33
- - **Hooked V4 discovery scope.** Auto-discovery checks both vanilla V4 pools and pools using the configured canonical `UNIV4_HOOK`. That keeps buyback-hook and LP-split-hook routes discoverable, but it also means deployment misconfiguration of `UNIV4_HOOK` changes which hooked pools are even visible to the router.
34
- - **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.
35
- - **Cashout loop value extraction.** `_cashOutLoop` iterates up to 20 times, cashing out JB project tokens recursively. Each cashout incurs bonding curve slippage. `cashOutMinReclaimed` is enforced only on the first concrete cashout hop. Later hops intentionally do not reuse or rescale that minimum, because multi-hop cashouts can change token units and a single metadata amount cannot be propagated safely across different assets. 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.
36
- - **Pre-existing balances are intentionally not swept into refunds.** `_handleSwap` snapshots a per-route refund baseline and only refunds the leftover input balance attributable to the current route. Tokens or ETH that were already sitting in the router before the route began are excluded from that refund calculation. This avoids gifting prior stray balances to the next caller, but it also means accidentally sent balances can remain stranded because the router has no sweep mechanism.
37
- - **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.
38
- - **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.
39
- - **Heuristic route selection, not best execution.** Automatic routing chooses among discovered paths using bounded heuristics, and pool discovery prefers the deepest discovered pool rather than exhaustively quoting every viable pool. This is an intentional tradeoff to keep routing predictable and gas-bounded. Integrators should treat router-selected execution as best-effort convenience, not a guarantee of the globally best obtainable output. When execution quality matters more than convenience, frontends should supply `quoteForSwap` metadata.
21
+ - **Uniswap V3 factory and V4 PoolManager behave correctly.** Pool discovery trusts those external systems to point at real pools.
22
+ - **Canonical V4 hook configuration is correct.** If deployers set the wrong `UNIV4_HOOK`, the router can miss intended hooked pools.
23
+ - **The trusted forwarder is trustworthy.** A compromised forwarder can initiate transfers on behalf of any user.
24
+ - **`JBDirectory` resolves the right terminals.** A compromised directory can redirect funds.
25
+ - **Permit2 allowances are intentional.** Stale approvals can be abused.
26
+ - **The registry owner acts correctly.** The owner controls allowlisting and the default router terminal.
27
+ - **`IJBPayerTracker` callers are trusted to name their own refund recipient.** A caller implementing `originalPayer()` can redirect leftovers from its own route.
28
+
29
+ ## 2. Economic And Manipulation Risks
30
+
31
+ - **V4 price manipulation.** `_getV4Quote` tries a 30-second TWAP first. If that fails, it falls back to spot pricing, which is manipulable within the block.
32
+ - **Hooked V4 discovery scope.** Auto-discovery checks both vanilla V4 pools and pools using the configured canonical `UNIV4_HOOK`.
33
+ - **V3 TWAP manipulation.** Short history reduces manipulation resistance, especially in new pools.
34
+ - **Cash-out loop value extraction.** `_cashOutLoop` is capped at 20 iterations. `cashOutMinReclaimed` only applies on the first real cash-out hop.
35
+ - **Pre-existing balances are intentionally excluded from route refunds.** Stray balances already sitting in the router are not swept into the next caller's refund.
36
+ - **V4 native ETH settlement is special-cased.** `_settleV4` unwraps WETH when the pool manager expects native ETH.
37
+ - **Pool selection is liquidity-first.** `_discoverPool` picks the deepest discovered pool, not the globally best execution path.
38
+ - **Route selection is heuristic, not best execution.** The router bounds discovery for predictability and gas, not exhaustive optimization.
40
39
 
41
40
  ## 3. Access Control
42
41
 
43
- - **No access control on `pay` / `addToBalanceOf`.** Anyone can route payments. This is by design but means the contract processes arbitrary token types and amounts.
44
- - **Lossy terminal-facing ERC-20s unsupported on the router.** Ingress into the router is balance-delta reconciled, and the router's final forwarded ERC-20 hop is enforced by checking that the destination terminal actually received the full nominal ERC-20 amount. Fee-on-transfer or otherwise lossy terminal-facing ERC-20 pulls revert on the router. The registry does not perform receipt enforcement because it always forwards to the router terminal, which never retains tokens.
45
- - **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.
46
- - **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.
47
- - **Registry terminal selection can permanently snapshot a bad terminal.** `lockTerminalFor` is intentionally a one-way commitment to the project's currently resolved terminal. If governance or a project owner selects a terminal that later proves unusable, hostile, or simply misconfigured, locking that choice can permanently brick registry-mediated routing for that project until governance and deployment topology change around it. This is not unique to the registry itself pointing at itself; it is true for any bad terminal choice, so terminal selection and locking should be treated as a high-trust operational action.
48
- - **Synthetic accounting contexts.** `JBRouterTerminal.accountingContextForTokenOf()` uses best-effort decimals for
49
- routing discovery: native tokens use `18`, ERC-20s probe `IERC20Metadata.decimals()` when available, and broken or
50
- non-standard tokens fall back to `18`. `JBRouterTerminalRegistry` simply forwards that context. This is safe for
51
- routing discovery but unsafe for integrations that treat the router or registry as a truthful accounting source for
52
- non-18-decimal assets. Lending and debt-normalization flows must point at a real terminal, not the router layer.
53
- 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`.
42
+ - **`pay` and `addToBalanceOf` are permissionless.** Anyone can route payments.
43
+ - **Lossy terminal-facing ERC-20s are unsupported.** Final forwarded ERC-20 hops must settle exactly on the router path.
44
+ - **Credit cash-out path depends on `TRANSFER_CREDITS`.** Broad grants of that permission widen the attack surface.
45
+ - **The registry owner controls allowlisting and the global default.** Disallowing the current default now reverts instead of silently clearing it.
46
+ - **Registry terminal locking can freeze a bad choice.** `lockTerminalFor` is a one-way commitment.
47
+ - **Router accounting contexts are synthetic.** They are safe for discovery, but unsafe as accounting truth for lending, debt, or balance normalization.
54
48
 
55
49
  ## 4. DoS Vectors
56
50
 
57
- - **No pool exists.** If no V3 or V4 pool exists for a token pair, `_discoverPool` returns empty and the swap reverts. Projects accepting uncommon tokens may become unpayable through this terminal.
58
- - **No observation history.** V3 pools without TWAP observations cause `_getV3TwapQuote` to revert with `NoObservationHistory`. This blocks automatic routing.
59
- - **Cashout loop limit.** Circular or deep token dependency chains hit `_MAX_CASHOUT_ITERATIONS = 20` and revert.
60
- - **Pool with zero liquidity.** Pools with zero in-range liquidity cause reverts.
61
- - **External terminal reverts.** The final `destTerminal.pay()` or `destTerminal.addToBalanceOf()` call is not wrapped in try-catch. A reverting destination terminal blocks the entire payment.
62
- - **Non-standard final ERC-20 transfer behavior.** Terminal-facing ERC-20 hops are enforced to settle exactly on the router path. Tokens that burn, tax, or otherwise reduce the amount actually received by the destination terminal will revert on the final forwarded hop. The registry does not independently enforce receipt checks; it relies on the router to reject lossy transfers.
51
+ - **No pool exists.** If no V3 or V4 pool exists for a token pair, automatic swap routing fails.
52
+ - **No observation history.** V3 TWAP quoting can revert on fresh pools.
53
+ - **Cash-out loop limit.** Circular or deep token dependency chains hit `_MAX_CASHOUT_ITERATIONS = 20` and revert.
54
+ - **Zero-liquidity pools.** Pools with no usable liquidity revert.
55
+ - **External terminal reverts.** Final terminal calls are not wrapped in `try/catch`.
56
+ - **Non-standard final ERC-20 transfer behavior.** Lossy terminal-facing tokens revert on the final forwarded hop.
63
57
 
64
58
  ## 5. Integration Risks
65
59
 
66
- - **Registry default terminal change.** Projects without explicit terminal assignments use `defaultTerminal`. If the registry owner changes the default, all unlocked projects are silently migrated. `lockTerminalFor` mitigates this.
67
- - **Locked bad-terminal risk.** `lockTerminalFor` protects projects from silent migrations, but it also freezes the current resolved terminal exactly as-is. If the locked terminal is malformed, recursively forwards, reverts on use, or otherwise cannot actually process routed payments, the project can be left permanently unroutable through the registry.
68
- - **forceApprove for terminal transfers.** `_beforeTransferFor` uses `forceApprove`, which resets the allowance to zero before setting the new value. This mitigates stale-allowance accumulation: even if a previous transaction left a non-zero allowance, `forceApprove` overwrites it rather than adding to it. The reset-then-set pattern prevents cumulative allowance drift.
69
- - **Callback data trust.** `uniswapV3SwapCallback` validates the caller by reconstructing the pool address from `(tokenIn, tokenOut, fee)`. The factory `getPool` lookup makes spoofing infeasible in practice.
70
- - **receive() function.** The contract accepts arbitrary ETH via `receive()`. This is necessary for WETH unwraps, cashout reclaims, and V4 PoolManager takes. Route-specific leftover refunds only return ETH attributable to the active route; arbitrary ETH already sitting in the router before that route is not swept out automatically and can remain stranded.
60
+ - **Registry default terminal changes affect unlocked projects.** Projects without explicit assignments follow `defaultTerminal`.
61
+ - **Locked bad-terminal risk remains.** Locking protects against silent migration, but also freezes any mistake.
62
+ - **`forceApprove` is used for terminal transfers.** This resets allowance before setting a new value and avoids stale-allowance accumulation.
63
+ - **Callback data trust matters.** `uniswapV3SwapCallback` validates the pool by reconstructing its address from the expected parameters.
64
+ - **The contract accepts arbitrary ETH.** That is necessary for unwraps and V4 settlement, but stray ETH can remain stranded.
71
65
 
72
66
  ## 6. MEV Surface
73
67
 
74
- - **V3 path: TWAP-protected.** The 10-minute TWAP oracle makes single-block manipulation futile. Multi-block attacks require sustained capital.
75
- - **V4 path: TWAP-first, spot-fallback.** V4 pools attempt a 30-second TWAP via the oracle hook first. If unavailable, the spot fallback is vulnerable. Without `quoteForSwap` metadata, V4 swaps using the spot fallback are exposed to up to sigmoid-slippage% loss per trade. The 2% floor bounds worst-case extraction.
76
- - **Cross-route arbitrage.** When JB routing bypasses the AMM (minting tokens directly), an arbitrage opportunity exists between the JB bonding curve price and the AMM price.
68
+ - **V3 path is TWAP-protected.** A 10-minute TWAP makes single-block manipulation much harder.
69
+ - **V4 path is TWAP-first, spot-fallback.** When no oracle quote is available, the spot fallback is vulnerable.
70
+ - **Cross-route arbitrage exists.** When JB routing bypasses the AMM, differences between bonding-curve price and AMM price create arbitrage opportunities.
77
71
 
78
- ## 7. Invariants to Verify
72
+ ## 7. Invariants To Verify
79
73
 
80
- - After any `pay()` or `addToBalanceOf()`, the contract should not retain balances attributable to the just-processed route. Pre-existing stray balances may still remain because refund logic intentionally excludes them.
81
- - `minAmountOut` in swaps is never zero when TWAP/spot price is available (the sigmoid formula has a 2% floor).
82
- - Callback validation: `uniswapV3SwapCallback` only transfers tokens to verified pool addresses.
83
- - `unlockCallback` only executes when called by `POOL_MANAGER`.
84
- - Credit cashout path: credits are transferred FROM `_msgSender()` only, never from arbitrary addresses.
85
- - Cashout loop terminates: either finds a terminal, reverts with `CashOutLoopLimit`, or reverts with `NoCashOutPath`.
74
+ - after any `pay()` or `addToBalanceOf()`, the router should not retain balances attributable to the just-processed route
75
+ - `minAmountOut` in swaps is never zero when TWAP or spot price is available
76
+ - `uniswapV3SwapCallback` only transfers tokens to verified pool addresses
77
+ - `unlockCallback` only executes when called by `POOL_MANAGER`
78
+ - credit cash-out only transfers credits from `_msgSender()`
79
+ - the cash-out loop always terminates: it finds a terminal, hits the loop limit, or reverts for lack of path
86
80
 
87
81
  ## 8. Accepted Behaviors
88
82
 
89
- ### 8.1 No reentrancy guard (stateless routing)
90
-
91
- The router terminal has no `ReentrancyGuard` or `_routing` flag. This is a conscious design choice, not an oversight. During `_cashOutLoop`, the cashout terminal's callback could re-enter `pay()` or `addToBalanceOf()` on this router. This is safe because:
83
+ ### 8.1 No reentrancy guard
92
84
 
93
- - **The router is stateless.** It does not maintain mutable accounting between `_route()` and the final `destTerminal.pay/addToBalanceOf`. Each call independently accepts funds, routes them, and forwards the result. There is no shared mutable state that a re-entrant call could corrupt no balances, no counters, no flags. The only storage is immutable configuration (DIRECTORY, TOKENS, WETH, etc.).
94
- - **CEI ordering is maintained within each call.** The execution flow follows a strict pipeline: (1) `_acceptFundsFor` pulls funds from the caller, (2) `_route` computes the destination and converts tokens if needed, (3) `_beforeTransferFor` + `destTerminal.pay/addToBalanceOf` forwards the result. Because there is no mutable state written between steps (1) and (3), there is nothing for a re-entrant call to read-before-write or corrupt. The "checks" and "effects" are collapsed into stateless computation, and the "interaction" (the final forwarding call) operates only on local variables.
95
- - **Each call uses its own funds.** The re-entrant call would need to supply its own tokens/ETH (via `_acceptFundsFor`). It cannot consume funds belonging to the outer call because those funds are already committed to the routing pipeline.
96
- - **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.
85
+ The router has no `ReentrancyGuard` or `_routing` flag. This is intentional because the router is designed as a stateless routing layer, not a persistent accounting surface. Each call must fund and resolve its own route, and a blanket reentrancy guard would break legitimate composed routing flows.
97
86
 
98
- 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`) confirms that routed operations do not strand balances attributable to the active route, including operations that exercise the cashout recursion loop (`_cashOutLoop`, up to `_MAX_CASHOUT_ITERATIONS = 20`). That does not imply the router can never hold pre-existing stray balances, because refund logic intentionally excludes balances that were already present before the route began.
87
+ ### 8.2 Router trusts `originalPayer()` from any caller that implements it
99
88
 
100
- ### 8.2 Router trusts `originalPayer()` from any `msg.sender` that implements it
89
+ `_resolveRefundWithBackupRecipient` calls `IJBPayerTracker(msg.sender).originalPayer()` in a `try/catch`. If the caller returns a non-zero address, leftovers can be sent there. This is accepted because the caller already supplied the funds being routed.
101
90
 
102
- `_resolveRefundWithBackupRecipient` 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.
103
-
104
- ### 8.5 Registry owns immediate circular-forward protection
91
+ ### 8.3 Cash-out loop slippage is first-hop only
105
92
 
106
- The router and resolver no longer contain registry-specific circular-resolution logic. Instead, `JBRouterTerminalRegistry` rejects forwarding back into its immediate caller. This preserves separation of concerns: the router only rejects direct self-routes, while the registry owns protection against `router -> registry -> same router` recursion.
93
+ `_cashOutLoop` applies `cashOutMinReclaimed` to the first cash-out step only. Later recursive hops may reclaim different assets with different units, so reusing one minimum across the full loop would be unsound.
107
94
 
108
- ### 8.3 Cashout loop slippage is first-hop only
95
+ ### 8.4 Liquidity-first pool selection is intentional
109
96
 
110
- `_cashOutLoop` applies `cashOutMinReclaimed` to the first cashout step only. Subsequent recursive cashouts (steps 2-20) do not carry that minimum forward. This is intentional: later hops may reclaim different assets with different units and decimals, so rescaling a single metadata amount across the remaining path would be unit-unsound. Users who need tighter protection on the final routed output should still rely on the destination terminal's `minReturnedTokens` parameter and swap-level quote controls such as `quoteForSwap`.
97
+ The router does not do an exhaustive best-execution search across every viable V3 and V4 pool. It prefers bounded discovery, lower complexity, and predictable behavior.
111
98
 
112
- ### 8.4 Liquidity-first pool selection is intentional
99
+ ### 8.5 Registry owns immediate circular-forward protection
113
100
 
114
- The router does not attempt full best-execution search across every viable V3 and V4 pool. `_discoverPool` prefers the deepest discovered pool, and the selected pool is then quoted and executed. This can underperform an alternative pool in some market states, but it is an accepted product tradeoff: bounded route discovery, lower complexity, and predictable behavior are prioritized over exhaustive output maximization. Users who need tighter execution guarantees should provide `quoteForSwap` metadata or route externally.
101
+ The router and resolver no longer contain registry-specific circular-resolution logic. `JBRouterTerminalRegistry` rejects forwarding back into its immediate caller instead.
115
102
 
116
103
  ### 8.6 V4 spot fallback is an accepted risk for programmatic integrations
117
104
 
118
- Automatic V4 quoting first tries a hook-provided oracle observation and only falls back to spot when no such quote is available. That fallback remains manipulable within the block and is not equivalent to an external quote or a trusted TWAP. It is nevertheless accepted in this terminal because some programmatic integrations cannot provide `quoteForSwap` metadata and still need a bounded on-chain quoting path. The intended operating envelope is routine routing through sufficiently deep pools with moderate swap sizes, where manipulation is possible in principle but expected to be uneconomic in practice. This is a risk reduction argument, not a safety proof: deep liquidity raises attack cost, but does not remove the vulnerability class.
105
+ Automatic V4 quoting first tries a hook-provided oracle observation and falls back to spot only when that quote is unavailable. The fallback is still manipulable and is accepted only as a bounded on-chain quoting path for integrations that cannot provide `quoteForSwap` metadata.
package/SKILLS.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  ## Use This File For
4
4
 
5
- - Use this file when the task involves routed payments or cash-outs, swap-path metadata, dynamic accepted-token discovery, route-registry selection, or router-terminal fee and slippage behavior.
6
- - Start here, then decide whether the problem is route discovery, swap execution, cash-out recursion, or registry selection. Those are distinct failure modes in this repo.
5
+ - Use this file when the task involves routed payments or cash outs, swap-path metadata, dynamic accepted-token discovery, registry selection, or router slippage and refund behavior.
6
+ - Start here, then decide whether the problem is route discovery, swap execution, cash-out recursion, or registry selection.
7
7
 
8
8
  ## Read This Next
9
9
 
@@ -32,17 +32,15 @@ Universal routing terminal for Juicebox V6. This repo accepts many input tokens,
32
32
 
33
33
  ## Reference Files
34
34
 
35
- - Open [`references/runtime.md`](./references/runtime.md) when you need the route-selection flow, cash-out loop behavior, callback and swap semantics, or the main invariants.
36
- - Open [`references/operations.md`](./references/operations.md) when you need registry and permission behavior, metadata keys, test breadcrumbs, or the main failure modes that cause stale assumptions.
35
+ - Open [`references/runtime.md`](./references/runtime.md) when you need route-selection flow, cash-out loop behavior, callback and swap semantics, or main invariants.
36
+ - Open [`references/operations.md`](./references/operations.md) when you need registry and permission behavior, metadata keys, test breadcrumbs, or common stale assumptions.
37
37
 
38
38
  ## Working Rules
39
39
 
40
40
  - Start in [`src/JBRouterTerminal.sol`](./src/JBRouterTerminal.sol) for execution behavior, but verify downstream semantics in the destination terminal before treating the router as the source of truth.
41
- - The router intentionally synthesizes accounting contexts instead of storing a static accepted-token list. If token acceptance looks wrong, verify discovery logic before touching registry state.
42
- - Treat preview behavior, quote selection, and execution callbacks as tightly coupled. Changes in one usually need verification in the others.
43
- - When the input token is itself a Juicebox project token, follow the cash-out loop carefully. Recursive routing assumptions are where subtle bugs hide.
44
- - Multi-hop and buyback-assisted routes are first-class behavior here, not edge cases. Verify them explicitly when changing route selection.
45
- - Refund handling is route-specific state, not cleanup garnish. Baseline snapshots and partial-fill leftovers are part of correctness.
46
- - Final terminal-facing receipt enforcement is a real boundary. If a terminal pull or forwarding model is non-standard, prove receipt semantics still hold before weakening guards.
47
- - Callback guards and final-hop receipt checks are security boundaries. Do not weaken them to accommodate non-standard token paths.
48
- - If you touch registry behavior, verify project-specific overrides, allowlisting, and terminal locking all still match the intended governance model.
41
+ - The router intentionally synthesizes accounting contexts instead of storing a static accepted-token list.
42
+ - Treat preview behavior, quote selection, and execution callbacks as tightly coupled.
43
+ - When the input token is itself a Juicebox project token, follow the cash-out loop carefully.
44
+ - Refund handling is part of correctness.
45
+ - Final terminal-facing receipt enforcement and callback guards are real security boundaries.
46
+ - If you touch registry behavior, verify project-specific overrides, allowlisting, and terminal locking together.
package/USER_JOURNEYS.md CHANGED
@@ -2,9 +2,7 @@
2
2
 
3
3
  ## Repo Purpose
4
4
 
5
- This repo is the project-facing payment router for "user has X, project wants Y."
6
- It owns route discovery, preview behavior, registry-level router choice, and special handling for project-token and
7
- credit-based sources. It does not replace the downstream terminal that finally receives and accounts for value.
5
+ This repo is the project-facing payment router for "user has X, project wants Y." It owns route discovery, preview behavior, registry-level router choice, and special handling for project-token and credit-based sources. It does not replace the downstream terminal that finally receives and accounts for value.
8
6
 
9
7
  ## Primary Actors
10
8
 
@@ -60,12 +58,12 @@ credit-based sources. It does not replace the downstream terminal that finally r
60
58
  **Failure Modes**
61
59
  - quotes are stale or liquidity moved before execution
62
60
  - permit, allowance, or refund handling breaks mid-route
63
- - metadata is valid for the destination terminal but not for route discovery assumptions
61
+ - metadata is valid for the destination terminal but not for route-discovery assumptions
64
62
 
65
63
  **Postconditions**
66
64
  - the router converts the user's asset into the terminal's accepted asset and forwards the payment
67
65
 
68
- ## Journey 3: Pay With A Juicebox Project Token Instead Of An External Asset
66
+ ## Journey 3: Pay With A Juicebox Project Token
69
67
 
70
68
  **Actor:** payer holding a project token.
71
69
 
@@ -86,7 +84,7 @@ credit-based sources. It does not replace the downstream terminal that finally r
86
84
  **Postconditions**
87
85
  - the router handles the recursive path correctly instead of assuming the input is a normal ERC-20
88
86
 
89
- ## Journey 4: Route A Payment From Credits Or A Cash-Out Source
87
+ ## Journey 4: Route A Payment From Credits Or Another Cash-Out Source
90
88
 
91
89
  **Actor:** payer or integration using a non-wallet source position.
92
90
 
@@ -98,7 +96,7 @@ credit-based sources. It does not replace the downstream terminal that finally r
98
96
  **Main Flow**
99
97
  1. Encode the `cashOutSource` or equivalent metadata the router expects.
100
98
  2. Let the router pull credits or source-project value through the token and terminal surfaces it integrates with.
101
- 3. Continue through the route-discovery process only after the source value has been converted into something the destination path can use.
99
+ 3. Continue through route discovery only after the source value has been converted into something the destination path can use.
102
100
 
103
101
  **Failure Modes**
104
102
  - `msg.value` is sent alongside credit-based routing and the route shape is wrong
@@ -124,7 +122,7 @@ credit-based sources. It does not replace the downstream terminal that finally r
124
122
 
125
123
  **Failure Modes**
126
124
  - preview assumptions become stale between quote and execution
127
- - the frontend surfaces a route as deterministic when the final path still depends on live market state
125
+ - the frontend presents a route as deterministic when the final path still depends on live market state
128
126
 
129
127
  **Postconditions**
130
128
  - the quote is useful, and execution either lands close to it or fails clearly when conditions changed too much
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/router-terminal-v6",
3
- "version": "0.0.27",
3
+ "version": "0.0.28",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -68,8 +68,13 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
68
68
  JBAccountingContext[][] memory allContexts = new JBAccountingContext[][](terminals.length);
69
69
  uint256 totalContexts;
70
70
  for (uint256 i; i < terminals.length;) {
71
- allContexts[i] = terminals[i].accountingContextsOf(projectId);
72
- totalContexts += allContexts[i].length;
71
+ // Wrap in try/catch so a single reverting terminal does not DoS the entire route enumeration.
72
+ try terminals[i].accountingContextsOf(projectId) returns (JBAccountingContext[] memory ctx) {
73
+ allContexts[i] = ctx;
74
+ totalContexts += ctx.length;
75
+ } catch {
76
+ // Skip terminals that revert — allContexts[i] remains an empty array.
77
+ }
73
78
  unchecked {
74
79
  ++i;
75
80
  }
@@ -165,7 +170,17 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
165
170
 
166
171
  for (uint256 i; i < terminals.length;) {
167
172
  // Read each terminal's accepted accounting contexts so the scorer can inspect every candidate token.
168
- JBAccountingContext[] memory contexts = terminals[i].accountingContextsOf(projectId);
173
+ // Wrap in try/catch so a single reverting terminal does not DoS the entire route discovery.
174
+ JBAccountingContext[] memory contexts;
175
+ try terminals[i].accountingContextsOf(projectId) returns (JBAccountingContext[] memory ctx) {
176
+ contexts = ctx;
177
+ } catch {
178
+ // Skip terminals that revert.
179
+ unchecked {
180
+ ++i;
181
+ }
182
+ continue;
183
+ }
169
184
 
170
185
  for (uint256 j; j < contexts.length;) {
171
186
  // Pull the candidate token out of the accounting context being inspected.
@@ -0,0 +1,463 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
8
+ import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
9
+ import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
10
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
11
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
12
+ import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
13
+
14
+ import {JBPayRouteResolver} from "../../src/JBPayRouteResolver.sol";
15
+ import {IJBPayRoutePreviewer} from "../../src/interfaces/IJBPayRoutePreviewer.sol";
16
+ import {IWETH9} from "../../src/interfaces/IWETH9.sol";
17
+
18
+ // ──────────────────────────────────────────────────────────────────────────────
19
+ // Mock: Terminal that returns accounting contexts normally.
20
+ // ──────────────────────────────────────────────────────────────────────────────
21
+
22
+ contract GoodTerminal {
23
+ address public immutable ACCEPTED_TOKEN;
24
+
25
+ constructor(address token_) {
26
+ ACCEPTED_TOKEN = token_;
27
+ }
28
+
29
+ function accountingContextsOf(uint256) external view returns (JBAccountingContext[] memory contexts) {
30
+ contexts = new JBAccountingContext[](1);
31
+ // forge-lint: disable-next-line(unsafe-typecast)
32
+ contexts[0] =
33
+ JBAccountingContext({token: ACCEPTED_TOKEN, decimals: 18, currency: uint32(uint160(ACCEPTED_TOKEN))});
34
+ }
35
+
36
+ function accountingContextForTokenOf(uint256, address token) external pure returns (JBAccountingContext memory) {
37
+ // forge-lint: disable-next-line(unsafe-typecast)
38
+ return JBAccountingContext({token: token, decimals: 18, currency: uint32(uint160(token))});
39
+ }
40
+
41
+ function previewPayFor(
42
+ uint256,
43
+ address,
44
+ uint256 amount,
45
+ address,
46
+ bytes calldata
47
+ )
48
+ external
49
+ pure
50
+ returns (
51
+ JBRuleset memory ruleset,
52
+ uint256 beneficiaryTokenCount,
53
+ uint256 reservedTokenCount,
54
+ JBPayHookSpecification[] memory hookSpecifications
55
+ )
56
+ {
57
+ ruleset = JBRuleset({
58
+ cycleNumber: 1,
59
+ id: 1,
60
+ basedOnId: 0,
61
+ start: 0,
62
+ duration: 0,
63
+ weight: 0,
64
+ weightCutPercent: 0,
65
+ approvalHook: IJBRulesetApprovalHook(address(0)),
66
+ metadata: 0
67
+ });
68
+ beneficiaryTokenCount = amount;
69
+ reservedTokenCount = 0;
70
+ hookSpecifications = new JBPayHookSpecification[](0);
71
+ }
72
+
73
+ function supportsInterface(bytes4) external pure returns (bool) {
74
+ return true;
75
+ }
76
+ }
77
+
78
+ // ──────────────────────────────────────────────────────────────────────────────
79
+ // Mock: Terminal that always reverts on accountingContextsOf.
80
+ // ──────────────────────────────────────────────────────────────────────────────
81
+
82
+ contract RevertingTerminal {
83
+ function accountingContextsOf(uint256) external pure returns (JBAccountingContext[] memory) {
84
+ revert("RevertingTerminal: broken");
85
+ }
86
+
87
+ function accountingContextForTokenOf(uint256, address) external pure returns (JBAccountingContext memory) {
88
+ revert("RevertingTerminal: broken");
89
+ }
90
+
91
+ function supportsInterface(bytes4) external pure returns (bool) {
92
+ return true;
93
+ }
94
+ }
95
+
96
+ // ──────────────────────────────────────────────────────────────────────────────
97
+ // Mock: Minimal WETH for constructor.
98
+ // ──────────────────────────────────────────────────────────────────────────────
99
+
100
+ contract MockWETH {
101
+ mapping(address => uint256) public balanceOf;
102
+
103
+ function deposit() external payable {
104
+ balanceOf[msg.sender] += msg.value;
105
+ }
106
+
107
+ function withdraw(uint256 amount) external {
108
+ balanceOf[msg.sender] -= amount;
109
+ payable(msg.sender).transfer(amount);
110
+ }
111
+
112
+ receive() external payable {}
113
+ }
114
+
115
+ // ──────────────────────────────────────────────────────────────────────────────
116
+ // Test contract
117
+ // ──────────────────────────────────────────────────────────────────────────────
118
+
119
+ contract RevertingTerminalRouteDiscoveryTest is Test {
120
+ JBPayRouteResolver internal resolver;
121
+ IJBDirectory internal directory;
122
+ IWETH9 internal weth;
123
+ IJBPayRoutePreviewer internal router;
124
+
125
+ uint256 internal constant PROJECT_ID = 42;
126
+ address internal tokenA;
127
+ address internal tokenB;
128
+ address internal tokenIn;
129
+
130
+ function setUp() public {
131
+ directory = IJBDirectory(makeAddr("directory"));
132
+ weth = IWETH9(address(new MockWETH()));
133
+ router = IJBPayRoutePreviewer(makeAddr("router"));
134
+
135
+ vm.etch(address(directory), hex"00");
136
+ vm.etch(address(router), hex"00");
137
+
138
+ resolver = new JBPayRouteResolver({directory: directory, weth: weth});
139
+
140
+ // Create distinct token addresses for testing.
141
+ tokenA = makeAddr("tokenA");
142
+ tokenB = makeAddr("tokenB");
143
+ tokenIn = makeAddr("tokenIn");
144
+ }
145
+
146
+ // ─────────────────────────────────────────────────────────────────────────
147
+ // Test 1: Route discovery succeeds when all terminals respond normally.
148
+ // ─────────────────────────────────────────────────────────────────────────
149
+
150
+ function test_resolveTokenOut_allTerminalsHealthy() public {
151
+ GoodTerminal goodTerminal = new GoodTerminal(tokenA);
152
+
153
+ IJBTerminal[] memory terminals = new IJBTerminal[](1);
154
+ terminals[0] = IJBTerminal(address(goodTerminal));
155
+
156
+ // Mock directory.terminalsOf -> returns the good terminal.
157
+ vm.mockCall(address(directory), abi.encodeCall(IJBDirectory.terminalsOf, (PROJECT_ID)), abi.encode(terminals));
158
+
159
+ // Mock directory.primaryTerminalOf for tokenIn -> address(0) (not directly accepted).
160
+ vm.mockCall(
161
+ address(directory),
162
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, tokenIn)),
163
+ abi.encode(address(0))
164
+ );
165
+
166
+ // Mock directory.primaryTerminalOf for tokenA -> goodTerminal.
167
+ vm.mockCall(
168
+ address(directory),
169
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, tokenA)),
170
+ abi.encode(address(goodTerminal))
171
+ );
172
+
173
+ // Mock router.WETH() -> our weth address.
174
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.WETH, ()), abi.encode(address(weth)));
175
+
176
+ // Mock router.TOKENS() -> return a mock that says tokenIn is not a project token.
177
+ address mockTokens = makeAddr("tokens");
178
+ vm.etch(address(mockTokens), hex"00");
179
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.TOKENS, ()), abi.encode(mockTokens));
180
+ vm.mockCall(mockTokens, abi.encodeWithSelector(IJBTokens.projectIdOf.selector), abi.encode(uint256(0)));
181
+
182
+ // Mock router.bestPoolLiquidityOf -> some liquidity for tokenIn/tokenA pair.
183
+ vm.mockCall(
184
+ address(router),
185
+ abi.encodeCall(IJBPayRoutePreviewer.bestPoolLiquidityOf, (tokenIn, tokenA)),
186
+ abi.encode(uint128(1_000_000))
187
+ );
188
+
189
+ // Mock WETH for native token check (no native terminal).
190
+ vm.mockCall(
191
+ address(directory),
192
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, address(weth))),
193
+ abi.encode(address(0))
194
+ );
195
+
196
+ (address resolvedTokenOut, IJBTerminal resolvedTerminal) =
197
+ resolver.resolveTokenOut({router: router, projectId: PROJECT_ID, tokenIn: tokenIn, metadata: ""});
198
+
199
+ assertEq(resolvedTokenOut, tokenA, "should resolve to tokenA from the healthy terminal");
200
+ assertEq(address(resolvedTerminal), address(goodTerminal), "should resolve to goodTerminal");
201
+ }
202
+
203
+ // ─────────────────────────────────────────────────────────────────────────
204
+ // Test 2: Route discovery still works when one terminal reverts -
205
+ // other terminals' tokens are still discovered.
206
+ // ─────────────────────────────────────────────────────────────────────────
207
+
208
+ function test_resolveTokenOut_revertingTerminalSkipped_otherTerminalDiscovered() public {
209
+ RevertingTerminal revertingTerminal = new RevertingTerminal();
210
+ GoodTerminal goodTerminal = new GoodTerminal(tokenA);
211
+
212
+ IJBTerminal[] memory terminals = new IJBTerminal[](2);
213
+ terminals[0] = IJBTerminal(address(revertingTerminal));
214
+ terminals[1] = IJBTerminal(address(goodTerminal));
215
+
216
+ // Mock directory.terminalsOf -> returns both terminals.
217
+ vm.mockCall(address(directory), abi.encodeCall(IJBDirectory.terminalsOf, (PROJECT_ID)), abi.encode(terminals));
218
+
219
+ // Mock directory.primaryTerminalOf for tokenIn -> address(0) (not directly accepted).
220
+ vm.mockCall(
221
+ address(directory),
222
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, tokenIn)),
223
+ abi.encode(address(0))
224
+ );
225
+
226
+ // Mock directory.primaryTerminalOf for tokenA -> goodTerminal.
227
+ vm.mockCall(
228
+ address(directory),
229
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, tokenA)),
230
+ abi.encode(address(goodTerminal))
231
+ );
232
+
233
+ // Mock router.WETH().
234
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.WETH, ()), abi.encode(address(weth)));
235
+
236
+ // Mock router.TOKENS().
237
+ address mockTokens = makeAddr("tokens");
238
+ vm.etch(address(mockTokens), hex"00");
239
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.TOKENS, ()), abi.encode(mockTokens));
240
+ vm.mockCall(mockTokens, abi.encodeWithSelector(IJBTokens.projectIdOf.selector), abi.encode(uint256(0)));
241
+
242
+ // Mock router.bestPoolLiquidityOf for tokenIn/tokenA pair.
243
+ vm.mockCall(
244
+ address(router),
245
+ abi.encodeCall(IJBPayRoutePreviewer.bestPoolLiquidityOf, (tokenIn, tokenA)),
246
+ abi.encode(uint128(500_000))
247
+ );
248
+
249
+ // Mock WETH for native token check.
250
+ vm.mockCall(
251
+ address(directory),
252
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, address(weth))),
253
+ abi.encode(address(0))
254
+ );
255
+
256
+ (address resolvedTokenOut, IJBTerminal resolvedTerminal) =
257
+ resolver.resolveTokenOut({router: router, projectId: PROJECT_ID, tokenIn: tokenIn, metadata: ""});
258
+
259
+ // The reverting terminal is skipped; the good terminal's token is discovered.
260
+ assertEq(resolvedTokenOut, tokenA, "should discover tokenA despite the reverting terminal");
261
+ assertEq(address(resolvedTerminal), address(goodTerminal), "should resolve to goodTerminal");
262
+ }
263
+
264
+ // ─────────────────────────────────────────────────────────────────────────
265
+ // Test 3: Route discovery returns empty when the only terminal reverts.
266
+ // _discoverAcceptedToken returns (address(0), address(0)) and
267
+ // resolveTokenOut reverts with JBRouterTerminal_NoRouteFound.
268
+ // ─────────────────────────────────────────────────────────────────────────
269
+
270
+ function test_resolveTokenOut_onlyTerminalReverts_noRouteFound() public {
271
+ RevertingTerminal revertingTerminal = new RevertingTerminal();
272
+
273
+ IJBTerminal[] memory terminals = new IJBTerminal[](1);
274
+ terminals[0] = IJBTerminal(address(revertingTerminal));
275
+
276
+ // Mock directory.terminalsOf -> returns the single reverting terminal.
277
+ vm.mockCall(address(directory), abi.encodeCall(IJBDirectory.terminalsOf, (PROJECT_ID)), abi.encode(terminals));
278
+
279
+ // Mock directory.primaryTerminalOf for tokenIn -> address(0).
280
+ vm.mockCall(
281
+ address(directory),
282
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, tokenIn)),
283
+ abi.encode(address(0))
284
+ );
285
+
286
+ // Mock router.WETH().
287
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.WETH, ()), abi.encode(address(weth)));
288
+
289
+ // Mock router.TOKENS().
290
+ address mockTokens = makeAddr("tokens");
291
+ vm.etch(address(mockTokens), hex"00");
292
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.TOKENS, ()), abi.encode(mockTokens));
293
+ vm.mockCall(mockTokens, abi.encodeWithSelector(IJBTokens.projectIdOf.selector), abi.encode(uint256(0)));
294
+
295
+ // Mock WETH for native token check.
296
+ vm.mockCall(
297
+ address(directory),
298
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, address(weth))),
299
+ abi.encode(address(0))
300
+ );
301
+
302
+ // Should revert because the only terminal reverts, leaving no discovered route.
303
+ vm.expectRevert();
304
+ resolver.resolveTokenOut({router: router, projectId: PROJECT_ID, tokenIn: tokenIn, metadata: ""});
305
+ }
306
+
307
+ // ─────────────────────────────────────────────────────────────────────────
308
+ // Test 4: previewBestPayRoute works when one terminal reverts and another
309
+ // responds. Exercises _candidatePayRouteTokens.
310
+ // ─────────────────────────────────────────────────────────────────────────
311
+
312
+ function test_previewBestPayRoute_revertingTerminalSkipped() public {
313
+ RevertingTerminal revertingTerminal = new RevertingTerminal();
314
+ GoodTerminal goodTerminal = new GoodTerminal(tokenA);
315
+
316
+ IJBTerminal[] memory terminals = new IJBTerminal[](2);
317
+ terminals[0] = IJBTerminal(address(revertingTerminal));
318
+ terminals[1] = IJBTerminal(address(goodTerminal));
319
+
320
+ // Mock directory.terminalsOf.
321
+ vm.mockCall(address(directory), abi.encodeCall(IJBDirectory.terminalsOf, (PROJECT_ID)), abi.encode(terminals));
322
+
323
+ // Mock directory.primaryTerminalOf for tokenIn -> address(0) (not directly accepted).
324
+ vm.mockCall(
325
+ address(directory),
326
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, tokenIn)),
327
+ abi.encode(address(0))
328
+ );
329
+
330
+ // Mock directory.primaryTerminalOf for tokenA -> goodTerminal.
331
+ vm.mockCall(
332
+ address(directory),
333
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, tokenA)),
334
+ abi.encode(address(goodTerminal))
335
+ );
336
+
337
+ // Mock router.WETH().
338
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.WETH, ()), abi.encode(address(weth)));
339
+
340
+ // Mock router.TOKENS() -> mock that says tokenIn is not a project token.
341
+ address mockTokens = makeAddr("tokens");
342
+ vm.etch(address(mockTokens), hex"00");
343
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.TOKENS, ()), abi.encode(mockTokens));
344
+ vm.mockCall(mockTokens, abi.encodeWithSelector(IJBTokens.projectIdOf.selector), abi.encode(uint256(0)));
345
+
346
+ // Mock router.BUYBACK_HOOK() -> address(0).
347
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.BUYBACK_HOOK, ()), abi.encode(address(0)));
348
+
349
+ // Mock router.bestPoolLiquidityOf -> 0 (no pool, triggers fallback route logic).
350
+ vm.mockCall(
351
+ address(router),
352
+ abi.encodeCall(IJBPayRoutePreviewer.bestPoolLiquidityOf, (tokenIn, tokenA)),
353
+ abi.encode(uint128(0))
354
+ );
355
+
356
+ // Mock WETH for native token check.
357
+ vm.mockCall(
358
+ address(directory),
359
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, address(weth))),
360
+ abi.encode(address(0))
361
+ );
362
+
363
+ // Mock router.previewSwapAmountOutOf -> returns amount (1:1 swap for simplicity).
364
+ vm.mockCall(
365
+ address(router),
366
+ abi.encodeWithSelector(IJBPayRoutePreviewer.previewSwapAmountOutOf.selector),
367
+ abi.encode(uint256(100 ether))
368
+ );
369
+
370
+ // Mock router.previewCashOutLoopOf -> returns no cashout (destTerminal=0, finalToken=tokenIn, amount
371
+ // unchanged).
372
+ vm.mockCall(
373
+ address(router),
374
+ abi.encodeWithSelector(IJBPayRoutePreviewer.previewCashOutLoopOf.selector),
375
+ abi.encode(IJBTerminal(address(0)), tokenIn, uint256(100 ether))
376
+ );
377
+
378
+ // Mock router.previewTerminalPayOf -> return a valid preview.
379
+ JBRuleset memory mockRuleset = JBRuleset({
380
+ cycleNumber: 1,
381
+ id: 1,
382
+ basedOnId: 0,
383
+ start: 0,
384
+ duration: 0,
385
+ weight: 0,
386
+ weightCutPercent: 0,
387
+ approvalHook: IJBRulesetApprovalHook(address(0)),
388
+ metadata: 0
389
+ });
390
+ JBPayHookSpecification[] memory emptyHooks = new JBPayHookSpecification[](0);
391
+ vm.mockCall(
392
+ address(router),
393
+ abi.encodeWithSelector(IJBPayRoutePreviewer.previewTerminalPayOf.selector),
394
+ abi.encode(mockRuleset, uint256(100 ether), uint256(0), emptyHooks)
395
+ );
396
+
397
+ // Call previewBestPayRoute — should succeed despite the reverting terminal.
398
+ (IJBTerminal destTerminal, address resolvedTokenOut,,, uint256 beneficiaryTokenCount,,) = resolver.previewBestPayRoute({
399
+ router: router,
400
+ projectId: PROJECT_ID,
401
+ tokenIn: tokenIn,
402
+ amount: 100 ether,
403
+ beneficiary: makeAddr("beneficiary"),
404
+ metadata: ""
405
+ });
406
+
407
+ assertEq(address(destTerminal), address(goodTerminal), "should route to goodTerminal");
408
+ assertEq(resolvedTokenOut, tokenA, "should route through tokenA");
409
+ assertGt(beneficiaryTokenCount, 0, "should produce beneficiary tokens");
410
+ }
411
+
412
+ // ─────────────────────────────────────────────────────────────────────────
413
+ // Test 5: previewBestPayRoute with only reverting terminals produces no
414
+ // route (reverts).
415
+ // ─────────────────────────────────────────────────────────────────────────
416
+
417
+ function test_previewBestPayRoute_allTerminalsRevert_noRoute() public {
418
+ RevertingTerminal revertingTerminal = new RevertingTerminal();
419
+
420
+ IJBTerminal[] memory terminals = new IJBTerminal[](1);
421
+ terminals[0] = IJBTerminal(address(revertingTerminal));
422
+
423
+ // Mock directory.terminalsOf.
424
+ vm.mockCall(address(directory), abi.encodeCall(IJBDirectory.terminalsOf, (PROJECT_ID)), abi.encode(terminals));
425
+
426
+ // Mock directory.primaryTerminalOf for tokenIn -> address(0).
427
+ vm.mockCall(
428
+ address(directory),
429
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, tokenIn)),
430
+ abi.encode(address(0))
431
+ );
432
+
433
+ // Mock router.WETH().
434
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.WETH, ()), abi.encode(address(weth)));
435
+
436
+ // Mock router.TOKENS().
437
+ address mockTokens = makeAddr("tokens");
438
+ vm.etch(address(mockTokens), hex"00");
439
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.TOKENS, ()), abi.encode(mockTokens));
440
+ vm.mockCall(mockTokens, abi.encodeWithSelector(IJBTokens.projectIdOf.selector), abi.encode(uint256(0)));
441
+
442
+ // Mock router.BUYBACK_HOOK().
443
+ vm.mockCall(address(router), abi.encodeCall(IJBPayRoutePreviewer.BUYBACK_HOOK, ()), abi.encode(address(0)));
444
+
445
+ // Mock WETH for native token check.
446
+ vm.mockCall(
447
+ address(directory),
448
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (PROJECT_ID, address(weth))),
449
+ abi.encode(address(0))
450
+ );
451
+
452
+ // Should revert since no candidates are found.
453
+ vm.expectRevert();
454
+ resolver.previewBestPayRoute({
455
+ router: router,
456
+ projectId: PROJECT_ID,
457
+ tokenIn: tokenIn,
458
+ amount: 100 ether,
459
+ beneficiary: makeAddr("beneficiary"),
460
+ metadata: ""
461
+ });
462
+ }
463
+ }