@bananapus/router-terminal-v6 0.0.6 → 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 +78 -36
- package/SKILLS.md +206 -58
- package/foundry.toml +1 -0
- package/package.json +2 -2
- package/slither-ci.config.json +1 -1
- package/src/JBRouterTerminal.sol +23 -7
- package/src/JBRouterTerminalRegistry.sol +9 -1
- package/src/interfaces/IJBRouterTerminal.sol +1 -0
- package/src/interfaces/IJBRouterTerminalRegistry.sol +2 -1
- package/test/RouterTerminal.t.sol +85 -67
- package/test/RouterTerminalRegistry.t.sol +3 -3
- package/test/regression/L29_LockTerminalRace.t.sol +93 -0
- package/test/regression/L30_CashOutLoopLimit.t.sol +211 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
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
|
|
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
|
|
12
|
-
| `JBRouterTerminalRegistry` | A proxy terminal that delegates `pay` and `addToBalanceOf` to a per-project or default `JBRouterTerminal` instance.
|
|
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
|
|
18
|
-
3.
|
|
19
|
-
4.
|
|
20
|
-
5.
|
|
21
|
-
6.
|
|
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
|
|
43
|
+
The terminal uses a multi-step routing algorithm:
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 =
|
|
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 #
|
|
122
|
+
│ │ └── JBSwapLib.sol # Slippage tolerance, impact, and price limit math
|
|
111
123
|
│ └── structs/
|
|
112
|
-
│ └── PoolInfo.sol #
|
|
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 #
|
|
119
|
-
|
|
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(...)`
|
|
137
|
+
The `JBRouterTerminal` accepts encoded `metadata` in its `pay(...)` and `addToBalanceOf(...)` functions. Metadata is decoded using `JBMetadataResolver` with string-based keys:
|
|
125
138
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
143
|
-
-
|
|
144
|
-
-
|
|
145
|
-
- Uniswap V4 requires `cancun` EVM version (transient storage opcodes).
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
|
19
|
-
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
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 `
|
|
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-
|
|
35
|
-
|
|
|
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
|
|
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
|
|
44
|
-
|
|
45
|
-
| `PoolInfo` | `isV4`, `v3Pool`, `v4Key
|
|
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
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
|
|
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
|
-
-
|
|
62
|
-
-
|
|
63
|
-
-
|
|
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
|
|
66
|
-
- TWAP
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
- `_msgSender()` (ERC-2771) is used instead of `msg.sender` for meta-transaction compatibility.
|
|
71
|
-
- The `JBSwapLib` library contains
|
|
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 {
|
|
77
|
-
import {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
204
|
+
1000e6, // amount
|
|
90
205
|
beneficiary, // who receives project tokens
|
|
91
206
|
0, // minReturnedTokens
|
|
92
|
-
"
|
|
93
|
-
"" // metadata
|
|
207
|
+
"USDC payment via router",
|
|
208
|
+
"" // empty metadata = use auto TWAP/spot quote
|
|
94
209
|
);
|
|
95
210
|
|
|
96
|
-
//
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/router-terminal-v6",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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",
|
package/slither-ci.config.json
CHANGED
|
@@ -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,
|
package/src/JBRouterTerminal.sol
CHANGED
|
@@ -703,6 +703,9 @@ contract JBRouterTerminal is
|
|
|
703
703
|
{
|
|
704
704
|
address normalizedTokenIn = tokenIn == JBConstants.NATIVE_TOKEN ? address(WETH) : tokenIn;
|
|
705
705
|
|
|
706
|
+
// Snapshot the input token balance before the swap to compute the leftover delta accurately.
|
|
707
|
+
uint256 balanceBefore = IERC20(normalizedTokenIn).balanceOf(address(this));
|
|
708
|
+
|
|
706
709
|
// Execute the swap in a scoped block to manage stack depth.
|
|
707
710
|
amountOut = _executeSwap({
|
|
708
711
|
normalizedTokenIn: normalizedTokenIn,
|
|
@@ -721,9 +724,10 @@ contract JBRouterTerminal is
|
|
|
721
724
|
// Unwrap if output is native token.
|
|
722
725
|
if (tokenOut == JBConstants.NATIVE_TOKEN) WETH.withdraw(amountOut);
|
|
723
726
|
|
|
724
|
-
// Return leftover input tokens to payer.
|
|
725
|
-
uint256
|
|
726
|
-
if (
|
|
727
|
+
// Return leftover input tokens to payer using balance delta rather than global balance.
|
|
728
|
+
uint256 balanceAfter = IERC20(normalizedTokenIn).balanceOf(address(this));
|
|
729
|
+
if (balanceAfter > balanceBefore) {
|
|
730
|
+
uint256 leftover = balanceAfter - balanceBefore;
|
|
727
731
|
if (tokenIn == JBConstants.NATIVE_TOKEN) WETH.withdraw(leftover);
|
|
728
732
|
_transferFrom({from: address(this), to: payable(_msgSender()), token: tokenIn, amount: leftover});
|
|
729
733
|
}
|
|
@@ -809,7 +813,8 @@ contract JBRouterTerminal is
|
|
|
809
813
|
address v4In = normalizedTokenIn == address(WETH) ? address(0) : normalizedTokenIn;
|
|
810
814
|
bool zeroForOne = Currency.unwrap(key.currency0) == v4In;
|
|
811
815
|
|
|
812
|
-
|
|
816
|
+
// Use sqrtPriceLimitFromAmounts for partial-fill protection, consistent with V3 path.
|
|
817
|
+
uint160 sqrtPriceLimitX96 = JBSwapLib.sqrtPriceLimitFromAmounts(amount, minAmountOut, zeroForOne);
|
|
813
818
|
|
|
814
819
|
// V4 sign convention: negative = exact input, positive = exact output.
|
|
815
820
|
bytes memory result =
|
|
@@ -1053,6 +1058,7 @@ contract JBRouterTerminal is
|
|
|
1053
1058
|
|
|
1054
1059
|
// slither-disable-next-line unused-return,calls-loop
|
|
1055
1060
|
(uint160 sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(key.toId());
|
|
1061
|
+
// slither-disable-next-line incorrect-equality
|
|
1056
1062
|
if (sqrtPriceX96 == 0) continue;
|
|
1057
1063
|
|
|
1058
1064
|
// slither-disable-next-line calls-loop
|
|
@@ -1096,6 +1102,10 @@ contract JBRouterTerminal is
|
|
|
1096
1102
|
return JBSwapLib.getSlippageTolerance(impact, poolFeeBps);
|
|
1097
1103
|
}
|
|
1098
1104
|
|
|
1105
|
+
/// @notice The maximum number of cashout iterations before reverting. Prevents infinite loops from circular
|
|
1106
|
+
/// token dependencies.
|
|
1107
|
+
uint256 internal constant _MAX_CASHOUT_ITERATIONS = 20;
|
|
1108
|
+
|
|
1099
1109
|
/// @notice Recursively cash out JB project tokens until reaching a token the destination accepts or a base token.
|
|
1100
1110
|
/// @param destProjectId The ID of the destination project.
|
|
1101
1111
|
/// @param token The current token being processed.
|
|
@@ -1124,7 +1134,7 @@ contract JBRouterTerminal is
|
|
|
1124
1134
|
if (exists) minTokensReclaimed = abi.decode(minData, (uint256));
|
|
1125
1135
|
}
|
|
1126
1136
|
|
|
1127
|
-
|
|
1137
|
+
for (uint256 i; i < _MAX_CASHOUT_ITERATIONS; i++) {
|
|
1128
1138
|
// Skip the destination check on the first iteration if we have a credit override.
|
|
1129
1139
|
if (sourceProjectIdOverride == 0) {
|
|
1130
1140
|
// slither-disable-next-line calls-loop
|
|
@@ -1167,6 +1177,9 @@ contract JBRouterTerminal is
|
|
|
1167
1177
|
token = tokenToReclaim;
|
|
1168
1178
|
sourceProjectIdOverride = 0;
|
|
1169
1179
|
}
|
|
1180
|
+
|
|
1181
|
+
// If we reach here, the loop exceeded the maximum iteration count.
|
|
1182
|
+
revert JBRouterTerminal_CashOutLoopLimit();
|
|
1170
1183
|
}
|
|
1171
1184
|
|
|
1172
1185
|
/// @notice Find which terminal to cash out from and which token to reclaim.
|
|
@@ -1307,11 +1320,14 @@ contract JBRouterTerminal is
|
|
|
1307
1320
|
}
|
|
1308
1321
|
}
|
|
1309
1322
|
|
|
1323
|
+
// Measure balance before transfer to determine actual tokens received (handles fee-on-transfer tokens).
|
|
1324
|
+
uint256 balanceBefore = IERC20(token).balanceOf(address(this));
|
|
1325
|
+
|
|
1310
1326
|
// Transfer the tokens from the `_msgSender()` to this terminal.
|
|
1311
1327
|
_transferFrom({from: _msgSender(), to: payable(address(this)), token: token, amount: amount});
|
|
1312
1328
|
|
|
1313
|
-
// Return the amount
|
|
1314
|
-
return
|
|
1329
|
+
// Return the actual amount received (balance delta), not the user-supplied amount.
|
|
1330
|
+
return IERC20(token).balanceOf(address(this)) - balanceBefore;
|
|
1315
1331
|
}
|
|
1316
1332
|
|
|
1317
1333
|
/// @notice Logic to be triggered before transferring tokens from this terminal.
|
|
@@ -35,6 +35,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
|
|
|
35
35
|
error JBRouterTerminalRegistry_NoMsgValueAllowed(uint256 value);
|
|
36
36
|
error JBRouterTerminalRegistry_PermitAllowanceNotEnough(uint256 amount, uint256 allowanceAmount);
|
|
37
37
|
error JBRouterTerminalRegistry_TerminalLocked(uint256 projectId);
|
|
38
|
+
error JBRouterTerminalRegistry_TerminalMismatch(IJBTerminal currentTerminal, IJBTerminal expectedTerminal);
|
|
38
39
|
error JBRouterTerminalRegistry_TerminalNotAllowed(IJBTerminal terminal);
|
|
39
40
|
error JBRouterTerminalRegistry_TerminalNotSet(uint256 projectId);
|
|
40
41
|
|
|
@@ -265,7 +266,9 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
|
|
|
265
266
|
/// @notice Lock a terminal for a project.
|
|
266
267
|
/// @dev Only the project's owner or an address with the `JBPermissionIds.SET_ROUTER_TERMINAL` permission can lock.
|
|
267
268
|
/// @param projectId The ID of the project to lock the terminal for.
|
|
268
|
-
|
|
269
|
+
/// @param expectedTerminal The terminal the caller expects to lock. Prevents race conditions where the default
|
|
270
|
+
/// changes between transaction submission and execution.
|
|
271
|
+
function lockTerminalFor(uint256 projectId, IJBTerminal expectedTerminal) external {
|
|
269
272
|
// Enforce permissions.
|
|
270
273
|
_requirePermissionFrom({
|
|
271
274
|
account: PROJECTS.ownerOf(projectId),
|
|
@@ -281,6 +284,11 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
|
|
|
281
284
|
_terminalOf[projectId] = terminal;
|
|
282
285
|
}
|
|
283
286
|
|
|
287
|
+
// Verify the resolved terminal matches what the caller expects to lock.
|
|
288
|
+
if (terminal != expectedTerminal) {
|
|
289
|
+
revert JBRouterTerminalRegistry_TerminalMismatch(terminal, expectedTerminal);
|
|
290
|
+
}
|
|
291
|
+
|
|
284
292
|
hasLockedTerminal[projectId] = true;
|
|
285
293
|
|
|
286
294
|
emit JBRouterTerminalRegistry_LockTerminal(projectId);
|
|
@@ -18,6 +18,7 @@ interface IJBRouterTerminal {
|
|
|
18
18
|
error JBRouterTerminal_NoLiquidity();
|
|
19
19
|
error JBRouterTerminal_NoObservationHistory();
|
|
20
20
|
error JBRouterTerminal_AmountOverflow(uint256 amount);
|
|
21
|
+
error JBRouterTerminal_CashOutLoopLimit();
|
|
21
22
|
|
|
22
23
|
/// @notice Search the Uniswap V3 factory for a pool between two tokens across common fee tiers.
|
|
23
24
|
/// @param normalizedTokenIn The input token (wrapped if native).
|
|
@@ -49,7 +49,8 @@ interface IJBRouterTerminalRegistry is IJBTerminal {
|
|
|
49
49
|
|
|
50
50
|
/// @notice Lock the terminal for a project, preventing it from being changed.
|
|
51
51
|
/// @param projectId The ID of the project to lock the terminal for.
|
|
52
|
-
|
|
52
|
+
/// @param expectedTerminal The terminal the caller expects to lock. Reverts if the current terminal doesn't match.
|
|
53
|
+
function lockTerminalFor(uint256 projectId, IJBTerminal expectedTerminal) external;
|
|
53
54
|
|
|
54
55
|
/// @notice Set the default terminal used when a project has not set a specific terminal.
|
|
55
56
|
/// @param terminal The terminal to set as the default.
|
|
@@ -30,6 +30,34 @@ import {IJBRouterTerminal} from "../src/interfaces/IJBRouterTerminal.sol";
|
|
|
30
30
|
import {PoolInfo} from "../src/structs/PoolInfo.sol";
|
|
31
31
|
import {IWETH9} from "../src/interfaces/IWETH9.sol";
|
|
32
32
|
|
|
33
|
+
/// @notice Minimal ERC20 mock that tracks balances so balanceOf delta works with L-33's _acceptFundsFor.
|
|
34
|
+
contract MockERC20 {
|
|
35
|
+
mapping(address => uint256) public balanceOf;
|
|
36
|
+
mapping(address => mapping(address => uint256)) public allowance;
|
|
37
|
+
|
|
38
|
+
function mint(address to, uint256 amount) external {
|
|
39
|
+
balanceOf[to] += amount;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function approve(address spender, uint256 amount) external returns (bool) {
|
|
43
|
+
allowance[msg.sender][spender] = amount;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function transfer(address to, uint256 amount) external returns (bool) {
|
|
48
|
+
balanceOf[msg.sender] -= amount;
|
|
49
|
+
balanceOf[to] += amount;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
|
|
54
|
+
allowance[from][msg.sender] -= amount;
|
|
55
|
+
balanceOf[from] -= amount;
|
|
56
|
+
balanceOf[to] += amount;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
33
61
|
/// @notice A harness that exposes internal functions for testing.
|
|
34
62
|
contract RouterTerminalHarness is JBRouterTerminal {
|
|
35
63
|
constructor(
|
|
@@ -284,13 +312,13 @@ contract RouterTerminalTest is Test {
|
|
|
284
312
|
|
|
285
313
|
function test_pay_directForward() public {
|
|
286
314
|
uint256 projectId = 1;
|
|
287
|
-
|
|
315
|
+
MockERC20 token = new MockERC20();
|
|
316
|
+
address tokenIn = address(token);
|
|
288
317
|
uint256 amount = 1000;
|
|
289
318
|
address beneficiary = makeAddr("beneficiary");
|
|
290
319
|
address payer = makeAddr("payer");
|
|
291
320
|
address mockTerminal = makeAddr("destTerminal");
|
|
292
321
|
vm.etch(mockTerminal, hex"00");
|
|
293
|
-
vm.etch(tokenIn, hex"00");
|
|
294
322
|
|
|
295
323
|
// Not a JB token.
|
|
296
324
|
vm.mockCall(address(mockTokens), abi.encodeWithSelector(IJBTokens.projectIdOf.selector), abi.encode(uint256(0)));
|
|
@@ -302,16 +330,12 @@ contract RouterTerminalTest is Test {
|
|
|
302
330
|
abi.encode(mockTerminal)
|
|
303
331
|
);
|
|
304
332
|
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
vm.
|
|
308
|
-
|
|
309
|
-
);
|
|
333
|
+
// Mint tokens to payer and approve the router terminal.
|
|
334
|
+
token.mint(payer, amount);
|
|
335
|
+
vm.prank(payer);
|
|
336
|
+
token.approve(address(routerTerminal), amount);
|
|
310
337
|
|
|
311
|
-
// Mock safeIncreaseAllowance:
|
|
312
|
-
vm.mockCall(
|
|
313
|
-
tokenIn, abi.encodeCall(IERC20.allowance, (address(routerTerminal), mockTerminal)), abi.encode(uint256(0))
|
|
314
|
-
);
|
|
338
|
+
// Mock safeIncreaseAllowance: the router terminal approves the dest terminal.
|
|
315
339
|
vm.mockCall(tokenIn, abi.encodeCall(IERC20.approve, (mockTerminal, amount)), abi.encode(true));
|
|
316
340
|
|
|
317
341
|
// Mock dest terminal pay.
|
|
@@ -412,12 +436,12 @@ contract RouterTerminalTest is Test {
|
|
|
412
436
|
|
|
413
437
|
function test_addToBalanceOf_directForward() public {
|
|
414
438
|
uint256 projectId = 1;
|
|
415
|
-
|
|
439
|
+
MockERC20 token = new MockERC20();
|
|
440
|
+
address tokenIn = address(token);
|
|
416
441
|
uint256 amount = 500;
|
|
417
442
|
address payer = makeAddr("payer");
|
|
418
443
|
address mockTerminal = makeAddr("destTerminal");
|
|
419
444
|
vm.etch(mockTerminal, hex"00");
|
|
420
|
-
vm.etch(tokenIn, hex"00");
|
|
421
445
|
|
|
422
446
|
// Not a JB token.
|
|
423
447
|
vm.mockCall(address(mockTokens), abi.encodeWithSelector(IJBTokens.projectIdOf.selector), abi.encode(uint256(0)));
|
|
@@ -429,16 +453,12 @@ contract RouterTerminalTest is Test {
|
|
|
429
453
|
abi.encode(mockTerminal)
|
|
430
454
|
);
|
|
431
455
|
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
vm.
|
|
435
|
-
|
|
436
|
-
);
|
|
456
|
+
// Mint tokens to payer and approve the router terminal.
|
|
457
|
+
token.mint(payer, amount);
|
|
458
|
+
vm.prank(payer);
|
|
459
|
+
token.approve(address(routerTerminal), amount);
|
|
437
460
|
|
|
438
|
-
// Mock safeIncreaseAllowance.
|
|
439
|
-
vm.mockCall(
|
|
440
|
-
tokenIn, abi.encodeCall(IERC20.allowance, (address(routerTerminal), mockTerminal)), abi.encode(uint256(0))
|
|
441
|
-
);
|
|
461
|
+
// Mock safeIncreaseAllowance: the router terminal approves the dest terminal.
|
|
442
462
|
vm.mockCall(tokenIn, abi.encodeCall(IERC20.approve, (mockTerminal, amount)), abi.encode(true));
|
|
443
463
|
|
|
444
464
|
// Mock dest terminal addToBalanceOf.
|
|
@@ -917,49 +937,46 @@ contract RouterTerminalTest is Test {
|
|
|
917
937
|
|
|
918
938
|
/// @notice cashOutMinReclaimed metadata should be forwarded to the cashout terminal (fix #4).
|
|
919
939
|
function test_pay_cashOutMinReclaimedMetadata() public {
|
|
920
|
-
|
|
940
|
+
MockERC20 jbTokenMock = new MockERC20();
|
|
941
|
+
address jbToken = address(jbTokenMock);
|
|
921
942
|
address payer = makeAddr("payer");
|
|
922
|
-
address jbToken = makeAddr("jbToken");
|
|
923
|
-
vm.etch(jbToken, hex"00");
|
|
924
|
-
uint256 sourceProjectId = 2;
|
|
925
|
-
uint256 amount = 100e18;
|
|
926
|
-
uint256 minReclaimed = 50e18;
|
|
927
943
|
address mockTerminal = makeAddr("destTerminal");
|
|
928
|
-
vm.etch(mockTerminal, hex"00");
|
|
929
944
|
address mockCashOutTerminal = makeAddr("cashOutTerminal");
|
|
945
|
+
vm.etch(mockTerminal, hex"00");
|
|
930
946
|
vm.etch(mockCashOutTerminal, hex"00");
|
|
931
947
|
|
|
932
948
|
// Build metadata with cashOutMinReclaimed — must use router address for getId.
|
|
933
|
-
|
|
934
|
-
|
|
949
|
+
bytes memory metadata;
|
|
950
|
+
{
|
|
951
|
+
bytes4 metadataId = JBMetadataResolver.getId("cashOutMinReclaimed", address(routerTerminal));
|
|
952
|
+
metadata = JBMetadataResolver.addToMetadata("", metadataId, abi.encode(uint256(50e18)));
|
|
953
|
+
}
|
|
935
954
|
|
|
936
|
-
// jbToken is a JB project token for sourceProjectId.
|
|
955
|
+
// jbToken is a JB project token for sourceProjectId (2).
|
|
937
956
|
vm.mockCall(
|
|
938
|
-
address(mockTokens), abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(jbToken))), abi.encode(
|
|
957
|
+
address(mockTokens), abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(jbToken))), abi.encode(uint256(2))
|
|
939
958
|
);
|
|
940
959
|
|
|
941
|
-
// Dest project accepts NATIVE_TOKEN.
|
|
960
|
+
// Dest project (1) accepts NATIVE_TOKEN.
|
|
942
961
|
vm.mockCall(
|
|
943
962
|
address(mockDirectory),
|
|
944
|
-
abi.encodeCall(IJBDirectory.primaryTerminalOf, (
|
|
963
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (1, JBConstants.NATIVE_TOKEN)),
|
|
945
964
|
abi.encode(mockTerminal)
|
|
946
965
|
);
|
|
947
966
|
|
|
948
967
|
// Dest project doesn't accept jbToken directly.
|
|
949
968
|
vm.mockCall(
|
|
950
|
-
address(mockDirectory),
|
|
951
|
-
abi.encodeCall(IJBDirectory.primaryTerminalOf, (destProjectId, jbToken)),
|
|
952
|
-
abi.encode(address(0))
|
|
969
|
+
address(mockDirectory), abi.encodeCall(IJBDirectory.primaryTerminalOf, (1, jbToken)), abi.encode(address(0))
|
|
953
970
|
);
|
|
954
971
|
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
972
|
+
{
|
|
973
|
+
// Source project's terminals (for _findCashOutPath).
|
|
974
|
+
IJBTerminal[] memory sourceTerminals = new IJBTerminal[](1);
|
|
975
|
+
sourceTerminals[0] = IJBTerminal(mockCashOutTerminal);
|
|
976
|
+
vm.mockCall(
|
|
977
|
+
address(mockDirectory), abi.encodeCall(IJBDirectory.terminalsOf, (2)), abi.encode(sourceTerminals)
|
|
978
|
+
);
|
|
979
|
+
}
|
|
963
980
|
|
|
964
981
|
// Mock supportsInterface for IJBCashOutTerminal.
|
|
965
982
|
vm.mockCall(
|
|
@@ -968,52 +985,53 @@ contract RouterTerminalTest is Test {
|
|
|
968
985
|
abi.encode(true)
|
|
969
986
|
);
|
|
970
987
|
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
988
|
+
{
|
|
989
|
+
// Accounting context: source project terminal accepts NATIVE_TOKEN.
|
|
990
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
991
|
+
contexts[0] = JBAccountingContext({
|
|
992
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
993
|
+
});
|
|
994
|
+
vm.mockCall(
|
|
995
|
+
mockCashOutTerminal, abi.encodeCall(IJBTerminal.accountingContextsOf, (2)), abi.encode(contexts)
|
|
996
|
+
);
|
|
997
|
+
}
|
|
981
998
|
|
|
982
999
|
// Mock cashOutTokensOf — use broad selector matching since vm.mockCall matches by prefix.
|
|
983
|
-
// The key assertion: the call should use minReclaimed (not 0).
|
|
984
1000
|
vm.mockCall(
|
|
985
1001
|
mockCashOutTerminal,
|
|
986
1002
|
abi.encodeWithSelector(IJBCashOutTerminal.cashOutTokensOf.selector),
|
|
987
1003
|
abi.encode(uint256(60e18))
|
|
988
1004
|
);
|
|
989
1005
|
|
|
990
|
-
// Expect the specific cashOutTokensOf call with minReclaimed =
|
|
1006
|
+
// Expect the specific cashOutTokensOf call with minReclaimed = 50e18.
|
|
991
1007
|
vm.expectCall(
|
|
992
1008
|
mockCashOutTerminal,
|
|
993
1009
|
abi.encodeCall(
|
|
994
1010
|
IJBCashOutTerminal.cashOutTokensOf,
|
|
995
1011
|
(
|
|
996
1012
|
address(routerTerminal),
|
|
997
|
-
sourceProjectId
|
|
998
|
-
amount
|
|
1013
|
+
2, // sourceProjectId
|
|
1014
|
+
100e18, // amount
|
|
999
1015
|
JBConstants.NATIVE_TOKEN,
|
|
1000
|
-
minReclaimed
|
|
1016
|
+
50e18, // minReclaimed
|
|
1001
1017
|
payable(address(routerTerminal)),
|
|
1002
1018
|
bytes("")
|
|
1003
1019
|
)
|
|
1004
1020
|
)
|
|
1005
1021
|
);
|
|
1006
1022
|
|
|
1007
|
-
//
|
|
1008
|
-
|
|
1009
|
-
vm.
|
|
1010
|
-
|
|
1011
|
-
|
|
1023
|
+
// Mint jbToken to payer and approve the router terminal (L-33: _acceptFundsFor uses balanceOf delta).
|
|
1024
|
+
jbTokenMock.mint(payer, 100e18);
|
|
1025
|
+
vm.prank(payer);
|
|
1026
|
+
jbTokenMock.approve(address(routerTerminal), 100e18);
|
|
1027
|
+
|
|
1028
|
+
// Fund the router terminal with ETH to cover the cashout reclaim (NATIVE_TOKEN).
|
|
1029
|
+
vm.deal(address(routerTerminal), 60e18);
|
|
1012
1030
|
|
|
1013
1031
|
// Mock dest terminal pay.
|
|
1014
1032
|
vm.mockCall(mockTerminal, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(10)));
|
|
1015
1033
|
|
|
1016
1034
|
vm.prank(payer);
|
|
1017
|
-
routerTerminal.pay(
|
|
1035
|
+
routerTerminal.pay(1, jbToken, 100e18, payer, 0, "", metadata);
|
|
1018
1036
|
}
|
|
1019
1037
|
}
|
|
@@ -196,7 +196,7 @@ contract RouterTerminalRegistryTest is Test {
|
|
|
196
196
|
registry.setTerminalFor(projectId, terminalA);
|
|
197
197
|
|
|
198
198
|
vm.prank(projectOwner);
|
|
199
|
-
registry.lockTerminalFor(projectId);
|
|
199
|
+
registry.lockTerminalFor(projectId, terminalA);
|
|
200
200
|
|
|
201
201
|
// Try to change.
|
|
202
202
|
vm.prank(projectOwner);
|
|
@@ -231,7 +231,7 @@ contract RouterTerminalRegistryTest is Test {
|
|
|
231
231
|
);
|
|
232
232
|
|
|
233
233
|
vm.prank(projectOwner);
|
|
234
|
-
registry.lockTerminalFor(projectId);
|
|
234
|
+
registry.lockTerminalFor(projectId, terminalA);
|
|
235
235
|
|
|
236
236
|
assertTrue(registry.hasLockedTerminal(projectId));
|
|
237
237
|
// Should have snapshotted the default as the project's terminal.
|
|
@@ -258,7 +258,7 @@ contract RouterTerminalRegistryTest is Test {
|
|
|
258
258
|
vm.expectRevert(
|
|
259
259
|
abi.encodeWithSelector(JBRouterTerminalRegistry.JBRouterTerminalRegistry_TerminalNotSet.selector, projectId)
|
|
260
260
|
);
|
|
261
|
-
registry.lockTerminalFor(projectId);
|
|
261
|
+
registry.lockTerminalFor(projectId, terminalA);
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
// ──────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
7
|
+
import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
|
|
8
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
9
|
+
import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
|
|
10
|
+
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
|
|
11
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
12
|
+
|
|
13
|
+
import {JBRouterTerminalRegistry} from "../../src/JBRouterTerminalRegistry.sol";
|
|
14
|
+
|
|
15
|
+
/// @notice Regression test for L-29: lockTerminalFor should revert if current terminal doesn't match expected.
|
|
16
|
+
contract L29_LockTerminalRaceTest is Test {
|
|
17
|
+
JBRouterTerminalRegistry registry;
|
|
18
|
+
|
|
19
|
+
IJBPermissions permissions = IJBPermissions(makeAddr("permissions"));
|
|
20
|
+
IJBProjects projects = IJBProjects(makeAddr("projects"));
|
|
21
|
+
IPermit2 permit2 = IPermit2(makeAddr("permit2"));
|
|
22
|
+
address owner = makeAddr("owner");
|
|
23
|
+
address trustedForwarder = makeAddr("trustedForwarder");
|
|
24
|
+
|
|
25
|
+
IJBTerminal terminalA = IJBTerminal(makeAddr("terminalA"));
|
|
26
|
+
IJBTerminal terminalB = IJBTerminal(makeAddr("terminalB"));
|
|
27
|
+
|
|
28
|
+
uint256 projectId = 1;
|
|
29
|
+
address projectOwner = makeAddr("projectOwner");
|
|
30
|
+
|
|
31
|
+
function setUp() public {
|
|
32
|
+
registry = new JBRouterTerminalRegistry(permissions, projects, permit2, owner, trustedForwarder);
|
|
33
|
+
|
|
34
|
+
// Allow both terminals.
|
|
35
|
+
vm.startPrank(owner);
|
|
36
|
+
registry.setDefaultTerminal(terminalA);
|
|
37
|
+
registry.allowTerminal(terminalB);
|
|
38
|
+
vm.stopPrank();
|
|
39
|
+
|
|
40
|
+
// Mock permissions.
|
|
41
|
+
vm.mockCall(address(projects), abi.encodeCall(IERC721.ownerOf, (projectId)), abi.encode(projectOwner));
|
|
42
|
+
vm.mockCall(
|
|
43
|
+
address(permissions),
|
|
44
|
+
abi.encodeWithSignature(
|
|
45
|
+
"hasPermission(address,address,uint256,uint256,bool,bool)",
|
|
46
|
+
projectOwner,
|
|
47
|
+
projectOwner,
|
|
48
|
+
projectId,
|
|
49
|
+
JBPermissionIds.SET_ROUTER_TERMINAL,
|
|
50
|
+
true,
|
|
51
|
+
true
|
|
52
|
+
),
|
|
53
|
+
abi.encode(true)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// @notice Locking with the correct expected terminal should succeed.
|
|
58
|
+
function test_lockTerminalFor_succeedsWithCorrectExpected() public {
|
|
59
|
+
vm.prank(projectOwner);
|
|
60
|
+
registry.lockTerminalFor(projectId, terminalA);
|
|
61
|
+
|
|
62
|
+
assertTrue(registry.hasLockedTerminal(projectId));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// @notice Locking with the wrong expected terminal should revert.
|
|
66
|
+
function test_lockTerminalFor_revertsWithWrongExpected() public {
|
|
67
|
+
// Project has no explicit terminal, so it resolves to defaultTerminal (terminalA).
|
|
68
|
+
// Trying to lock with terminalB should revert.
|
|
69
|
+
vm.prank(projectOwner);
|
|
70
|
+
vm.expectRevert(
|
|
71
|
+
abi.encodeWithSelector(
|
|
72
|
+
JBRouterTerminalRegistry.JBRouterTerminalRegistry_TerminalMismatch.selector, terminalA, terminalB
|
|
73
|
+
)
|
|
74
|
+
);
|
|
75
|
+
registry.lockTerminalFor(projectId, terminalB);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// @notice If a project has an explicit terminal set, locking with the wrong one reverts.
|
|
79
|
+
function test_lockTerminalFor_revertsWhenExplicitTerminalMismatch() public {
|
|
80
|
+
// Set terminalB for the project.
|
|
81
|
+
vm.prank(projectOwner);
|
|
82
|
+
registry.setTerminalFor(projectId, terminalB);
|
|
83
|
+
|
|
84
|
+
// Try to lock expecting terminalA -- should revert.
|
|
85
|
+
vm.prank(projectOwner);
|
|
86
|
+
vm.expectRevert(
|
|
87
|
+
abi.encodeWithSelector(
|
|
88
|
+
JBRouterTerminalRegistry.JBRouterTerminalRegistry_TerminalMismatch.selector, terminalB, terminalA
|
|
89
|
+
)
|
|
90
|
+
);
|
|
91
|
+
registry.lockTerminalFor(projectId, terminalA);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBCashOutTerminal} from "@bananapus/core-v6/src/interfaces/IJBCashOutTerminal.sol";
|
|
7
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
8
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
9
|
+
import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
|
|
10
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
11
|
+
import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
|
|
12
|
+
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
13
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
14
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
15
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
|
16
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
17
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
18
|
+
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
|
19
|
+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
|
|
20
|
+
|
|
21
|
+
import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
|
|
22
|
+
import {IJBRouterTerminal} from "../../src/interfaces/IJBRouterTerminal.sol";
|
|
23
|
+
import {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
24
|
+
|
|
25
|
+
/// @notice Minimal ERC20 mock for balance-delta accounting in _acceptFundsFor (L-33).
|
|
26
|
+
contract MockToken {
|
|
27
|
+
mapping(address => uint256) public balanceOf;
|
|
28
|
+
mapping(address => mapping(address => uint256)) public allowance;
|
|
29
|
+
|
|
30
|
+
function mint(address to, uint256 amount) external {
|
|
31
|
+
balanceOf[to] += amount;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function approve(address spender, uint256 amount) external returns (bool) {
|
|
35
|
+
allowance[msg.sender][spender] = amount;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function transfer(address to, uint256 amount) external returns (bool) {
|
|
40
|
+
balanceOf[msg.sender] -= amount;
|
|
41
|
+
balanceOf[to] += amount;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
|
|
46
|
+
allowance[from][msg.sender] -= amount;
|
|
47
|
+
balanceOf[from] -= amount;
|
|
48
|
+
balanceOf[to] += amount;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// @notice Regression test for L-30: _cashOutLoop should revert with CashOutLoopLimit when circular token
|
|
54
|
+
/// dependencies cause more than 20 iterations, instead of consuming all gas.
|
|
55
|
+
contract L30_CashOutLoopLimitTest is Test {
|
|
56
|
+
JBRouterTerminal routerTerminal;
|
|
57
|
+
|
|
58
|
+
IJBDirectory directory = IJBDirectory(makeAddr("directory"));
|
|
59
|
+
IJBPermissions permissions = IJBPermissions(makeAddr("permissions"));
|
|
60
|
+
IJBProjects projects = IJBProjects(makeAddr("projects"));
|
|
61
|
+
IJBTokens tokens = IJBTokens(makeAddr("tokens"));
|
|
62
|
+
IPermit2 permit2 = IPermit2(makeAddr("permit2"));
|
|
63
|
+
IWETH9 weth = IWETH9(makeAddr("weth"));
|
|
64
|
+
IUniswapV3Factory factory = IUniswapV3Factory(makeAddr("factory"));
|
|
65
|
+
address owner = makeAddr("owner");
|
|
66
|
+
|
|
67
|
+
address payer = makeAddr("payer");
|
|
68
|
+
address mockTerminal = makeAddr("mockTerminal");
|
|
69
|
+
|
|
70
|
+
// Two JB project tokens that form a cycle: tokenA -> tokenB -> tokenA -> ...
|
|
71
|
+
MockToken tokenA;
|
|
72
|
+
MockToken tokenB;
|
|
73
|
+
|
|
74
|
+
uint256 constant DEST_PROJECT_ID = 99;
|
|
75
|
+
uint256 constant PROJECT_A_ID = 10;
|
|
76
|
+
uint256 constant PROJECT_B_ID = 20;
|
|
77
|
+
|
|
78
|
+
function setUp() public {
|
|
79
|
+
vm.etch(address(directory), hex"00");
|
|
80
|
+
vm.etch(address(permissions), hex"00");
|
|
81
|
+
vm.etch(address(projects), hex"00");
|
|
82
|
+
vm.etch(address(tokens), hex"00");
|
|
83
|
+
vm.etch(address(permit2), hex"00");
|
|
84
|
+
vm.etch(address(weth), hex"00");
|
|
85
|
+
vm.etch(address(factory), hex"00");
|
|
86
|
+
vm.etch(mockTerminal, hex"00");
|
|
87
|
+
|
|
88
|
+
routerTerminal = new JBRouterTerminal(
|
|
89
|
+
directory,
|
|
90
|
+
permissions,
|
|
91
|
+
projects,
|
|
92
|
+
tokens,
|
|
93
|
+
permit2,
|
|
94
|
+
owner,
|
|
95
|
+
weth,
|
|
96
|
+
factory,
|
|
97
|
+
IPoolManager(address(0)), // no V4
|
|
98
|
+
address(0)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
tokenA = new MockToken();
|
|
102
|
+
tokenB = new MockToken();
|
|
103
|
+
|
|
104
|
+
// tokenA is the JB project token for PROJECT_A_ID.
|
|
105
|
+
vm.mockCall(
|
|
106
|
+
address(tokens),
|
|
107
|
+
abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(address(tokenA)))),
|
|
108
|
+
abi.encode(PROJECT_A_ID)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// tokenB is the JB project token for PROJECT_B_ID.
|
|
112
|
+
vm.mockCall(
|
|
113
|
+
address(tokens),
|
|
114
|
+
abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(address(tokenB)))),
|
|
115
|
+
abi.encode(PROJECT_B_ID)
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Destination project does not accept tokenA or tokenB directly.
|
|
119
|
+
vm.mockCall(
|
|
120
|
+
address(directory),
|
|
121
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (DEST_PROJECT_ID, address(tokenA))),
|
|
122
|
+
abi.encode(address(0))
|
|
123
|
+
);
|
|
124
|
+
vm.mockCall(
|
|
125
|
+
address(directory),
|
|
126
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (DEST_PROJECT_ID, address(tokenB))),
|
|
127
|
+
abi.encode(address(0))
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// --- Circular cashout path setup ---
|
|
131
|
+
// Project A's terminal cashes out to tokenB.
|
|
132
|
+
_setupCashOutTerminal(PROJECT_A_ID, address(tokenB));
|
|
133
|
+
// Project B's terminal cashes out to tokenA.
|
|
134
|
+
_setupCashOutTerminal(PROJECT_B_ID, address(tokenA));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _setupCashOutTerminal(uint256 projectId, address reclaimToken) internal {
|
|
138
|
+
address terminal = makeAddr(string(abi.encodePacked("terminal", projectId)));
|
|
139
|
+
vm.etch(terminal, hex"00");
|
|
140
|
+
|
|
141
|
+
// Register as the project's terminal.
|
|
142
|
+
IJBTerminal[] memory terminalList = new IJBTerminal[](1);
|
|
143
|
+
terminalList[0] = IJBTerminal(terminal);
|
|
144
|
+
vm.mockCall(address(directory), abi.encodeCall(IJBDirectory.terminalsOf, (projectId)), abi.encode(terminalList));
|
|
145
|
+
|
|
146
|
+
// Supports IJBCashOutTerminal.
|
|
147
|
+
vm.mockCall(
|
|
148
|
+
terminal,
|
|
149
|
+
abi.encodeCall(IERC165.supportsInterface, (type(IJBCashOutTerminal).interfaceId)),
|
|
150
|
+
abi.encode(true)
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Accounting context: terminal accepts the reclaim token.
|
|
154
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
155
|
+
contexts[0] = JBAccountingContext({token: reclaimToken, decimals: 18, currency: uint32(uint160(reclaimToken))});
|
|
156
|
+
vm.mockCall(terminal, abi.encodeCall(IJBTerminal.accountingContextsOf, (projectId)), abi.encode(contexts));
|
|
157
|
+
|
|
158
|
+
// cashOutTokensOf returns 1e18 each time (simulating a successful cashout).
|
|
159
|
+
vm.mockCall(
|
|
160
|
+
terminal, abi.encodeWithSelector(IJBCashOutTerminal.cashOutTokensOf.selector), abi.encode(uint256(1e18))
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// @notice A circular cashout chain (A -> B -> A -> ...) must revert with CashOutLoopLimit, not OOG.
|
|
165
|
+
function test_cashOutLoop_revertsOnCircularDependency() public {
|
|
166
|
+
uint256 amount = 10e18;
|
|
167
|
+
|
|
168
|
+
// Mint tokenA to payer and approve.
|
|
169
|
+
tokenA.mint(payer, amount);
|
|
170
|
+
vm.prank(payer);
|
|
171
|
+
tokenA.approve(address(routerTerminal), amount);
|
|
172
|
+
|
|
173
|
+
// Expect the specific CashOutLoopLimit revert.
|
|
174
|
+
vm.expectRevert(abi.encodeWithSelector(IJBRouterTerminal.JBRouterTerminal_CashOutLoopLimit.selector));
|
|
175
|
+
|
|
176
|
+
vm.prank(payer);
|
|
177
|
+
routerTerminal.pay(DEST_PROJECT_ID, address(tokenA), amount, payer, 0, "", "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/// @notice A non-circular path within the iteration cap should succeed normally.
|
|
181
|
+
function test_cashOutLoop_succeedsWithinLimit() public {
|
|
182
|
+
uint256 amount = 10e18;
|
|
183
|
+
address baseToken = makeAddr("baseToken");
|
|
184
|
+
vm.etch(baseToken, hex"00");
|
|
185
|
+
|
|
186
|
+
// Override: tokenB is NOT a JB token (breaks the cycle).
|
|
187
|
+
vm.mockCall(
|
|
188
|
+
address(tokens), abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(address(tokenB)))), abi.encode(uint256(0))
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Dest project accepts baseToken via a terminal (so the router can swap tokenB -> baseToken).
|
|
192
|
+
// For simplicity: dest accepts tokenB directly.
|
|
193
|
+
vm.mockCall(
|
|
194
|
+
address(directory),
|
|
195
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (DEST_PROJECT_ID, address(tokenB))),
|
|
196
|
+
abi.encode(mockTerminal)
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
// Mock dest terminal pay.
|
|
200
|
+
vm.mockCall(mockTerminal, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(5)));
|
|
201
|
+
|
|
202
|
+
// Mint and approve tokenA.
|
|
203
|
+
tokenA.mint(payer, amount);
|
|
204
|
+
vm.prank(payer);
|
|
205
|
+
tokenA.approve(address(routerTerminal), amount);
|
|
206
|
+
|
|
207
|
+
vm.prank(payer);
|
|
208
|
+
uint256 result = routerTerminal.pay(DEST_PROJECT_ID, address(tokenA), amount, payer, 0, "", "");
|
|
209
|
+
assertEq(result, 5);
|
|
210
|
+
}
|
|
211
|
+
}
|