@bananapus/router-terminal-v6 0.0.5 → 0.0.7

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # nana-router-terminal-v6
1
+ # Juicebox Router Terminal
2
2
 
3
- A Juicebox terminal that accepts payments in any token, dynamically discovers what token each destination project accepts, and routes the payment there via direct forwarding, Uniswap swap, JB token cashout, or a combination. Supports both Uniswap V3 and V4 pools, choosing whichever offers better liquidity.
3
+ A Juicebox terminal that accepts payments in any token, dynamically discovers what token each destination project accepts, and routes the payment there -- via direct forwarding, Uniswap swap, JB token cashout, or a combination. Supports both Uniswap V3 and V4 pools, choosing whichever offers better liquidity.
4
4
 
5
5
  _If you're having trouble understanding this contract, take a look at the [core protocol contracts](https://github.com/Bananapus/nana-core-v6) and the [documentation](https://docs.juicebox.money/) first. If you have questions, reach out on [Discord](https://discord.com/invite/ErQYmth4dS)._
6
6
 
@@ -8,17 +8,18 @@ _If you're having trouble understanding this contract, take a look at the [core
8
8
 
9
9
  | Contract | Description |
10
10
  |----------|-------------|
11
- | `JBRouterTerminal` | Core terminal. Accepts any token via `pay` or `addToBalanceOf`, discovers the destination project's accepted token, and routes there swapping through Uniswap V3 or V4 pools if needed. Uses TWAP oracle for automatic slippage protection when the caller doesn't provide a quote. |
12
- | `JBRouterTerminalRegistry` | A proxy terminal that delegates `pay` and `addToBalanceOf` to a per-project or default `JBRouterTerminal` instance. Allows project owners to choose (and lock) which router terminal implementation they use. |
11
+ | `JBRouterTerminal` | Core terminal. Accepts any token via `pay` or `addToBalanceOf`, discovers the destination project's accepted token, and routes there -- swapping through Uniswap V3 or V4 pools if needed, cashing out JB project tokens if the input is a project token, or forwarding directly if the token is already accepted. Uses TWAP oracle (V3) or spot price (V4) for automatic slippage protection when the caller does not provide a quote. Implements `IJBTerminal`, `IJBPermitTerminal`, `IUniswapV3SwapCallback`, and `IUnlockCallback`. |
12
+ | `JBRouterTerminalRegistry` | A proxy terminal that delegates `pay` and `addToBalanceOf` to a per-project or default `JBRouterTerminal` instance. Project owners can choose which router terminal they use, and optionally lock that choice permanently. Implements `IJBTerminal` via `IJBRouterTerminalRegistry`. |
13
13
 
14
14
  ## How It Works
15
15
 
16
16
  1. A payer calls `pay(projectId, token, amount, ...)` with any token.
17
- 2. The terminal accepts the token (supports ERC-20 approvals and Permit2).
18
- 3. It discovers the destination project's accepted token by querying the directory.
19
- 4. If the input token differs from the accepted token, it converts it by cashing out JB project tokens, swapping through the best Uniswap V3 or V4 pool across multiple fee tiers, or both.
20
- 5. Slippage protection: the caller can pass a minimum output quote in metadata (`quoteForSwap` key), or the terminal calculates one from the pool's TWAP oracle with dynamic slippage tolerance.
21
- 6. The output tokens are forwarded to the project's primary terminal via `terminal.pay(...)` or `terminal.addToBalanceOf(...)`.
17
+ 2. The terminal accepts the token (supports ERC-20 approvals, Permit2, and credit transfers).
18
+ 3. If the input is a JB project token (ERC-20 or credits), it recursively cashes out through the source project's terminals until reaching a base token.
19
+ 4. It resolves which token the destination project accepts, checking: metadata override (`routeTokenOut`), direct acceptance, NATIVE/WETH equivalence, then dynamic pool discovery across all terminals.
20
+ 5. If the resolved token differs from the input, it converts -- wrapping/unwrapping ETH/WETH, or swapping through the best Uniswap V3 or V4 pool.
21
+ 6. Slippage protection: the caller can pass a minimum output quote in metadata (`quoteForSwap` key), or the terminal calculates one using TWAP (V3) or spot price (V4) with a dynamic sigmoid slippage tolerance based on estimated price impact.
22
+ 7. The output tokens are forwarded to the project's primary terminal via `terminal.pay(...)` or `terminal.addToBalanceOf(...)`.
22
23
 
23
24
  ```mermaid
24
25
  sequenceDiagram
@@ -39,13 +40,22 @@ sequenceDiagram
39
40
 
40
41
  ### Routing Strategies
41
42
 
42
- The terminal can route payments via multiple strategies:
43
+ The terminal uses a multi-step routing algorithm:
43
44
 
44
- - **Direct forwarding** If the input token is already accepted by the destination terminal.
45
- - **Uniswap V3 swap** Through the highest-liquidity V3 pool across fee tiers (0.01%, 0.05%, 0.3%, 1%).
46
- - **Uniswap V4 swap** Through V4 pools, also searching multiple fee/tick-spacing configurations.
47
- - **JB token cashout** Redeeming JB project tokens to get the needed output token.
48
- - **Combination** Chaining strategies when no single route works.
45
+ 1. **JB token cashout** -- If the input is a JB project token (detected via `TOKENS.projectIdOf()` or the `cashOutSource` metadata key for credits), the terminal recursively cashes out through the source project's cashout terminals. At each step it prioritizes: tokens the destination directly accepts, then other JB tokens (recursable), then any base token.
46
+ 2. **Direct forwarding** -- If the (possibly post-cashout) token is already accepted by the destination terminal.
47
+ 3. **NATIVE/WETH equivalence** -- If the project accepts NATIVE_TOKEN and the input is WETH (or vice versa), wrap or unwrap.
48
+ 4. **Uniswap swap** -- Through the highest-liquidity pool across V3 fee tiers (0.3%, 0.05%, 1%, 0.01%) and V4 fee/tick-spacing pairs. V3 and V4 compete on liquidity; the deeper pool wins.
49
+ 5. **Combination** -- Chaining cashout + swap when no single route works.
50
+
51
+ ### Token Output Resolution Priority
52
+
53
+ When deciding what token to convert to, `_resolveTokenOut` follows this order:
54
+
55
+ 1. **Metadata override** -- Payer specifies `routeTokenOut` in metadata.
56
+ 2. **Direct acceptance** -- The destination project accepts `tokenIn` as-is.
57
+ 3. **NATIVE/WETH equivalence** -- The project accepts the wrapped/unwrapped form.
58
+ 4. **Dynamic discovery** -- Iterate all terminals and accounting contexts for the project, find the accepted token with the deepest Uniswap pool against `tokenIn`.
49
59
 
50
60
  ## Install
51
61
 
@@ -93,7 +103,9 @@ Key `foundry.toml` settings:
93
103
 
94
104
  - `solc = '0.8.26'`
95
105
  - `evm_version = 'cancun'` (required for Uniswap V4's transient storage)
96
- - `optimizer_runs = 100000000`
106
+ - `optimizer_runs = 200`
107
+ - `fuzz.runs = 4096`
108
+ - `invariant.runs = 1024`, `invariant.depth = 100`
97
109
 
98
110
  ## Repository Layout
99
111
 
@@ -104,42 +116,72 @@ nana-router-terminal-v6/
104
116
  │ ├── JBRouterTerminalRegistry.sol # Per-project terminal routing
105
117
  │ ├── interfaces/
106
118
  │ │ ├── IJBRouterTerminal.sol # Router terminal interface
107
- │ │ ├── IJBRouterTerminalRegistry.sol # Registry interface
119
+ │ │ ├── IJBRouterTerminalRegistry.sol # Registry interface (extends IJBTerminal)
108
120
  │ │ └── IWETH9.sol # WETH wrapper interface
109
121
  │ ├── libraries/
110
- │ │ └── JBSwapLib.sol # Swap math and pool discovery
122
+ │ │ └── JBSwapLib.sol # Slippage tolerance, impact, and price limit math
111
123
  │ └── structs/
112
- │ └── PoolInfo.sol # Pool metadata struct
124
+ │ └── PoolInfo.sol # V3/V4 pool metadata struct
113
125
  ├── script/
114
- │ ├── Deploy.s.sol # Deployment script
126
+ │ ├── Deploy.s.sol # Deployment script (multi-chain)
115
127
  │ └── helpers/
116
128
  │ └── RouterTerminalDeploymentLib.sol # Deployment address loader
117
129
  └── test/
118
- ├── RouterTerminal.t.sol # Terminal tests
119
- └── RouterTerminalRegistry.t.sol # Registry tests
130
+ ├── RouterTerminal.t.sol # Unit tests (mocked dependencies)
131
+ ├── RouterTerminalRegistry.t.sol # Registry unit tests
132
+ └── RouterTerminalFork.t.sol # Fork tests against mainnet Uniswap pools
120
133
  ```
121
134
 
122
135
  ## Payment Metadata
123
136
 
124
- The `JBRouterTerminal` accepts encoded `metadata` in its `pay(...)` function. Metadata is decoded using `JBMetadataResolver`:
137
+ The `JBRouterTerminal` accepts encoded `metadata` in its `pay(...)` and `addToBalanceOf(...)` functions. Metadata is decoded using `JBMetadataResolver` with string-based keys:
125
138
 
126
- ```solidity
127
- (bool exists, bytes memory quote) =
128
- JBMetadataResolver.getDataFor(JBMetadataResolver.getId("quoteForSwap"), metadata);
139
+ | Key | Type | Purpose |
140
+ |-----|------|---------|
141
+ | `"quoteForSwap"` | `uint256` | Minimum output amount from the swap. Overrides the automatic TWAP/spot-based quote. |
142
+ | `"permit2"` | `JBSingleAllowance` | Permit2 signature for gasless ERC-20 approvals. |
143
+ | `"routeTokenOut"` | `address` | Force the router to convert to a specific output token instead of auto-discovering. Reverts if the destination project does not accept it. |
144
+ | `"cashOutSource"` | `(uint256 sourceProjectId, uint256 creditAmount)` | Cash out credits from `sourceProjectId`. The payer must have granted `TRANSFER_CREDITS` permission (ID 13) to the router terminal. |
145
+ | `"cashOutMinReclaimed"` | `uint256` | Minimum tokens reclaimed from the first cashout step (slippage protection for cashouts). |
146
+
147
+ ### Quote Example
129
148
 
130
- if (exists) {
131
- (minAmountOut) = abi.decode(quote, (uint256));
132
- }
149
+ ```solidity
150
+ // Provide a minimum output quote in metadata
151
+ bytes memory metadata = JBMetadataResolver.addToMetadata({
152
+ originalMetadata: "",
153
+ id: JBMetadataResolver.getId("quoteForSwap"),
154
+ data: abi.encode(minAmountOut)
155
+ });
156
+
157
+ routerTerminal.pay(projectId, usdc, 1000e6, beneficiary, 0, "swap with quote", metadata);
133
158
  ```
134
159
 
135
- If no quote is provided, the terminal calculates one from the pool's TWAP oracle with a dynamic slippage tolerance based on the estimated price impact of the swap.
160
+ If no `quoteForSwap` is provided, the terminal calculates one automatically:
161
+ - **V3 pools**: TWAP oracle with a configurable window (default 10 minutes, capped by oldest observation). Reverts if no observation history exists.
162
+ - **V4 pools**: Spot tick price (V4 vanilla pools have no built-in TWAP oracle).
163
+ - **Both**: Dynamic sigmoid slippage tolerance based on estimated price impact and pool fee. Range: 2% minimum to 88% maximum ceiling.
164
+
165
+ ## Supported Chains
166
+
167
+ The deployment script supports:
168
+
169
+ | Chain | WETH | V3 Factory | V4 PoolManager |
170
+ |-------|------|------------|----------------|
171
+ | Ethereum Mainnet | `0xC02a...6Cc2` | `0x1F98...F984` | `0x0000...8A90` |
172
+ | Optimism | `0x4200...0006` | `0x1F98...F984` | `0x0000...8A90` |
173
+ | Base | `0x4200...0006` | `0x3312...FDfD` | `0x0000...8A90` |
174
+ | Arbitrum | `0x82aF...b1DD` | `0x1F98...F984` | `0x0000...8A90` |
175
+ | + Sepolia testnets for each | | | |
136
176
 
137
- The terminal also supports Permit2 metadata (key: `"permit2"`) for gasless token approvals.
177
+ Permit2 is deployed at `0x000000000022D473030F116dDEE9F6B43aC78BA3` on all chains.
138
178
 
139
179
  ## Risks
140
180
 
141
- - The terminal never holds a token balance. After every swap, all output tokens are forwarded and leftover input tokens are returned to the payer.
142
- - Pool discovery is dynamic the terminal searches V3 and V4 pools at runtime. If pool liquidity changes between discovery and execution, slippage protection prevents losses.
143
- - TWAP fallback: when no TWAP observations exist, the terminal falls back to the pool's current spot tick rather than reverting.
144
- - The `receive()` function only accepts ETH from the WETH contract (during unwrap). All other senders revert.
145
- - Uniswap V4 requires `cancun` EVM version (transient storage opcodes). This terminal will not work on chains without EIP-1153 support.
181
+ - The terminal never holds a token balance between transactions. After every swap, all output tokens are forwarded to the destination terminal, and leftover input tokens from partial fills are returned to the payer.
182
+ - Pool discovery is dynamic -- the terminal searches V3 and V4 pools at runtime. If pool liquidity changes between discovery and execution, slippage protection prevents excessive losses.
183
+ - The `receive()` function accepts ETH from any sender. This is required because ETH arrives from multiple sources: WETH unwraps, cash out reclaims from project terminals, and V4 PoolManager takes. The terminal processes all received ETH within the same transaction.
184
+ - TWAP fallback: V3 pools with no TWAP observation history (`oldestObservation == 0`) will cause the transaction to revert with `JBRouterTerminal_NoObservationHistory()`. V4 uses spot price and does not require observations.
185
+ - Uniswap V4 requires `cancun` EVM version (transient storage opcodes). On chains without EIP-1153 support, the terminal falls back to V3-only routing.
186
+ - Credit cashouts require the payer to grant `TRANSFER_CREDITS` permission (ID 13) to the router terminal address. Without this, credit-based routing will revert.
187
+ - The `_cashOutLoop` recursively cashes out JB project tokens. Deeply nested project token chains (project A's token backed by project B's token backed by project C's token, etc.) will consume more gas per level of recursion.
package/SKILLS.md CHANGED
@@ -1,101 +1,249 @@
1
- # nana-router-terminal-v6
1
+ # Juicebox Router Terminal
2
2
 
3
3
  ## Purpose
4
4
 
5
- Accept payments in any ERC-20 token (or native ETH), dynamically discover what token the destination project accepts, and route there via Uniswap V3/V4 swap, direct forwarding, JB token cashout, or a combination.
5
+ Accept payments in any ERC-20 token (or native ETH), dynamically discover what token the destination project accepts, and route there -- via Uniswap V3/V4 swap, direct forwarding, JB token cashout, or a combination. The router terminal also supports credit-based cashouts where a payer transfers their JB project credits to the terminal for cashout and re-routing.
6
6
 
7
7
  ## Contracts
8
8
 
9
9
  | Contract | Role |
10
10
  |----------|------|
11
- | `JBRouterTerminal` | Core terminal: accepts any token, discovers the best route to the destination project's accepted token, swaps via Uniswap V3 or V4, forwards to primary terminal. Implements `IJBTerminal`, `IJBPermitTerminal`, `IUniswapV3SwapCallback`, `IUnlockCallback`. |
12
- | `JBRouterTerminalRegistry` | Proxy terminal routing `pay`/`addToBalanceOf` to a per-project or default `JBRouterTerminal`. Implements `IJBTerminal`. |
11
+ | `JBRouterTerminal` | Core terminal: accepts any token, discovers the best route to the destination project's accepted token, swaps via Uniswap V3 or V4, cashes out JB project tokens, forwards to the primary terminal. Implements `IJBTerminal`, `IJBPermitTerminal`, `IUniswapV3SwapCallback`, `IUnlockCallback`, `IJBRouterTerminal`. |
12
+ | `JBRouterTerminalRegistry` | Proxy terminal routing `pay`/`addToBalanceOf` to a per-project or default `JBRouterTerminal`. Project owners can set and lock their terminal choice. Implements `IJBTerminal` via `IJBRouterTerminalRegistry`. |
13
13
 
14
14
  ## Key Functions
15
15
 
16
- | Function | Contract | What it does |
17
- |----------|----------|--------------|
18
- | `pay(projectId, token, amount, beneficiary, minReturnedTokens, memo, metadata)` | `JBRouterTerminal` | Accept any token, discover destination's accepted token, swap if needed via best V3/V4 pool, forward to project's primary terminal. Returns project token count. |
19
- | `addToBalanceOf(projectId, token, amount, shouldReturnHeldFees, memo, metadata)` | `JBRouterTerminal` | Same routing flow but calls `terminal.addToBalanceOf(...)` instead of `terminal.pay(...)`. |
20
- | `discoverPool(normalizedTokenIn, normalizedTokenOut)` | `JBRouterTerminal` | Search V3 factory for highest liquidity pool across 4 fee tiers (0.01%, 0.05%, 0.3%, 1%). |
21
- | `discoverBestPool(normalizedTokenIn, normalizedTokenOut)` | `JBRouterTerminal` | Discover best pool across both V3 and V4, comparing liquidity. Returns `PoolInfo` with version, key, and liquidity. |
22
- | `setTerminalFor(projectId, terminal)` | `JBRouterTerminalRegistry` | Route a project to a specific allowed router terminal. Requires `SET_ROUTER_TERMINAL` permission. |
23
- | `lockTerminalFor(projectId)` | `JBRouterTerminalRegistry` | Lock the terminal choice for a project (irreversible). |
24
- | `allowTerminal(terminal)` | `JBRouterTerminalRegistry` | Owner-only: add a terminal to the allowlist. |
25
- | `setDefaultTerminal(terminal)` | `JBRouterTerminalRegistry` | Owner-only: set the default terminal for projects without a custom choice. |
16
+ ### JBRouterTerminal
17
+
18
+ | Function | What it does |
19
+ |----------|--------------|
20
+ | `pay(projectId, token, amount, beneficiary, minReturnedTokens, memo, metadata)` | Accept any token, route to the destination project's accepted token (cashout, swap, wrap/unwrap, or direct), forward to the project's primary terminal. Returns project token count. |
21
+ | `addToBalanceOf(projectId, token, amount, shouldReturnHeldFees, memo, metadata)` | Same routing flow as `pay` but calls `terminal.addToBalanceOf(...)` instead of `terminal.pay(...)`. |
22
+ | `discoverPool(normalizedTokenIn, normalizedTokenOut) -> IUniswapV3Pool` | Search V3 factory for highest-liquidity pool across 4 fee tiers. Returns the V3 pool (or zero address if V4 was better). |
23
+ | `discoverBestPool(normalizedTokenIn, normalizedTokenOut) -> PoolInfo` | Discover best pool across both V3 and V4, comparing in-range liquidity. Returns `PoolInfo` indicating protocol version and pool details. |
24
+ | `accountingContextForTokenOf(projectId, token) -> JBAccountingContext` | Returns a dynamically constructed context with 18 decimals and currency = `uint32(uint160(token))`. Does not read from storage. |
25
+ | `accountingContextsOf(projectId) -> JBAccountingContext[]` | Always returns an empty array (this terminal accepts any token dynamically). |
26
+ | `currentSurplusOf(...) -> uint256` | Always returns 0. This terminal holds no surplus. |
27
+ | `uniswapV3SwapCallback(amount0Delta, amount1Delta, data)` | V3 swap callback. Verifies caller is a legitimate pool via the factory. Wraps ETH if needed and transfers input tokens to the pool. |
28
+ | `unlockCallback(data) -> bytes` | V4 swap callback. Called by PoolManager during `unlock()`. Executes the swap, settles input, takes output, checks slippage. |
29
+ | `supportsInterface(interfaceId) -> bool` | Returns true for `IJBTerminal`, `IJBPermitTerminal`, `IERC165`, `IJBPermissioned`. |
30
+
31
+ ### JBRouterTerminalRegistry
32
+
33
+ | Function | What it does |
34
+ |----------|--------------|
35
+ | `pay(projectId, token, amount, beneficiary, minReturnedTokens, memo, metadata)` | Resolves the terminal for the project (per-project or default), accepts funds, forwards payment. |
36
+ | `addToBalanceOf(projectId, token, amount, shouldReturnHeldFees, memo, metadata)` | Same resolution and forwarding but for balance additions. |
37
+ | `terminalOf(projectId) -> IJBTerminal` | Returns the terminal for the project, or `defaultTerminal` if none is set. |
38
+ | `setTerminalFor(projectId, terminal)` | Route a project to a specific allowed router terminal. Requires `SET_ROUTER_TERMINAL` permission (ID 28). Reverts if locked or terminal not allowed. |
39
+ | `lockTerminalFor(projectId, expectedTerminal)` | Lock the terminal choice permanently. If no terminal is explicitly set, the current default is snapshotted. Reverts with `TerminalMismatch` if the resolved terminal does not match `expectedTerminal` (prevents race conditions). Requires `SET_ROUTER_TERMINAL` permission. |
40
+ | `allowTerminal(terminal)` | Owner-only: add a terminal to the allowlist. |
41
+ | `disallowTerminal(terminal)` | Owner-only: remove a terminal from the allowlist. Also clears `defaultTerminal` if it matches. |
42
+ | `setDefaultTerminal(terminal)` | Owner-only: set the default terminal and auto-allow it. |
43
+ | `accountingContextForTokenOf(projectId, token)` | Delegates to the resolved terminal. |
44
+ | `accountingContextsOf(projectId)` | Delegates to the resolved terminal. |
45
+ | `currentSurplusOf(...)` | Always returns 0 (empty implementation). |
46
+ | `supportsInterface(interfaceId) -> bool` | Returns true for `IJBRouterTerminalRegistry`, `IJBTerminal`, `IERC165`. |
47
+
48
+ ## Internal Routing Functions (JBRouterTerminal)
49
+
50
+ | Function | What it does |
51
+ |----------|--------------|
52
+ | `_route(destProjectId, tokenIn, amount, metadata)` | Core routing logic. Detects JB project tokens, runs `_cashOutLoop` if needed, then resolves output token and converts. |
53
+ | `_resolveTokenOut(projectId, tokenIn, metadata)` | Priority: 1) `routeTokenOut` metadata override, 2) direct acceptance, 3) NATIVE/WETH equivalence, 4) `_discoverAcceptedToken`. |
54
+ | `_discoverAcceptedToken(projectId, tokenIn)` | Iterates all terminals and their accounting contexts for a project. Finds the accepted token with the deepest Uniswap pool. Falls back to the first accepted token if no pool exists. |
55
+ | `_convert(tokenIn, tokenOut, amount, projectId, metadata)` | No-op if same token, wrap/unwrap for NATIVE/WETH, or swap via `_handleSwap`. |
56
+ | `_handleSwap(projectId, tokenIn, tokenOut, amount, metadata)` | Discovers the best pool, gets a quote, executes the swap (V3 or V4), unwraps output if needed, returns leftover input to payer. |
57
+ | `_pickPoolAndQuote(metadata, normalizedTokenIn, amount, normalizedTokenOut)` | Discovers pool, checks for user-provided `quoteForSwap`, otherwise computes TWAP quote (V3) or spot quote (V4) with dynamic slippage. |
58
+ | `_cashOutLoop(destProjectId, token, amount, sourceProjectIdOverride, metadata)` | Recursively cashes out JB project tokens. At each step, checks if the destination accepts the reclaimed token. Continues until a non-JB base token is reached or the destination accepts. |
59
+ | `_findCashOutPath(sourceProjectId, destProjectId)` | Priority: 1) tokens the destination directly accepts, 2) JB project tokens (recursable), 3) any base token. Only considers terminals that support `IJBCashOutTerminal`. |
60
+ | `_getSlippageTolerance(amountIn, liquidity, tokenOut, tokenIn, tick, poolFeeBps)` | Computes sigmoid slippage from `JBSwapLib.calculateImpact` and `JBSwapLib.getSlippageTolerance`. |
61
+ | `_bestPoolLiquidity(tokenA, tokenB)` | Scans all V3 fee tiers and V4 pools for the highest in-range liquidity. |
26
62
 
27
63
  ## Integration Points
28
64
 
29
65
  | Dependency | Import | Used For |
30
66
  |------------|--------|----------|
31
- | `nana-core-v6` | `IJBDirectory`, `IJBTerminal`, `IJBProjects`, `IJBPermissions`, `IJBTokens` | Directory lookups (`primaryTerminalOf`), project ownership, permission checks, token discovery |
32
- | `nana-core-v6` | `JBMetadataResolver` | Parsing `quoteForSwap` and `permit2` metadata from calldata |
67
+ | `nana-core-v6` | `IJBDirectory`, `IJBTerminal`, `IJBCashOutTerminal`, `IJBProjects`, `IJBPermissions`, `IJBTokens` | Directory lookups (`primaryTerminalOf`, `terminalsOf`), project ownership, permission checks, token discovery, cashout execution |
68
+ | `nana-core-v6` | `JBMetadataResolver` | Parsing `quoteForSwap`, `permit2`, `routeTokenOut`, `cashOutSource`, and `cashOutMinReclaimed` metadata from calldata |
33
69
  | `nana-core-v6` | `JBAccountingContext`, `JBSingleAllowance` | Token accounting and Permit2 allowance structs |
34
- | `nana-permission-ids-v6` | `JBPermissionIds` | Permission ID constants (`SET_ROUTER_TERMINAL`) |
35
- | `@uniswap/v3-core` | `IUniswapV3Pool`, `IUniswapV3Factory`, `TickMath` | V3 pool swaps, factory pool discovery, tick math |
70
+ | `nana-core-v6` | `IJBPermitTerminal` | Interface for Permit2 support and the `Permit2AllowanceFailed` event |
71
+ | `nana-permission-ids-v6` | `JBPermissionIds` | Permission ID constants: `SET_ROUTER_TERMINAL` (28), `TRANSFER_CREDITS` (13, required by payer for credit cashouts) |
72
+ | `@uniswap/v3-core` | `IUniswapV3Pool`, `IUniswapV3Factory`, `IUniswapV3SwapCallback`, `TickMath` | V3 pool swaps, factory pool discovery, tick math |
36
73
  | `@uniswap/v3-periphery` | `OracleLibrary` | TWAP oracle consultation (`consult`, `getQuoteAtTick`, `getOldestObservationSecondsAgo`) |
37
- | `@uniswap/v4-core` | `IPoolManager`, `PoolKey`, `Currency`, `StateLibrary` | V4 pool swaps and liquidity queries |
74
+ | `@uniswap/v4-core` | `IPoolManager`, `IUnlockCallback`, `PoolKey`, `PoolId`, `Currency`, `BalanceDelta`, `SwapParams`, `StateLibrary` | V4 pool swaps, liquidity queries, settle/take flow |
38
75
  | `@uniswap/permit2` | `IPermit2`, `IAllowanceTransfer` | Gasless token approvals |
39
- | `@openzeppelin/contracts` | `Ownable`, `ERC2771Context`, `SafeERC20` | Access control, meta-transactions, safe transfers |
76
+ | `@openzeppelin/contracts` | `Ownable`, `ERC2771Context`, `SafeERC20`, `Math` | Access control, meta-transactions, safe transfers, sqrt |
77
+ | `@prb/math` | `mulDiv` | Safe fixed-point multiplication in JBSwapLib |
40
78
 
41
79
  ## Key Types
42
80
 
43
- | Struct/Enum | Key Fields | Used In |
44
- |-------------|------------|---------|
45
- | `PoolInfo` | `isV4`, `v3Pool`, `v4Key`, `liquidity` | Returned by `discoverBestPool`. Indicates whether the best route is V3 or V4 and stores the pool details. |
46
- | `JBAccountingContext` | `token`, `decimals`, `currency` | Token accounting contexts for accepted tokens. |
47
- | `JBSingleAllowance` | `sigDeadline`, `amount`, `expiration`, `nonce`, `signature` | Decoded from `permit2` metadata key for gasless approvals. |
81
+ | Struct | Fields | Used In |
82
+ |--------|--------|---------|
83
+ | `PoolInfo` | `bool isV4`, `IUniswapV3Pool v3Pool`, `PoolKey v4Key` | Returned by `discoverBestPool` and used internally by `_discoverPool`, `_pickPoolAndQuote`. Indicates whether the best route is V3 or V4 and stores the pool reference. |
84
+ | `JBAccountingContext` | `address token`, `uint8 decimals`, `uint32 currency` | Token accounting contexts for accepted tokens. The router terminal constructs these dynamically with 18 decimals. |
85
+ | `JBSingleAllowance` | `uint48 sigDeadline`, `uint160 amount`, `uint48 expiration`, `uint48 nonce`, `bytes signature` | Decoded from `permit2` metadata key for gasless approvals. |
48
86
 
49
87
  ## Constants
50
88
 
89
+ ### JBRouterTerminal
90
+
91
+ | Constant | Value | Purpose |
92
+ |----------|-------|---------|
93
+ | `DEFAULT_TWAP_WINDOW` | `10 minutes` (600 seconds) | Default TWAP oracle window for V3 pool quotes |
94
+ | `SLIPPAGE_DENOMINATOR` | `10,000` | Basis points denominator for slippage tolerance |
95
+ | `_FEE_TIERS` | `[3000, 500, 10000, 100]` | V3 fee tiers to search (0.3%, 0.05%, 1%, 0.01%) |
96
+ | `_V4_FEES` | `[3000, 500, 10000, 100]` | V4 fee tiers to search |
97
+ | `_V4_TICK_SPACINGS` | `[60, 10, 200, 1]` | V4 tick spacings paired with fee tiers |
98
+
99
+ ### JBSwapLib
100
+
51
101
  | Constant | Value | Purpose |
52
102
  |----------|-------|---------|
53
- | `DEFAULT_TWAP_WINDOW` | `10 minutes` | Default TWAP oracle window for auto-discovered pools |
54
- | `FEE_TIERS` | `[3000, 500, 10000, 100]` | V3 fee tiers to search (0.3%, 0.05%, 1%, 0.01%) |
55
- | `V4_FEES` | `[3000, 500, 10000, 100]` | V4 fee tiers to search |
56
- | `V4_TICK_SPACINGS` | `[60, 10, 200, 1]` | V4 tick spacings paired with fee tiers |
57
- | `SLIPPAGE_DENOMINATOR` | `10,000` | Basis points denominator for slippage |
103
+ | `SLIPPAGE_DENOMINATOR` | `10,000` | Basis points denominator |
104
+ | `MAX_SLIPPAGE` | `8,800` (88%) | Maximum slippage ceiling |
105
+ | `IMPACT_PRECISION` | `1e18` | Precision multiplier for impact calculations |
106
+ | `SIGMOID_K` | `5e16` | K parameter for sigmoid curve |
107
+
108
+ ## Errors
109
+
110
+ ### JBRouterTerminal
111
+
112
+ | Error | When |
113
+ |-------|------|
114
+ | `JBRouterTerminal_NoRouteFound(uint256 projectId, address tokenIn)` | No accepted token found for the project when iterating all terminals |
115
+ | `JBRouterTerminal_TokenNotAccepted(uint256 projectId, address token)` | The `routeTokenOut` metadata override specifies a token the project does not accept |
116
+ | `JBRouterTerminal_CallerNotPool(address caller)` | V3 swap callback called by an address that is not a legitimate factory pool |
117
+ | `JBRouterTerminal_CallerNotPoolManager(address caller)` | V4 unlock callback called by an address other than the PoolManager |
118
+ | `JBRouterTerminal_SlippageExceeded(uint256 amountOut, uint256 minAmountOut)` | Swap output is below the minimum acceptable amount |
119
+ | `JBRouterTerminal_NoPoolFound(address tokenIn, address tokenOut)` | No V3 or V4 pool exists for the token pair |
120
+ | `JBRouterTerminal_NoCashOutPath(uint256 sourceProjectId, uint256 destProjectId)` | No cashout terminal found for the source project |
121
+ | `JBRouterTerminal_NoMsgValueAllowed(uint256 value)` | `msg.value > 0` when paying with an ERC-20 token or using credit cashout |
122
+ | `JBRouterTerminal_PermitAllowanceNotEnough(uint256 amount, uint256 allowance)` | Permit2 allowance is less than the payment amount |
123
+ | `JBRouterTerminal_NoLiquidity()` | Pool has zero in-range liquidity (TWAP or spot quote would be meaningless) |
124
+ | `JBRouterTerminal_NoObservationHistory()` | V3 pool has no TWAP observation history (`oldestObservation == 0`) |
125
+ | `JBRouterTerminal_AmountOverflow(uint256 amount)` | Amount exceeds `type(uint128).max` (required by `OracleLibrary.getQuoteAtTick`) |
126
+ | `JBRouterTerminal_CashOutLoopLimit()` | Cashout loop exceeded 20 iterations (circular token dependency) |
127
+
128
+ ### JBRouterTerminalRegistry
129
+
130
+ | Error | When |
131
+ |-------|------|
132
+ | `JBRouterTerminalRegistry_NoMsgValueAllowed(uint256 value)` | `msg.value > 0` when paying with an ERC-20 |
133
+ | `JBRouterTerminalRegistry_PermitAllowanceNotEnough(uint256 amount, uint256 allowanceAmount)` | Permit2 allowance is less than the payment amount |
134
+ | `JBRouterTerminalRegistry_TerminalLocked(uint256 projectId)` | Attempting to change terminal after it has been locked |
135
+ | `JBRouterTerminalRegistry_TerminalMismatch(IJBTerminal currentTerminal, IJBTerminal expectedTerminal)` | Resolved terminal does not match the `expectedTerminal` passed to `lockTerminalFor` |
136
+ | `JBRouterTerminalRegistry_TerminalNotAllowed(IJBTerminal terminal)` | Attempting to set a terminal that is not on the allowlist |
137
+ | `JBRouterTerminalRegistry_TerminalNotSet(uint256 projectId)` | Attempting to lock when no terminal is set and no default exists |
138
+
139
+ ## Events
140
+
141
+ ### JBRouterTerminal
142
+
143
+ | Event | When |
144
+ |-------|------|
145
+ | `Permit2AllowanceFailed(address indexed token, address indexed owner, bytes reason)` | Permit2 allowance call failed (from `IJBPermitTerminal`). Payment continues using fallback transfer. |
146
+
147
+ ### JBRouterTerminalRegistry
148
+
149
+ | Event | When |
150
+ |-------|------|
151
+ | `JBRouterTerminalRegistry_AllowTerminal(IJBTerminal terminal)` | Terminal added to allowlist |
152
+ | `JBRouterTerminalRegistry_DisallowTerminal(IJBTerminal terminal)` | Terminal removed from allowlist |
153
+ | `JBRouterTerminalRegistry_LockTerminal(uint256 projectId)` | Terminal locked for a project |
154
+ | `JBRouterTerminalRegistry_SetDefaultTerminal(IJBTerminal terminal)` | Default terminal updated |
155
+ | `JBRouterTerminalRegistry_SetTerminal(uint256 indexed projectId, IJBTerminal terminal)` | Terminal set for a project |
156
+
157
+ ## Metadata Keys
158
+
159
+ | Key | Encoding | Used In | Purpose |
160
+ |-----|----------|---------|---------|
161
+ | `"quoteForSwap"` | `abi.encode(uint256 minAmountOut)` | `_pickPoolAndQuote` | Caller-provided minimum swap output. Overrides TWAP/spot auto-quote. |
162
+ | `"permit2"` | `abi.encode(JBSingleAllowance)` | `_acceptFundsFor` | Permit2 signature for gasless ERC-20 approval. |
163
+ | `"routeTokenOut"` | `abi.encode(address tokenOut)` | `_resolveTokenOut` | Force the router to convert to a specific output token. |
164
+ | `"cashOutSource"` | `abi.encode(uint256 sourceProjectId, uint256 creditAmount)` | `_acceptFundsFor`, `_route` | Cash out credits from `sourceProjectId`. Payer must grant `TRANSFER_CREDITS` (13) to the router. |
165
+ | `"cashOutMinReclaimed"` | `abi.encode(uint256 minTokensReclaimed)` | `_cashOutLoop` | Minimum tokens reclaimed from first cashout step. |
58
166
 
59
167
  ## Gotchas
60
168
 
61
- - The terminal never holds a token balance. After every swap, all output tokens are forwarded and leftover input tokens are returned to the payer.
62
- - Unlike the swap terminal which had a fixed `TOKEN_OUT`, the router terminal dynamically discovers what token each project accepts. This makes it a universal entry point.
63
- - Pool discovery runs at call time it searches V3 and V4 pools across multiple fee tiers. The best pool (by liquidity) wins. This is gas-intensive but ensures optimal routing.
169
+ - **Dynamic accounting contexts**: `accountingContextsOf()` returns an empty array and `accountingContextForTokenOf()` constructs contexts on the fly with 18 decimals. This is intentional -- the router accepts any token.
170
+ - **No surplus, no migration**: `currentSurplusOf()` always returns 0. `migrateBalanceOf()` always returns 0. The terminal is stateless between transactions.
171
+ - Unlike `JBMultiTerminal` which has a fixed `TOKEN_OUT`, the router terminal dynamically discovers what token each project accepts. This makes it a universal entry point.
172
+ - Pool discovery runs at call time -- it searches V3 and V4 pools across 4 fee tiers each (8 pools total). The best pool (by in-range liquidity) wins. This is gas-intensive but ensures optimal routing.
64
173
  - When `tokenIn == NATIVE_TOKEN`, the terminal wraps ETH to WETH before swapping. When the output is `NATIVE_TOKEN`, it unwraps WETH after swapping.
65
- - The `receive()` function only accepts ETH from the WETH contract (during unwrap). All other senders revert.
66
- - TWAP fallback: when no observations exist (`oldestObservation == 0`), the terminal falls back to the pool's current spot tick and liquidity rather than reverting.
67
- - Uniswap V4 requires `cancun` EVM version (transient storage). Chains without EIP-1153 cannot use V4 routing the terminal falls back to V3.
68
- - The `JBRouterTerminalRegistry` handles token custody during delegation it transfers tokens from the payer to itself, then to the underlying terminal.
69
- - Metadata keys: `"quoteForSwap"` for the minimum output amount, `"permit2"` for gasless approvals.
70
- - `_msgSender()` (ERC-2771) is used instead of `msg.sender` for meta-transaction compatibility.
71
- - The `JBSwapLib` library contains the core swap execution logic, extracted for contract size management.
174
+ - The `receive()` function accepts ETH from any sender. This is necessary because ETH arrives from WETH unwraps, cashout reclaims from project terminals, and V4 PoolManager takes. The terminal handles all ETH within the same transaction.
175
+ - **V3 TWAP**: Reverts with `JBRouterTerminal_NoObservationHistory()` when a V3 pool has no observation history. The TWAP window is capped by the pool's oldest observation if shorter than 10 minutes.
176
+ - **V4 spot price**: V4 vanilla pools have no built-in TWAP oracle. The terminal uses the current spot tick with the same sigmoid slippage formula.
177
+ - **V4 requires cancun EVM**: Chains without EIP-1153 (transient storage) cannot use V4 routing. If `POOL_MANAGER` is `address(0)`, V4 discovery is skipped entirely.
178
+ - The `JBRouterTerminalRegistry` handles token custody during delegation -- it transfers tokens from the payer to itself, then approves and forwards to the underlying terminal.
179
+ - `_msgSender()` (ERC-2771) is used instead of `msg.sender` for meta-transaction compatibility in both contracts.
180
+ - The `JBSwapLib` library contains slippage tolerance math (sigmoid formula), price impact estimation, and V3-compatible `sqrtPriceLimitX96` calculation. It does not contain swap execution logic.
181
+ - **Leftover handling**: After a swap, leftover input tokens (from partial fills where the price limit was hit) are returned to the payer. For native token inputs, any remaining raw ETH is wrapped to WETH first so the leftover check catches it.
182
+ - **Credit cashouts**: When using `cashOutSource` metadata, the payer must have granted `TRANSFER_CREDITS` permission (ID 13) to the router terminal for the source project. The router calls `TOKENS.transferCreditsFrom()` to pull credits.
183
+ - **Cashout loop depth**: The `_cashOutLoop` iterates through JB project token chains with a cap of 20 iterations (`_MAX_CASHOUT_ITERATIONS`). Exceeding this limit reverts with `JBRouterTerminal_CashOutLoopLimit()`.
184
+ - **V3 callback verification**: The `uniswapV3SwapCallback` verifies the caller by reading the pool's `fee()` and checking `FACTORY.getPool()`. This is standard V3 security.
185
+ - **V4 amount overflow**: Both `_getV3TwapQuote` and `_getV4SpotQuote` revert if `amount > type(uint128).max` because `OracleLibrary.getQuoteAtTick` requires `uint128`.
186
+ - **Disallowing the default terminal**: `disallowTerminal()` clears `defaultTerminal` if it matches the terminal being disallowed.
187
+ - **Locking snapshots default**: `lockTerminalFor(projectId, expectedTerminal)` snapshots the current `defaultTerminal` into `_terminalOf[projectId]` if no explicit terminal was set, preventing future default changes from affecting locked projects. The `expectedTerminal` parameter prevents race conditions where the default changes between transaction submission and execution.
188
+ - **Cashout loop limit**: `_cashOutLoop` is capped at 20 iterations. Circular JB token dependencies (A -> B -> A) will revert with `CashOutLoopLimit` instead of consuming all gas.
72
189
 
73
190
  ## Example Integration
74
191
 
75
192
  ```solidity
76
- import {JBRouterTerminal} from "@bananapus/router-terminal-v6/src/JBRouterTerminal.sol";
77
- import {JBRouterTerminalRegistry} from "@bananapus/router-terminal-v6/src/JBRouterTerminalRegistry.sol";
78
-
79
- // The registry is the entry point for payments.
80
- // It delegates to per-project or default JBRouterTerminal instances.
81
-
82
- // Pay project 1 with USDC the router terminal discovers that
83
- // project 1 accepts ETH, finds the best USDC/WETH pool across
84
- // V3 and V4, swaps, and forwards ETH to the project's terminal.
85
- IERC20(usdc).approve(address(registry), 1000e6);
86
- registry.pay{value: 0}(
193
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
194
+ import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
195
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
196
+
197
+ // --- Basic payment (auto-routing, auto-quote) ---
198
+ // Pay project 1 with USDC. The router discovers the project accepts ETH,
199
+ // finds the best USDC/WETH pool across V3 and V4, swaps, and forwards.
200
+ IERC20(usdc).approve(address(routerTerminal), 1000e6);
201
+ routerTerminal.pay(
87
202
  1, // projectId
88
203
  usdc, // token (USDC)
89
- 1000e6, // amount (1000 USDC)
204
+ 1000e6, // amount
90
205
  beneficiary, // who receives project tokens
91
206
  0, // minReturnedTokens
92
- "Payment via router",
93
- "" // metadata (empty = use TWAP quote)
207
+ "USDC payment via router",
208
+ "" // empty metadata = use auto TWAP/spot quote
94
209
  );
95
210
 
96
- // Project owners can choose a specific router terminal:
97
- registry.setTerminalFor(projectId, preferredTerminal);
211
+ // --- Payment with explicit quote ---
212
+ bytes memory quoteMetadata = JBMetadataResolver.addToMetadata({
213
+ originalMetadata: "",
214
+ id: JBMetadataResolver.getId("quoteForSwap"),
215
+ data: abi.encode(uint256(0.5 ether)) // minimum 0.5 ETH from swap
216
+ });
98
217
 
99
- // And lock it permanently:
100
- registry.lockTerminalFor(projectId);
218
+ IERC20(usdc).approve(address(routerTerminal), 1000e6);
219
+ routerTerminal.pay(
220
+ 1, usdc, 1000e6, beneficiary, 0, "with quote", quoteMetadata
221
+ );
222
+
223
+ // --- Payment with explicit output token ---
224
+ bytes memory routeMetadata = JBMetadataResolver.addToMetadata({
225
+ originalMetadata: "",
226
+ id: JBMetadataResolver.getId("routeTokenOut"),
227
+ data: abi.encode(dai) // force conversion to DAI
228
+ });
229
+
230
+ IERC20(usdc).approve(address(routerTerminal), 1000e6);
231
+ routerTerminal.pay(
232
+ 1, usdc, 1000e6, beneficiary, 0, "force DAI", routeMetadata
233
+ );
234
+
235
+ // --- Native ETH payment ---
236
+ routerTerminal.pay{value: 1 ether}(
237
+ 1,
238
+ 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE, // NATIVE_TOKEN
239
+ 1 ether,
240
+ beneficiary,
241
+ 0,
242
+ "ETH payment",
243
+ ""
244
+ );
245
+
246
+ // --- Registry: project owner sets terminal ---
247
+ registry.setTerminalFor(projectId, preferredTerminal);
248
+ registry.lockTerminalFor(projectId, preferredTerminal); // permanent, reverts if terminal changed
101
249
  ```
package/foundry.toml CHANGED
@@ -6,6 +6,7 @@ libs = ["node_modules", "lib"]
6
6
  fs_permissions = [{ access = "read-write", path = "./"}]
7
7
 
8
8
  [profile.ci_sizes]
9
+ via_ir = true
9
10
  optimizer_runs = 200
10
11
 
11
12
  [fuzz]
@@ -16,6 +17,9 @@ runs = 1024
16
17
  depth = 100
17
18
  fail_on_revert = false
18
19
 
20
+ [rpc_endpoints]
21
+ mainnet = "${RPC_ETHEREUM_MAINNET}"
22
+
19
23
  [fmt]
20
24
  number_underscore = "thousands"
21
25
  multiline_func_header = "all"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/router-terminal-v6",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,7 +17,7 @@
17
17
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-router-terminal-v6'"
18
18
  },
19
19
  "dependencies": {
20
- "@bananapus/core-v6": "^0.0.5",
20
+ "@bananapus/core-v6": "^0.0.9",
21
21
  "@openzeppelin/contracts": "^5.2.0",
22
22
  "@uniswap/permit2": "github:Uniswap/permit2",
23
23
  "@uniswap/v3-core": "github:Uniswap/v3-core#0.8",
@@ -1,5 +1,5 @@
1
1
  {
2
- "detectors_to_exclude": "timestamp,uninitialized-local,naming-convention,solc-version,shadowing-local",
2
+ "detectors_to_exclude": "timestamp,uninitialized-local,naming-convention,solc-version,shadowing-local,incorrect-equality",
3
3
  "exclude_informational": true,
4
4
  "exclude_low": false,
5
5
  "exclude_medium": false,