@bananapus/router-terminal-v6 0.0.52 → 0.0.54

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
@@ -126,7 +126,7 @@ script/
126
126
  - swap previews are best-effort estimates and depend on current pool state plus caller-supplied quote data
127
127
  - recursive cash-out routing increases complexity when the input token is itself a Juicebox project token
128
128
  - slippage and sandwich resistance depend on the quality of the chosen quote path
129
- - final terminal-facing ERC-20 hops must be standard tokens; lossy terminal pulls are rejected
129
+ - `addToBalanceOf` rejects lossy terminal pulls; `pay` cannot reliably detect final-hop fee-on-transfer loss because pay hooks can consume tokens during settlement
130
130
 
131
131
  The most common reader mistake here is to stop at the router and forget to inspect the terminal that actually receives the value.
132
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/router-terminal-v6",
3
- "version": "0.0.52",
3
+ "version": "0.0.54",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,7 +9,7 @@
9
9
  ## Change Checklist
10
10
 
11
11
  - If you edit route discovery, verify both direct acceptance and swap-based routes.
12
- - If you edit the cash-out loop, check credit-based flows and fork tests, not just simple payments.
12
+ - If you edit the cash-out loop, check project-token cash-out flows and fork tests, not only simple payments.
13
13
  - If you edit slippage or quote logic, inspect [`src/JBPayRouteResolver.sol`](../src/JBPayRouteResolver.sol) and the preview tests together.
14
14
  - If you edit preview behavior, verify route ranking still normalizes buyback-hook hints and still agrees with execution.
15
15
  - If you edit refund or partial-fill handling, verify baseline snapshots and destination-terminal receipt enforcement together.
@@ -21,7 +21,7 @@
21
21
  - Preview output drifts from execution because quote and execution paths were edited independently.
22
22
  - Registry state makes a project use a different router than expected.
23
23
  - Metadata overrides force an output token or cash-out source that the caller did not intend.
24
- - A terminal-facing ERC-20 path reverts because the destination terminal did not actually receive the nominal amount. This now indicates a non-standard final-hop token path, not just a documentation caveat.
24
+ - On `addToBalanceOf` paths, a terminal-facing ERC-20 receipt mismatch indicates a non-standard final-hop token path.
25
25
 
26
26
  ## Useful Proof Points
27
27
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  ## Runtime Path
10
10
 
11
- 1. The router accepts funds or credits.
11
+ 1. The router accepts native tokens, ERC-20s, or claimed Juicebox project-token ERC-20s.
12
12
  2. If the input is a Juicebox project token, the router may enter a cash-out loop first.
13
13
  3. The router resolves the desired output token using direct acceptance, wrap/unwrap equivalence, metadata overrides, or pool discovery.
14
14
  4. It converts value through direct forwarding, wrap/unwrap, Uniswap V3, or Uniswap V4.
@@ -17,18 +17,18 @@
17
17
  ## High-Risk Areas
18
18
 
19
19
  - Preview and execution parity: changes to quote selection or route discovery should be checked against both preview and mutative paths.
20
- - V4 discovery scope: the router now searches both vanilla V4 pools and pools using the configured canonical `UNIV4_HOOK`.
20
+ - V4 discovery scope: the router searches both vanilla V4 pools and pools using the configured canonical `UNIV4_HOOK`.
21
21
  - Cash-out loop behavior: recursive routing through project tokens can create subtle loop or slippage issues.
22
22
  - Callback validation: V3 and V4 callback guards are security-critical and should not drift.
23
23
  - Leftover/refund handling: refunds can route to the original payer or fallback recipient depending on context.
24
24
  - Dynamic accounting contexts: this repo intentionally synthesizes accounting contexts instead of storing a static token list.
25
- - Final terminal-facing ERC-20 receipt enforcement: the router rejects lossy terminal pulls, so terminal mocks and integrations must behave like standard pull-based ERC-20 receivers. The registry does not independently enforce receipts; it relies on the router.
25
+ - Final terminal-facing ERC-20 receipt enforcement: `addToBalanceOf` rejects lossy terminal pulls, while `pay` does not enforce receipt deltas because pay hooks can consume tokens during settlement. The registry does not independently enforce receipts; it relies on the router path it forwards into.
26
26
  - Preview normalization: buyback-hook metadata can improve the user-visible preview outcome, so route ranking must normalize hook-returned hints consistently across candidates.
27
27
 
28
28
  ## Tests To Trust First
29
29
 
30
30
  - [`test/RouterTerminalPreviewFork.t.sol`](../test/RouterTerminalPreviewFork.t.sol) for preview-path behavior.
31
- - [`test/RouterTerminalCashOutFork.t.sol`](../test/RouterTerminalCashOutFork.t.sol) and [`test/RouterTerminalCreditCashout.t.sol`](../test/RouterTerminalCreditCashout.t.sol) for cash-out routing.
31
+ - [`test/RouterTerminalCashOutFork.t.sol`](../test/RouterTerminalCashOutFork.t.sol) and [`test/RouterTerminalFeeCashOutFork.t.sol`](../test/RouterTerminalFeeCashOutFork.t.sol) for project-token cash-out routing.
32
32
  - [`test/RouterTerminalReentrancy.t.sol`](../test/RouterTerminalReentrancy.t.sol) for callback and reentrancy-sensitive behavior.
33
33
  - [`test/RouterTerminalFork.t.sol`](../test/RouterTerminalFork.t.sol), [`test/RouterTerminalMultihopFork.t.sol`](../test/RouterTerminalMultihopFork.t.sol), and [`test/invariant/RouterTerminalInvariant.t.sol`](../test/invariant/RouterTerminalInvariant.t.sol) for live routing assumptions.
34
34
  - [`test/regression/CashOutCircularPrimaryTerminal.t.sol`](../test/regression/CashOutCircularPrimaryTerminal.t.sol), [`test/regression/CashOutFallbackPrefersRecursiveLoop.t.sol`](../test/regression/CashOutFallbackPrefersRecursiveLoop.t.sol), [`test/regression/LeftoverRefund.t.sol`](../test/regression/LeftoverRefund.t.sol), and [`test/regression/PreviewPrimaryTerminalMismatch.t.sol`](../test/regression/PreviewPrimaryTerminalMismatch.t.sol) for the misdiagnosis-prone edge cases.
@@ -172,7 +172,7 @@ contract DeployScript is Script, Sphinx {
172
172
  trustedForwarder: trustedForwarder
173
173
  });
174
174
 
175
- // Deploy the router terminal with chain-same CREATE2 inputs; chain-specific constants
175
+ // Deploy the router terminal with chain-identical CREATE2 inputs; chain-specific constants
176
176
  // (WETH + Uniswap V3 factory + V4 PoolManager + V4 hook) are wired afterwards via the
177
177
  // DEPLOYER-gated one-shot setChainSpecificConstants setter on the terminal.
178
178
  require(address(buyback.hook) != address(0), "RouterTerminal: missing buyback hook");
@@ -39,9 +39,9 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
39
39
  /// @param directory The directory storing project terminal relationships.
40
40
  /// @dev The wrapped-native-token address is intentionally NOT cached here. The router passes it in as a parameter
41
41
  /// (`address wrappedNativeToken`) on every external resolver call and the resolver threads it through internal
42
- /// helpers. This keeps the resolver's constructor inputs chain-same (no chain-specific WETH baked in) so its
43
- /// CREATE address (router + nonce 1) stays unified, AND avoids paying an extra external call per normalization
44
- /// step inside loops like `_discoverAcceptedToken`.
42
+ /// helpers. This keeps the resolver's constructor inputs byte-identical across chains (no chain-specific WETH
43
+ /// baked in), so its deployed address (router + nonce 1) stays unified and avoids an extra external call per
44
+ /// normalization step inside loops like `_discoverAcceptedToken`.
45
45
  constructor(IJBDirectory directory) {
46
46
  DIRECTORY = directory;
47
47
  }
@@ -87,6 +87,7 @@ contract JBRouterTerminal is
87
87
  address terminal, address token, uint256 expectedAmount, uint256 actualAmount
88
88
  );
89
89
  error JBRouterTerminal_PermitAllowanceNotEnough(uint256 amount, uint256 allowance);
90
+ error JBRouterTerminal_QuoteTokenMismatch(address quotedTokenOut, address expectedTokenOut);
90
91
  error JBRouterTerminal_SlippageExceeded(uint256 amountOut, uint256 minAmountOut);
91
92
  error JBRouterTerminal_Unauthorized(address caller);
92
93
 
@@ -121,8 +122,8 @@ contract JBRouterTerminal is
121
122
  //*********************************************************************//
122
123
 
123
124
  /// @notice The canonical buyback hook whose preview hook specification metadata this router understands.
124
- /// @dev Chain-same: `JBBuybackHook` is deployed via CREATE2 to a unified address on every chain, so this stays
125
- /// `immutable` without breaking the router's own chain-same CREATE2 address.
125
+ /// @dev `JBBuybackHook` is deployed via CREATE2 to a unified address on every chain, so this stays
126
+ /// `immutable` without breaking the router's own chain-identical CREATE2 address.
126
127
  address public immutable BUYBACK_HOOK;
127
128
 
128
129
  /// @notice The directory of terminals and controllers for projects.
@@ -144,10 +145,10 @@ contract JBRouterTerminal is
144
145
  address internal immutable _DEPLOYER;
145
146
 
146
147
  /// @notice The helper contract used to resolve best pay-route previews without bloating router runtime size.
147
- /// @dev Deployed in the constructor with chain-same inputs (just `directory` — the resolver does NOT cache
148
+ /// @dev Deployed in the constructor with chain-identical inputs (only `directory` — the resolver does NOT cache
148
149
  /// `wrappedNativeToken` locally; the router passes it in on every external resolver call as a parameter to
149
- /// avoid an extra external call on each normalization step). Because this router's address is chain-same via
150
- /// CREATE2 and the resolver is deployed at the router's nonce 1, the resolver's address is chain-same too.
150
+ /// avoid an extra external call on each normalization step). Because this router's address is unified via
151
+ /// CREATE2 and the resolver is deployed at the router's nonce 1, the resolver's address is unified too.
151
152
  IJBPayRouteResolver internal immutable _PAY_ROUTE_RESOLVER;
152
153
 
153
154
  /// @notice Pre-computed metadata ID for "permit2".
@@ -196,7 +197,7 @@ contract JBRouterTerminal is
196
197
  /// @param directory A contract storing directories of terminals and controllers for each project.
197
198
  /// @param tokens A contract managing project token balances.
198
199
  /// @param permit2 A permit2 utility.
199
- /// @param buybackHook The canonical buyback hook (chain-same across all chains).
200
+ /// @param buybackHook The canonical buyback hook, deployed to the same address on each supported chain.
200
201
  /// @param trustedForwarder The trusted forwarder for the contract.
201
202
  /// @param deployer The address authorized to call `setChainSpecificConstants` exactly once. Held immutable so the
202
203
  /// constructor inputs are byte-identical across chains and the CREATE2 address is unified.
@@ -909,7 +910,7 @@ contract JBRouterTerminal is
909
910
  /// @param tokenIn The token currently available to swap.
910
911
  /// @param tokenOut The token the swap should deliver.
911
912
  /// @param amount The amount of `tokenIn` to preview.
912
- /// @param metadata Metadata that can provide an explicit quote override for the swap.
913
+ /// @param metadata Metadata that can provide an explicit `(tokenOut, minAmountOut)` quote for the swap.
913
914
  /// @return amountOut The predicted amount of `tokenOut` the router would receive.
914
915
  function _previewSwapAmountOut(
915
916
  address tokenIn,
@@ -1096,7 +1097,7 @@ contract JBRouterTerminal is
1096
1097
  }
1097
1098
 
1098
1099
  /// @notice Run the common post-transfer cleanup after forwarding funds into a destination terminal.
1099
- /// @param destTerminal The terminal that just received the forwarded call.
1100
+ /// @param destTerminal The terminal that received the forwarded call.
1100
1101
  /// @param token The token that was forwarded into the destination terminal.
1101
1102
  function _afterTransferFor(IJBTerminal destTerminal, address token) internal {
1102
1103
  // Revoke any leftover allowance the destination terminal did not pull so routed calls do not leave approvals
@@ -1188,6 +1189,9 @@ contract JBRouterTerminal is
1188
1189
  // Pass minTokensReclaimed=0 to the terminal because the buyback hook's sell-side delivers tokens via
1189
1190
  // callback (reclaimAmount=0 from the terminal's perspective), which would fail the terminal's own min
1190
1191
  // check. The router enforces the user's minimum via the balance-delta check below instead.
1192
+ // Still forward the original metadata on the first hop so the source hook can use the same user floor
1193
+ // when choosing between direct cash-out and its own routed cash-out path.
1194
+ bytes memory hopMetadata = i == 0 ? metadata : bytes("");
1191
1195
  cashOutTerminal.cashOutTokensOf({
1192
1196
  holder: address(this),
1193
1197
  projectId: sourceProjectId,
@@ -1195,7 +1199,7 @@ contract JBRouterTerminal is
1195
1199
  tokenToReclaim: tokenToReclaim,
1196
1200
  minTokensReclaimed: 0,
1197
1201
  beneficiary: payable(address(this)),
1198
- metadata: "",
1202
+ metadata: hopMetadata,
1199
1203
  referralProjectId: 0
1200
1204
  });
1201
1205
 
@@ -1258,7 +1262,7 @@ contract JBRouterTerminal is
1258
1262
  address nOut = _normalize(tokenOut);
1259
1263
 
1260
1264
  if (nIn == nOut) {
1261
- // Same underlying token — just wrap or unwrap.
1265
+ // Same underlying token; wrap or unwrap.
1262
1266
  if (tokenIn == JBConstants.NATIVE_TOKEN) _wrapNativeToken(amount);
1263
1267
  else _unwrapNativeToken(amount);
1264
1268
  return amount;
@@ -2260,7 +2264,8 @@ contract JBRouterTerminal is
2260
2264
  ///
2261
2265
  /// Mitigations in place:
2262
2266
  /// 1. Users SHOULD provide a `quoteForSwap` value in the payment metadata (obtained from an off-chain
2263
- /// quoter or RPC simulation). When present, this function is bypassed entirely see `_pickPoolAndQuote`.
2267
+ /// quoter or RPC simulation). The quote must encode the output token and minimum output amount. When present,
2268
+ /// this function is bypassed entirely — see `_pickPoolAndQuote`.
2264
2269
  /// 2. When a hook implements `IGeomeanOracle.observe(...)`, this function uses that oracle-derived tick instead
2265
2270
  /// of spot.
2266
2271
  /// 3. The sigmoid slippage formula (`JBSwapLib.getSlippageTolerance`) enforces a minimum 2% slippage floor
@@ -2299,7 +2304,7 @@ contract JBRouterTerminal is
2299
2304
 
2300
2305
  // If the pool has a hook, try querying it as a geomean oracle (e.g., JBUniswapV4Hook implements this).
2301
2306
  if (address(key.hooks) != address(0)) {
2302
- // Build the two-element lookback array: [_TWAP_WINDOW seconds ago, now].
2307
+ // Build the two-element lookback array: [_TWAP_WINDOW seconds ago, current block time].
2303
2308
  uint32[] memory secondsAgos = new uint32[](2);
2304
2309
  secondsAgos[0] = _TWAP_WINDOW; // Start of the window (120 seconds ago).
2305
2310
  secondsAgos[1] = 0; // End of the window (current block).
@@ -2416,8 +2421,9 @@ contract JBRouterTerminal is
2416
2421
  /// protection. Integrators should still supply `quoteForSwap` metadata whenever they can.
2417
2422
  ///
2418
2423
  /// Priority for `minAmountOut`:
2419
- /// 1. **User-provided quote** — If `quoteForSwap` is present in `metadata`, it is used directly.
2420
- /// This is the recommended path for MEV protection, especially for V4 pools.
2424
+ /// 1. **User-provided quote** — If `quoteForSwap` is present in `metadata`, it is used after confirming the
2425
+ /// quote's output token matches the selected route. This is the recommended path for MEV protection,
2426
+ /// especially for V4 pools.
2421
2427
  /// 2. **V3 TWAP** — If the best pool is V3, uses a manipulation-resistant time-weighted average price.
2422
2428
  /// 3. **V4 automatic quote** — If the best pool is V4, first attempts a hook-provided oracle quote and
2423
2429
  /// otherwise falls back to the instantaneous `getSlot0` tick. The spot fallback is manipulable within the
@@ -2446,11 +2452,19 @@ contract JBRouterTerminal is
2446
2452
  revert JBRouterTerminal_NoPoolFound({tokenIn: normalizedTokenIn, tokenOut: normalizedTokenOut});
2447
2453
  }
2448
2454
 
2449
- // Check for a user-provided quote.
2455
+ // `quoteForSwap` is encoded as `(tokenOut, minAmountOut)`. Binding the quote to its output token prevents
2456
+ // metadata quoted for one route from being replayed against another route with a weaker floor.
2450
2457
  (bool exists, bytes memory quote) = _getDataFor({metadata: metadata, id: _QUOTE_FOR_SWAP_ID});
2451
2458
 
2452
2459
  if (exists) {
2453
- (minAmountOut) = abi.decode(quote, (uint256));
2460
+ (address quotedTokenOut, uint256 quotedMinAmountOut) = abi.decode(quote, (address, uint256));
2461
+ // Normalize ETH/WETH before comparing because pool routes use WETH internally for native-token swaps.
2462
+ if (_normalize(quotedTokenOut) != normalizedTokenOut) {
2463
+ revert JBRouterTerminal_QuoteTokenMismatch({
2464
+ quotedTokenOut: quotedTokenOut, expectedTokenOut: normalizedTokenOut
2465
+ });
2466
+ }
2467
+ minAmountOut = quotedMinAmountOut;
2454
2468
  }
2455
2469
 
2456
2470
  // Treat a decoded value of 0 the same as "not provided" so that a stale or default-zero quote