@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 +25 -25
- package/ARCHITECTURE.md +27 -26
- package/AUDIT_INSTRUCTIONS.md +13 -12
- package/README.md +11 -11
- package/RISKS.md +65 -78
- package/SKILLS.md +10 -12
- package/USER_JOURNEYS.md +6 -8
- package/package.json +1 -1
- package/src/JBPayRouteResolver.sol +18 -3
- package/test/audit/RevertingTerminalRouteDiscovery.t.sol +463 -0
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
|
|
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
|
-
-
|
|
20
|
-
- `JBRouterTerminal` has immutable routing dependencies and no owner-controlled strategy knobs
|
|
21
|
-
-
|
|
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
|
-
-
|
|
44
|
-
-
|
|
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
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
52
|
-
-
|
|
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
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
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
|
-
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
-
|
|
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
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
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,
|
|
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.
|
|
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
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
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
|
-
-
|
|
36
|
-
-
|
|
37
|
-
-
|
|
38
|
-
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
-
-
|
|
66
|
-
-
|
|
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
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
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
|
|
package/AUDIT_INSTRUCTIONS.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
|
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
|
|
64
|
-
- route discovery and route execution are related but not identical, especially when liquidity or
|
|
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
|
|
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
|
|
71
|
-
- per-project router choice and lock status
|
|
72
|
-
- accepted-token accounting and final balance changes
|
|
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
|
|
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
|
|
129
|
-
- final terminal-facing ERC-20 hops must be standard tokens; lossy terminal pulls are rejected
|
|
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
|
|
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
|
|
5
|
+
## How To Use This File
|
|
6
6
|
|
|
7
|
-
- Read `Priority risks` first
|
|
8
|
-
- Use the
|
|
9
|
-
- Treat `Accepted
|
|
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
|
|
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
|
|
16
|
-
| P1 | Wrong-route or low-liquidity execution | The router chooses among direct forwarding, V3, V4, and cash-out paths
|
|
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
|
|
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
|
|
23
|
-
- **Canonical V4 hook configuration
|
|
24
|
-
- **
|
|
25
|
-
-
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
28
|
-
- **`IJBPayerTracker`
|
|
29
|
-
|
|
30
|
-
## 2. Economic
|
|
31
|
-
|
|
32
|
-
- **V4 price manipulation.** `_getV4Quote`
|
|
33
|
-
- **Hooked V4 discovery scope.** Auto-discovery checks both vanilla V4 pools and pools using the configured canonical `UNIV4_HOOK`.
|
|
34
|
-
- **V3 TWAP manipulation.** Short
|
|
35
|
-
- **
|
|
36
|
-
- **Pre-existing balances are intentionally
|
|
37
|
-
- **V4 native ETH settlement.** `_settleV4` unwraps WETH
|
|
38
|
-
- **Pool selection
|
|
39
|
-
- **
|
|
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
|
-
-
|
|
44
|
-
- **Lossy terminal-facing ERC-20s unsupported
|
|
45
|
-
- **Credit
|
|
46
|
-
- **
|
|
47
|
-
- **Registry terminal
|
|
48
|
-
- **
|
|
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,
|
|
58
|
-
- **No observation history.** V3
|
|
59
|
-
- **
|
|
60
|
-
- **
|
|
61
|
-
- **External terminal reverts.**
|
|
62
|
-
- **Non-standard final ERC-20 transfer behavior.**
|
|
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
|
|
67
|
-
- **Locked bad-terminal risk.**
|
|
68
|
-
-
|
|
69
|
-
- **Callback data trust.** `uniswapV3SwapCallback` validates the
|
|
70
|
-
- **
|
|
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
|
|
75
|
-
- **V4 path
|
|
76
|
-
- **Cross-route arbitrage.** When JB routing bypasses the AMM
|
|
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
|
|
72
|
+
## 7. Invariants To Verify
|
|
79
73
|
|
|
80
|
-
-
|
|
81
|
-
- `minAmountOut` in swaps is never zero when TWAP
|
|
82
|
-
-
|
|
83
|
-
- `unlockCallback` only executes when called by `POOL_MANAGER
|
|
84
|
-
-
|
|
85
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
87
|
+
### 8.2 Router trusts `originalPayer()` from any caller that implements it
|
|
99
88
|
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
### 8.5 Registry owns immediate circular-forward protection
|
|
91
|
+
### 8.3 Cash-out loop slippage is first-hop only
|
|
105
92
|
|
|
106
|
-
|
|
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.
|
|
95
|
+
### 8.4 Liquidity-first pool selection is intentional
|
|
109
96
|
|
|
110
|
-
|
|
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.
|
|
99
|
+
### 8.5 Registry owns immediate circular-forward protection
|
|
113
100
|
|
|
114
|
-
The router
|
|
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
|
|
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
|
|
6
|
-
- Start here, then decide whether the problem is route discovery, swap execution, cash-out recursion, or registry selection.
|
|
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
|
|
36
|
-
- Open [`references/operations.md`](./references/operations.md) when you need registry and permission behavior, metadata keys, test breadcrumbs, or
|
|
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.
|
|
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
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
@@ -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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
+
}
|