@bananapus/router-terminal-v6 0.0.30 → 0.0.31

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/RISKS.md CHANGED
@@ -1,113 +1,44 @@
1
- # Router Terminal Risk Register
1
+ # Accepted Security Risks
2
2
 
3
- This file covers the routing, accounting-context, and liquidity-selection risks in the terminal that accepts arbitrary tokens and forwards them into a project's real accounting surface.
3
+ Documented risks that were reviewed and accepted.
4
4
 
5
- ## How To Use This File
5
+ ## Oracle & Slippage Risks
6
6
 
7
- - Read `Priority risks` first. They explain where routing convenience can diverge from accounting truth.
8
- - Use the later sections for token-decimal synthesis, swap-path, and integration reasoning.
9
- - Treat `Accepted behaviors` as explicit statements about what this terminal does not guarantee.
7
+ **Pool-local V3 TWAP trusted as swap floor for permissionless pools.** *(Medium)*
8
+ An attacker could deploy a manipulable pool with higher liquidity to become the selected candidate. Users should provide `quoteForSwap` metadata from off-chain sources. Mitigated by 120s minimum TWAP window and sigmoid slippage formula.
10
9
 
11
- ## Priority Risks
10
+ **Liquidity-based pool selection enables unsafe spot quoting.** *(Medium)*
11
+ Pool discovery ranks candidates by instantaneous liquidity, so an attacker could inflate liquidity to force selection of a manipulable pool. Mitigated by V4 TWAP hardening and sigmoid slippage formula. Users should provide off-chain quotes for high-value swaps.
12
12
 
13
- | Priority | Risk | Why it matters | Primary controls |
14
- |----------|------|----------------|------------------|
15
- | P0 | Synthetic accounting context misuse | The router synthesizes best-effort decimals and routing context. If downstream systems treat it as accounting truth, they can misprice or mis-lend. | Clear docs, registry scoping, and explicit prohibition on accounting-sensitive reuse. |
16
- | P1 | Wrong-route or low-liquidity execution | The router chooses among direct forwarding, V3, V4, and cash-out paths. A bad route can degrade user execution. | Route-selection tests, liquidity checks, and user-specified minimum returns. |
17
- | P1 | Integration fragility with broken tokens | Non-standard ERC-20 metadata or transfer behavior can distort decimal inference or swap execution. | Fallback defaults, defensive probing, receipt checks on terminal-facing hops, and hostile-token testing. |
13
+ **Harmonic-mean liquidity inflates V3 slippage tolerance.** *(Medium)*
14
+ `OracleLibrary.consult` returns harmonic-mean liquidity, which can be deflated by brief low-liquidity periods. However, harmonic mean is more resistant to manipulation than spot liquidity. Mitigated by 120s TWAP minimum and 10-minute default observation window.
18
15
 
19
- ## 1. Trust Assumptions
16
+ **`quoteForSwap` / auto-selected tokenOut mismatch.** *(Minor)*
17
+ When a user provides `quoteForSwap` metadata, the quote may not match the auto-selected output token. Frontends should set `quoteForSwap` per the expected output token.
20
18
 
21
- - **Uniswap V3 factory and V4 PoolManager behave correctly.** Pool discovery trusts those external systems to point at real pools.
22
- - **Canonical V4 hook configuration is correct.** If deployers set the wrong `UNIV4_HOOK`, the router can miss intended hooked pools.
23
- - **The trusted forwarder is trustworthy.** A compromised forwarder can initiate transfers on behalf of any user.
24
- - **`JBDirectory` resolves the right terminals.** A compromised directory can redirect funds.
25
- - **Permit2 allowances are intentional.** Stale approvals can be abused.
26
- - **The registry owner acts correctly.** The owner controls allowlisting and the default router terminal.
27
- - **`IJBPayerTracker` callers are trusted to name their own refund recipient.** A caller implementing `originalPayer()` can redirect leftovers from its own route.
19
+ **Multi-hop cashout slippage cleared after first hop.** *(Minor)*
20
+ Only the final output matters; the outer function enforces end-to-end minimum via `minReclaimed`. Intermediate per-hop slippage checks are intentionally omitted.
28
21
 
29
- ## 2. Economic And Manipulation Risks
22
+ **Zero oracle quote disables swap protection.** *(Minor)*
23
+ When the oracle returns zero (no liquidity), slippage tolerance becomes zero. The swap would fail anyway due to lack of liquidity, so this has no practical impact.
30
24
 
31
- - **V4 price manipulation.** `_getV4Quote` tries a 30-second TWAP first. If that fails, it falls back to spot pricing, which is manipulable within the block.
32
- - **Hooked V4 discovery scope.** Auto-discovery checks both vanilla V4 pools and pools using the configured canonical `UNIV4_HOOK`.
33
- - **V3 TWAP manipulation.** Short history reduces manipulation resistance, especially in new pools.
34
- - **Cash-out loop value extraction.** `_cashOutLoop` is capped at 20 iterations. `cashOutMinReclaimed` only applies on the first real cash-out hop.
35
- - **Pre-existing balances are intentionally excluded from route refunds.** Stray balances already sitting in the router are not swept into the next caller's refund.
36
- - **V4 native ETH settlement is special-cased.** `_settleV4` unwraps WETH when the pool manager expects native ETH.
37
- - **Pool selection is liquidity-first.** `_discoverPool` picks the deepest discovered pool, not the globally best execution path.
38
- - **Route selection is heuristic, not best execution.** The router bounds discovery for predictability and gas, not exhaustive optimization.
25
+ > **Note:** The V4 TWAP window was hardened from 30s to 120s. This is no longer an accepted risk -- it was fixed.
39
26
 
40
- ## 3. Access Control
27
+ ## Registry & Forwarding Risks
41
28
 
42
- - **`pay` and `addToBalanceOf` are permissionless.** Anyone can route payments.
43
- - **Lossy terminal-facing ERC-20s are unsupported.** Final forwarded ERC-20 hops must settle exactly on the router path.
44
- - **Credit cash-out path depends on `TRANSFER_CREDITS`.** Broad grants of that permission widen the attack surface.
45
- - **The registry owner controls allowlisting and the global default.** Disallowing the current default now reverts instead of silently clearing it.
46
- - **Registry terminal locking can freeze a bad choice.** `lockTerminalFor` is a one-way commitment.
47
- - **Router accounting contexts are synthetic.** They are safe for discovery, but unsafe as accounting truth for lending, debt, or balance normalization.
29
+ **Registry forwarding uses registry as credit holder.** *(Medium)*
30
+ When payments flow through the registry, credits accrue to the registry address, not the original user. Credit-based cashouts must go directly to the router terminal, not through the registry. This is intentional to prevent `originalPayer()` spoofing attacks.
48
31
 
49
- ## 4. DoS Vectors
32
+ **Forwarding-terminal receipt bypass.** *(Minor)*
33
+ `_isForwardingTerminal` bypasses receipt validation on incoming transfers. Forwarding terminals are registered by project owners and therefore trusted to handle receipts correctly.
50
34
 
51
- - **No pool exists.** If no V3 or V4 pool exists for a token pair, automatic swap routing fails.
52
- - **No observation history.** V3 TWAP quoting can revert on fresh pools.
53
- - **Cash-out loop limit.** Circular or deep token dependency chains hit `_MAX_CASHOUT_ITERATIONS = 20` and revert.
54
- - **Zero-liquidity pools.** Pools with no usable liquidity revert.
55
- - **External terminal reverts.** Final terminal calls are not wrapped in `try/catch`.
56
- - **Non-standard final ERC-20 transfer behavior.** Lossy terminal-facing tokens revert on the final forwarded hop.
35
+ **Forwarder claim disables receipt check.** *(Minor)*
36
+ Forwarding terminals registered by project owners are trusted to handle receipts correctly, so receipt validation is skipped for these callers.
57
37
 
58
- ## 5. Integration Risks
38
+ ## Minor Configuration Risks
59
39
 
60
- - **Registry default terminal changes affect unlocked projects.** Projects without explicit assignments follow `defaultTerminal`.
61
- - **Locked bad-terminal risk remains.** Locking protects against silent migration, but also freezes any mistake.
62
- - **`forceApprove` is used for terminal transfers.** This resets allowance before setting a new value and avoids stale-allowance accumulation.
63
- - **Callback data trust matters.** `uniswapV3SwapCallback` validates the pool by reconstructing its address from the expected parameters.
64
- - **The contract accepts arbitrary ETH.** That is necessary for unwraps and V4 settlement, but stray ETH can remain stranded.
40
+ **Unbounded quadratic candidate enumeration.** *(Minor)*
41
+ `_candidatePayRouteTokens` can enumerate O(n^2) candidates in theory. Bounded in practice to ~5-10 terminals per project, keeping gas costs manageable.
65
42
 
66
- ## 6. MEV Surface
67
-
68
- - **V3 path is TWAP-protected.** A 10-minute TWAP makes single-block manipulation much harder.
69
- - **V4 path is TWAP-first, spot-fallback.** When no oracle quote is available, the spot fallback is vulnerable.
70
- - **Cross-route arbitrage exists.** When JB routing bypasses the AMM, differences between bonding-curve price and AMM price create arbitrage opportunities.
71
-
72
- ## 7. Invariants To Verify
73
-
74
- - after any `pay()` or `addToBalanceOf()`, the router should not retain balances attributable to the just-processed route
75
- - `minAmountOut` in swaps is never zero when TWAP or spot price is available
76
- - `uniswapV3SwapCallback` only transfers tokens to verified pool addresses
77
- - `unlockCallback` only executes when called by `POOL_MANAGER`
78
- - credit cash-out only transfers credits from `_msgSender()`
79
- - the cash-out loop always terminates: it finds a terminal, hits the loop limit, or reverts for lack of path
80
-
81
- ## 8. Accepted Behaviors
82
-
83
- ### 8.1 No reentrancy guard
84
-
85
- The router has no `ReentrancyGuard` or `_routing` flag. This is intentional because the router is designed as a stateless routing layer, not a persistent accounting surface. Each call must fund and resolve its own route, and a blanket reentrancy guard would break legitimate composed routing flows.
86
-
87
- ### 8.2 Router trusts `originalPayer()` from any caller that implements it
88
-
89
- `_resolveRefundWithBackupRecipient` calls `IJBPayerTracker(msg.sender).originalPayer()` in a `try/catch`. If the caller returns a non-zero address, leftovers can be sent there. This is accepted because the caller already supplied the funds being routed.
90
-
91
- ### 8.3 Cash-out loop slippage is first-hop only
92
-
93
- `_cashOutLoop` applies `cashOutMinReclaimed` to the first cash-out step only. Later recursive hops may reclaim different assets with different units, so reusing one minimum across the full loop would be unsound.
94
-
95
- ### 8.4 Liquidity-first pool selection is intentional
96
-
97
- The router does not do an exhaustive best-execution search across every viable V3 and V4 pool. It prefers bounded discovery, lower complexity, and predictable behavior.
98
-
99
- ### 8.5 Registry owns immediate circular-forward protection
100
-
101
- The router and resolver no longer contain registry-specific circular-resolution logic. `JBRouterTerminalRegistry` rejects forwarding back into its immediate caller instead.
102
-
103
- ### 8.6 V4 spot fallback is an accepted risk for programmatic integrations
104
-
105
- Automatic V4 quoting first tries a hook-provided oracle observation and falls back to spot only when that quote is unavailable. The fallback is still manipulable and is accepted only as a bounded on-chain quoting path for integrations that cannot provide `quoteForSwap` metadata.
106
-
107
- ### 8.7 Credit cash-outs only work when calling the router terminal directly
108
-
109
- The credit-cashout path in `_acceptFundsFor` uses `holder = _msgSender()` — the direct caller — as the credit holder. This means credit cash-outs **do not work through the `JBRouterTerminalRegistry`**, because when the registry forwards a `pay()` call, `_msgSender()` inside the router terminal resolves to the registry's address, not the original user. The registry has no credits, so `transferCreditsFrom` would fail.
110
-
111
- This is intentional. The previous design used `_resolveOriginalPayer(sender)` to recover the original user from the registry's transient `originalPayer` storage. However, any contract implementing `IJBPayerTracker.originalPayer()` could spoof a victim's address and steal their credits (H-24). The fix uses the direct sender unconditionally for credit transfers, closing the spoofing vector at the cost of registry-mediated credit flows.
112
-
113
- Users who need to cash out credits through the router should call `JBRouterTerminal.pay()` directly with `cashOutSource` metadata, not through the registry.
43
+ **Permit2 try/catch falls through to ERC20 allowance.** *(Minor)*
44
+ Standard Permit2 fallback pattern. If Permit2 signature verification fails, the contract falls back to standard ERC20 `transferFrom` using existing allowance.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/router-terminal-v6",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -340,20 +340,30 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
340
340
  view
341
341
  returns (bool isCircular)
342
342
  {
343
- // Treat direct self-routes as circular immediately.
344
- if (address(terminal) == address(router)) return true;
345
-
346
- // Probe via staticcall so plain terminals degrade cleanly.
347
- (bool success, bytes memory data) =
348
- address(terminal).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
349
-
350
- // Non-forwarding terminals (call fails or returns zero) are not circular.
351
- if (!success || data.length < 32) return false;
352
- IJBTerminal forwardingTarget = abi.decode(data, (IJBTerminal));
353
- if (address(forwardingTarget) == address(0)) return false;
343
+ // Follow the forwarding chain up to 5 hops to detect circular routes back to the router.
344
+ // A bounded loop prevents infinite gas consumption from longer chains while catching realistic cycles.
345
+ IJBTerminal current = terminal;
346
+ for (uint256 i; i < 5; i++) {
347
+ // Treat routes back to the router as circular.
348
+ if (address(current) == address(router)) return true;
349
+
350
+ // Probe via staticcall so plain terminals degrade cleanly.
351
+ // slither-disable-next-line calls-loop
352
+ (bool success, bytes memory data) =
353
+ address(current).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
354
+
355
+ // Non-forwarding terminals (call fails or returns zero) end the chain — not circular.
356
+ if (!success || data.length < 32) return false;
357
+ IJBTerminal forwardingTarget = abi.decode(data, (IJBTerminal));
358
+ if (address(forwardingTarget) == address(0)) return false;
359
+
360
+ // Follow the forwarding chain one more hop.
361
+ current = forwardingTarget;
362
+ }
354
363
 
355
- // Forwarding terminals that route back into the router are circular.
356
- return address(forwardingTarget) == address(router);
364
+ // If we followed 5 hops without finding a non-forwarding terminal or the router,
365
+ // treat this as a suspicious deep chain and mark it as circular to be safe.
366
+ return true;
357
367
  }
358
368
 
359
369
  /// @notice Normalize a token into the form the router uses for routing comparisons.
@@ -481,56 +491,6 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
481
491
  routedAmountOut = routedAmountIn;
482
492
  }
483
493
 
484
- /// @notice External wrapper so candidate previews can be isolated with `try/catch`.
485
- /// @param router The router terminal whose preview helpers should be used.
486
- /// @param projectId The destination project that would receive the payment.
487
- /// @param tokenIn The token currently available to route.
488
- /// @param amount The amount of `tokenIn` being previewed.
489
- /// @param beneficiary The address whose beneficiary token count is being measured.
490
- /// @param metadata Metadata forwarded into both the routing preview and terminal preview.
491
- /// @param tokenOut The candidate destination token to preview.
492
- /// @param destTerminal The terminal that accepts `tokenOut` for the destination project.
493
- /// @return routedDestTerminal The terminal chosen for this candidate route.
494
- /// @return routedTokenOut The routed token that would be paid into the destination terminal.
495
- /// @return routedAmountOut The routed amount that would be paid into the destination terminal.
496
- /// @return ruleset The ruleset returned by the terminal preview.
497
- /// @return beneficiaryTokenCount The effective beneficiary token count for this candidate route.
498
- /// @return reservedTokenCount The effective reserved token count for this candidate route.
499
- /// @return hookSpecifications The hook specifications returned by the terminal preview.
500
- function previewPayRouteForCandidate(
501
- IJBPayRoutePreviewer router,
502
- uint256 projectId,
503
- address tokenIn,
504
- uint256 amount,
505
- address beneficiary,
506
- bytes calldata metadata,
507
- address tokenOut,
508
- IJBTerminal destTerminal
509
- )
510
- external
511
- view
512
- returns (
513
- IJBTerminal routedDestTerminal,
514
- address routedTokenOut,
515
- uint256 routedAmountOut,
516
- JBRuleset memory ruleset,
517
- uint256 beneficiaryTokenCount,
518
- uint256 reservedTokenCount,
519
- JBPayHookSpecification[] memory hookSpecifications
520
- )
521
- {
522
- return _previewPayRouteForCandidate({
523
- router: router,
524
- projectId: projectId,
525
- tokenIn: tokenIn,
526
- amount: amount,
527
- beneficiary: beneficiary,
528
- metadata: metadata,
529
- tokenOut: tokenOut,
530
- destTerminal: destTerminal
531
- });
532
- }
533
-
534
494
  /// @notice Preview the fallback route that would be used when no candidate token can be scored directly.
535
495
  /// @param router The router terminal whose preview helpers should be used.
536
496
  /// @param destProjectId The destination project being paid.
@@ -762,6 +722,7 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
762
722
  returns (IJBTerminal candidateTerminal)
763
723
  {
764
724
  // Resolve the primary terminal for the candidate token so fallback discovery agrees with preview/execution.
725
+ // slither-disable-next-line calls-loop
765
726
  candidateTerminal = directory.primaryTerminalOf({projectId: projectId, token: candidateToken});
766
727
 
767
728
  // Drop candidates whose primary terminal disappeared or would route straight back into the router.
@@ -773,39 +734,10 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
773
734
  }
774
735
  }
775
736
 
776
- /// @inheritdoc IJBPayRouteResolver
777
- function usablePrimaryTerminalOf(
778
- IJBPayRoutePreviewer router,
779
- uint256 projectId,
780
- address token
781
- )
782
- external
783
- view
784
- returns (IJBTerminal terminal)
785
- {
786
- return _usablePrimaryTerminalForCandidate({
787
- router: router, directory: DIRECTORY, projectId: projectId, candidateToken: token
788
- });
789
- }
790
-
791
737
  //*********************************************************************//
792
738
  // ------------------------- external views -------------------------- //
793
739
  //*********************************************************************//
794
740
 
795
- /// @inheritdoc IJBPayRouteResolver
796
- function resolveTokenOut(
797
- IJBPayRoutePreviewer router,
798
- uint256 projectId,
799
- address tokenIn,
800
- bytes calldata metadata
801
- )
802
- external
803
- view
804
- returns (address tokenOut, IJBTerminal destTerminal)
805
- {
806
- return _resolveTokenOut({router: router, projectId: projectId, tokenIn: tokenIn, metadata: metadata});
807
- }
808
-
809
741
  /// @inheritdoc IJBPayRouteResolver
810
742
  // slither-disable-next-line calls-loop
811
743
  function previewBestPayRoute(
@@ -930,27 +862,168 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
930
862
  );
931
863
  }
932
864
 
933
- // Fall back to the router's generic route resolution when no candidate token could be scored directly.
865
+ // No candidate token could be scored — fall back to the router's generic route resolution.
866
+ // Uses an external self-call (`self.previewFallbackRoute`) so Solidity's try/catch can isolate
867
+ // reverts from broken terminals or price feeds without bricking the entire best-route preview.
868
+ try self.previewFallbackRoute(router, projectId, tokenIn, amount, beneficiary, metadata) returns (
869
+ IJBTerminal fallbackDestTerminal,
870
+ address fallbackTokenOut,
871
+ uint256 fallbackAmountOut,
872
+ JBRuleset memory fallbackRuleset,
873
+ uint256 fallbackBeneficiaryTokenCount,
874
+ uint256 fallbackReservedTokenCount,
875
+ JBPayHookSpecification[] memory fallbackHookSpecifications
876
+ ) {
877
+ destTerminal = fallbackDestTerminal;
878
+ tokenOut = fallbackTokenOut;
879
+ amountOut = fallbackAmountOut;
880
+ ruleset = fallbackRuleset;
881
+ beneficiaryTokenCount = fallbackBeneficiaryTokenCount;
882
+ reservedTokenCount = fallbackReservedTokenCount;
883
+ hookSpecifications = fallbackHookSpecifications;
884
+ } catch {
885
+ // If the fallback also fails, return default zero values — the caller gets "no route found".
886
+ }
887
+ }
888
+
889
+ /// @notice External self-call wrapper that previews the fallback route in an isolated context.
890
+ /// @dev Solidity's `try/catch` only works on external calls. `previewBestPayRoute` calls
891
+ /// `self.previewFallbackRoute(...)` so that a revert in the fallback path (e.g. a broken terminal or
892
+ /// price feed) is caught instead of bricking the entire best-route preview.
893
+ /// @dev This function should only be called by this contract itself — external callers have no reason to use it.
894
+ /// @param routePreviewer The router terminal whose preview helpers are used to simulate the route.
895
+ /// @param destProjectId The project being paid through the fallback route.
896
+ /// @param tokenIn The token the payer is sending.
897
+ /// @param amountIn The amount of `tokenIn` being routed.
898
+ /// @param beneficiary The address that would receive minted project tokens.
899
+ /// @param metadata Arbitrary bytes forwarded into route and terminal pay previews.
900
+ /// @return destTerminal The terminal the fallback route would deliver funds to.
901
+ /// @return tokenOut The token `destTerminal` would receive after any intermediate swaps.
902
+ /// @return amountOut The amount of `tokenOut` that would arrive at `destTerminal`.
903
+ /// @return ruleset The ruleset that would govern the terminal pay.
904
+ /// @return beneficiaryTokenCount The number of project tokens `beneficiary` would receive.
905
+ /// @return reservedTokenCount The number of project tokens that would be reserved.
906
+ /// @return hookSpecifications Any pay-hook specifications returned by the terminal preview.
907
+ function previewFallbackRoute(
908
+ IJBPayRoutePreviewer routePreviewer,
909
+ uint256 destProjectId,
910
+ address tokenIn,
911
+ uint256 amountIn,
912
+ address beneficiary,
913
+ bytes calldata metadata
914
+ )
915
+ external
916
+ view
917
+ returns (
918
+ IJBTerminal destTerminal,
919
+ address tokenOut,
920
+ uint256 amountOut,
921
+ JBRuleset memory ruleset,
922
+ uint256 beneficiaryTokenCount,
923
+ uint256 reservedTokenCount,
924
+ JBPayHookSpecification[] memory hookSpecifications
925
+ )
926
+ {
927
+ // Resolve which terminal and token the fallback route would use.
934
928
  (destTerminal, tokenOut, amountOut) = _previewRoute({
935
- router: router, destProjectId: projectId, tokenIn: tokenIn, amount: amount, metadata: metadata
929
+ router: routePreviewer, destProjectId: destProjectId, tokenIn: tokenIn, amount: amountIn, metadata: metadata
936
930
  });
937
931
 
938
- // Preview the final terminal pay for that fallback route.
939
- (ruleset, beneficiaryTokenCount, reservedTokenCount, hookSpecifications) = router.previewTerminalPayOf({
932
+ // Simulate the terminal pay to get token counts and hook specs.
933
+ (ruleset, beneficiaryTokenCount, reservedTokenCount, hookSpecifications) = routePreviewer.previewTerminalPayOf({
940
934
  destTerminal: destTerminal,
941
- projectId: projectId,
935
+ projectId: destProjectId,
942
936
  token: tokenOut,
943
937
  amount: amountOut,
944
938
  beneficiary: beneficiary,
945
939
  metadata: metadata
946
940
  });
947
941
 
948
- // Normalize the fallback preview counts so buyback-hook metadata still affects route ranking consistently.
942
+ // Normalize counts to account for buyback-hook overrides.
949
943
  (beneficiaryTokenCount, reservedTokenCount) = _effectivePreviewPayTokenCounts({
950
- buybackHook: router.BUYBACK_HOOK(),
944
+ buybackHook: routePreviewer.BUYBACK_HOOK(),
951
945
  beneficiaryTokenCount: beneficiaryTokenCount,
952
946
  reservedTokenCount: reservedTokenCount,
953
947
  hookSpecifications: hookSpecifications
954
948
  });
955
949
  }
950
+
951
+ /// @notice External wrapper so candidate previews can be isolated with `try/catch`.
952
+ /// @param router The router terminal whose preview helpers should be used.
953
+ /// @param projectId The destination project that would receive the payment.
954
+ /// @param tokenIn The token currently available to route.
955
+ /// @param amount The amount of `tokenIn` being previewed.
956
+ /// @param beneficiary The address whose beneficiary token count is being measured.
957
+ /// @param metadata Metadata forwarded into both the routing preview and terminal preview.
958
+ /// @param tokenOut The candidate destination token to preview.
959
+ /// @param destTerminal The terminal that accepts `tokenOut` for the destination project.
960
+ /// @return routedDestTerminal The terminal chosen for this candidate route.
961
+ /// @return routedTokenOut The routed token that would be paid into the destination terminal.
962
+ /// @return routedAmountOut The routed amount that would be paid into the destination terminal.
963
+ /// @return ruleset The ruleset returned by the terminal preview.
964
+ /// @return beneficiaryTokenCount The effective beneficiary token count for this candidate route.
965
+ /// @return reservedTokenCount The effective reserved token count for this candidate route.
966
+ /// @return hookSpecifications The hook specifications returned by the terminal preview.
967
+ function previewPayRouteForCandidate(
968
+ IJBPayRoutePreviewer router,
969
+ uint256 projectId,
970
+ address tokenIn,
971
+ uint256 amount,
972
+ address beneficiary,
973
+ bytes calldata metadata,
974
+ address tokenOut,
975
+ IJBTerminal destTerminal
976
+ )
977
+ external
978
+ view
979
+ returns (
980
+ IJBTerminal routedDestTerminal,
981
+ address routedTokenOut,
982
+ uint256 routedAmountOut,
983
+ JBRuleset memory ruleset,
984
+ uint256 beneficiaryTokenCount,
985
+ uint256 reservedTokenCount,
986
+ JBPayHookSpecification[] memory hookSpecifications
987
+ )
988
+ {
989
+ return _previewPayRouteForCandidate({
990
+ router: router,
991
+ projectId: projectId,
992
+ tokenIn: tokenIn,
993
+ amount: amount,
994
+ beneficiary: beneficiary,
995
+ metadata: metadata,
996
+ tokenOut: tokenOut,
997
+ destTerminal: destTerminal
998
+ });
999
+ }
1000
+
1001
+ /// @inheritdoc IJBPayRouteResolver
1002
+ function resolveTokenOut(
1003
+ IJBPayRoutePreviewer router,
1004
+ uint256 projectId,
1005
+ address tokenIn,
1006
+ bytes calldata metadata
1007
+ )
1008
+ external
1009
+ view
1010
+ returns (address tokenOut, IJBTerminal destTerminal)
1011
+ {
1012
+ return _resolveTokenOut({router: router, projectId: projectId, tokenIn: tokenIn, metadata: metadata});
1013
+ }
1014
+
1015
+ /// @inheritdoc IJBPayRouteResolver
1016
+ function usablePrimaryTerminalOf(
1017
+ IJBPayRoutePreviewer router,
1018
+ uint256 projectId,
1019
+ address token
1020
+ )
1021
+ external
1022
+ view
1023
+ returns (IJBTerminal terminal)
1024
+ {
1025
+ return _usablePrimaryTerminalForCandidate({
1026
+ router: router, directory: DIRECTORY, projectId: projectId, candidateToken: token
1027
+ });
1028
+ }
956
1029
  }
@@ -109,7 +109,8 @@ contract JBRouterTerminal is
109
109
  uint256 internal constant _SLIPPAGE_DENOMINATOR = 10_000;
110
110
 
111
111
  /// @notice The TWAP window (in seconds) used when querying a V4 oracle hook.
112
- uint32 private constant _TWAP_WINDOW = 30;
112
+ /// @dev Matches the V3 minimum TWAP window (MIN_TWAP_WINDOW = 120) to resist short-window manipulation.
113
+ uint32 private constant _TWAP_WINDOW = 120;
113
114
 
114
115
  //*********************************************************************//
115
116
  // ---------------- public immutable stored properties --------------- //
@@ -196,6 +197,7 @@ contract JBRouterTerminal is
196
197
  WETH = weth;
197
198
  // slither-disable-next-line missing-zero-check
198
199
  BUYBACK_HOOK = buybackHook;
200
+ // slither-disable-next-line missing-zero-check
199
201
  UNIV4_HOOK = univ4Hook;
200
202
  _PAY_ROUTE_RESOLVER = IJBPayRouteResolver(address(new JBPayRouteResolver({directory: directory, weth: weth})));
201
203
 
@@ -403,7 +405,7 @@ contract JBRouterTerminal is
403
405
 
404
406
  // Calculate the amount of tokens to send to the pool (the positive delta).
405
407
  // forge-lint: disable-next-line(unsafe-typecast)
406
- uint256 amountToSendToPool = amount0Delta < 0 ? uint256(amount1Delta) : uint256(amount0Delta);
408
+ uint256 amountToSendToPool = amount0Delta > 0 ? uint256(amount0Delta) : uint256(amount1Delta);
407
409
 
408
410
  // Wrap native tokens if needed.
409
411
  if (tokenIn == JBConstants.NATIVE_TOKEN) _wethDeposit(amountToSendToPool);
@@ -988,6 +990,7 @@ contract JBRouterTerminal is
988
990
  /// @param token The token that terminal should accept.
989
991
  /// @return terminal The usable primary terminal, or address(0) if none is usable.
990
992
  function _usablePrimaryTerminalOf(uint256 projectId, address token) internal view returns (IJBTerminal terminal) {
993
+ // slither-disable-next-line calls-loop
991
994
  terminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: token});
992
995
 
993
996
  // Drop terminals that would route straight back into the router (circular).
@@ -998,6 +1001,7 @@ contract JBRouterTerminal is
998
1001
  // Check if the terminal is a forwarding layer that routes back into this router.
999
1002
  // Uses the same low-level staticcall pattern as _isForwardingTerminal — non-forwarding terminals degrade
1000
1003
  // cleanly into a no-op (success=false or empty data).
1004
+ // slither-disable-next-line calls-loop
1001
1005
  (bool ok, bytes memory data) =
1002
1006
  address(terminal).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
1003
1007
  if (ok && data.length >= 32 && address(abi.decode(data, (IJBTerminal))) == address(this)) {
@@ -1362,14 +1366,15 @@ contract JBRouterTerminal is
1362
1366
 
1363
1367
  // Ask the pool to execute an exact-input swap. The callback settles the input token after the pool
1364
1368
  // computes how much of the output side it owes this router.
1369
+ // Use extreme sqrtPriceLimitX96 values to allow the swap to execute fully. Slippage is enforced
1370
+ // by the post-swap minAmountOut check below, which is more correct than deriving a price limit
1371
+ // from average execution rate.
1365
1372
  (int256 amount0, int256 amount1) = pool.swap({
1366
1373
  recipient: address(this),
1367
1374
  zeroForOne: zeroForOne,
1368
1375
  // forge-lint: disable-next-line(unsafe-typecast)
1369
1376
  amountSpecified: int256(amount),
1370
- sqrtPriceLimitX96: JBSwapLib.sqrtPriceLimitFromAmounts({
1371
- amountIn: amount, minimumAmountOut: minAmountOut, zeroForOne: zeroForOne
1372
- }),
1377
+ sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1,
1373
1378
  data: callbackData
1374
1379
  });
1375
1380
 
@@ -1377,8 +1382,8 @@ contract JBRouterTerminal is
1377
1382
  // pool sent tokens out to the router, so negate the selected leg to recover the positive amount received.
1378
1383
  amountOut = uint256(-(zeroForOne ? amount1 : amount0));
1379
1384
 
1380
- // Recheck the realized output against the quote floor so partial fills or price movement cannot slip
1381
- // through even if the pool call itself completed.
1385
+ // Enforce slippage protection via realized output vs minimum acceptable output.
1386
+ // This is strictly more correct than sqrtPriceLimitX96 (which conflates marginal and average price).
1382
1387
  if (amountOut < minAmountOut) revert JBRouterTerminal_SlippageExceeded(amountOut, minAmountOut);
1383
1388
  }
1384
1389
 
@@ -1405,10 +1410,9 @@ contract JBRouterTerminal is
1405
1410
  // Determine the V4 swap direction by comparing the input token to currency0 in the pool key.
1406
1411
  bool zeroForOne = _unwrapCurrency(key.currency0) == v4In;
1407
1412
 
1408
- // Use sqrtPriceLimitFromAmounts for partial-fill protection, consistent with V3 path.
1409
- uint160 sqrtPriceLimitX96 = JBSwapLib.sqrtPriceLimitFromAmounts({
1410
- amountIn: amount, minimumAmountOut: minAmountOut, zeroForOne: zeroForOne
1411
- });
1413
+ // Use extreme sqrtPriceLimitX96 to allow full swap execution. Slippage is enforced by
1414
+ // the post-swap minAmountOut check in the unlock callback.
1415
+ uint160 sqrtPriceLimitX96 = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
1412
1416
 
1413
1417
  // V4 sign convention: negative = exact input, positive = exact output.
1414
1418
  // Ask the PoolManager to unlock and call back into this router to execute the swap atomically.
@@ -2335,10 +2339,18 @@ contract JBRouterTerminal is
2335
2339
  try IGeomeanOracle(address(key.hooks)).observe(key, secondsAgos) returns (
2336
2340
  int56[] memory tickCumulatives, uint160[] memory
2337
2341
  ) {
2338
- // Derive the arithmetic mean tick: (cumulative_now - cumulative_start) / elapsed_seconds.
2339
- // forge-lint: disable-next-line(unsafe-typecast)
2340
- tick = int24((tickCumulatives[1] - tickCumulatives[0]) / int56(int32(_TWAP_WINDOW)));
2341
- usedTwap = true;
2342
+ // Guard against malicious/broken hooks returning fewer elements than requested.
2343
+ // An OOB access in the try-success block panics and is NOT caught by catch{}.
2344
+ if (tickCumulatives.length >= 2) {
2345
+ // Derive the arithmetic mean tick: (cumulative_now - cumulative_start) / elapsed_seconds.
2346
+ // forge-lint: disable-next-line(unsafe-typecast)
2347
+ int56 tickDelta = tickCumulatives[1] - tickCumulatives[0];
2348
+ int56 period = int56(int32(_TWAP_WINDOW));
2349
+ tick = int24(tickDelta / period);
2350
+ // Round towards negative infinity for negative ticks (Uniswap convention).
2351
+ if (tickDelta < 0 && (tickDelta % period != 0)) tick--;
2352
+ usedTwap = true;
2353
+ }
2342
2354
  } catch {}
2343
2355
  }
2344
2356
 
@@ -2356,14 +2368,35 @@ contract JBRouterTerminal is
2356
2368
  normalizedTokenIn = normalizedTokenIn == address(WETH) ? address(0) : normalizedTokenIn;
2357
2369
  normalizedTokenOut = normalizedTokenOut == address(WETH) ? address(0) : normalizedTokenOut;
2358
2370
 
2359
- minAmountOut = _quoteWithSlippage({
2360
- amount: amount,
2361
- liquidity: liquidity,
2362
- tokenIn: normalizedTokenIn,
2363
- tokenOut: normalizedTokenOut,
2364
- tick: tick,
2365
- poolFeeBps: uint256(key.fee) / 100
2366
- });
2371
+ if (!usedTwap) {
2372
+ // Without TWAP, instantaneous liquidity and spot price are both JIT-manipulable.
2373
+ // Use a fixed conservative slippage tolerance (15%) instead of the sigmoid formula, which
2374
+ // an attacker could deflate by inflating liquidity via just-in-time provisioning.
2375
+ uint256 fixedSlippage = 1500; // 15% in basis points of _SLIPPAGE_DENOMINATOR (10_000)
2376
+
2377
+ // Quote the gross output at the spot tick.
2378
+ if (amount > type(uint128).max) revert JBRouterTerminal_AmountOverflow(amount);
2379
+
2380
+ minAmountOut = OracleLibrary.getQuoteAtTick({
2381
+ tick: tick,
2382
+ // forge-lint: disable-next-line(unsafe-typecast)
2383
+ baseAmount: uint128(amount),
2384
+ baseToken: normalizedTokenIn,
2385
+ quoteToken: normalizedTokenOut
2386
+ });
2387
+
2388
+ // Apply the fixed slippage tolerance.
2389
+ minAmountOut -= (minAmountOut * fixedSlippage) / _SLIPPAGE_DENOMINATOR;
2390
+ } else {
2391
+ minAmountOut = _quoteWithSlippage({
2392
+ amount: amount,
2393
+ liquidity: liquidity,
2394
+ tokenIn: normalizedTokenIn,
2395
+ tokenOut: normalizedTokenOut,
2396
+ tick: tick,
2397
+ poolFeeBps: uint256(key.fee) / 100
2398
+ });
2399
+ }
2367
2400
  }
2368
2401
 
2369
2402
  /// @notice Parse the optional `cashOutMinReclaimed` metadata.
@@ -278,7 +278,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
278
278
  /// @param shouldReturnHeldFees A boolean to indicate whether held fees should be returned.
279
279
  /// @param memo A memo to pass along to the emitted event.
280
280
  /// @param metadata Bytes in `JBMetadataResolver`'s format.
281
- // slither-disable-next-line reentrancy-benign
281
+ // slither-disable-next-line reentrancy-benign,reentrancy-eth
282
282
  function addToBalanceOf(
283
283
  uint256 projectId,
284
284
  address token,
@@ -300,6 +300,9 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
300
300
  // Trigger any pre-transfer logic.
301
301
  uint256 payValue = _beforeTransferFor({to: address(terminal), token: token, amount: amount});
302
302
 
303
+ // Save any previous payer so nested reentrant calls through pay hooks restore correctly.
304
+ address previousPayer = originalPayer;
305
+
303
306
  // Store the original payer in transient storage so downstream router terminals can refund partial-fill
304
307
  // leftovers to the true payer.
305
308
  originalPayer = _msgSender();
@@ -320,8 +323,8 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
320
323
  // Revoke any leftover allowance the terminal did not pull.
321
324
  if (token != JBConstants.NATIVE_TOKEN) IERC20(token).forceApprove({spender: address(terminal), value: 0});
322
325
 
323
- // Clear transient storage.
324
- originalPayer = address(0);
326
+ // Restore the previous payer (supports nested reentrant calls through pay hooks).
327
+ originalPayer = previousPayer;
325
328
  }
326
329
 
327
330
  /// @notice Allow a terminal.
@@ -406,7 +409,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
406
409
  /// @param memo A memo to pass along to the emitted event.
407
410
  /// @param metadata Bytes in `JBMetadataResolver`'s format.
408
411
  /// @return result The number of tokens received.
409
- // slither-disable-next-line reentrancy-benign
412
+ // slither-disable-next-line reentrancy-benign,reentrancy-eth
410
413
  function pay(
411
414
  uint256 projectId,
412
415
  address token,
@@ -431,6 +434,9 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
431
434
  // Trigger any pre-transfer logic.
432
435
  uint256 payValue = _beforeTransferFor({to: address(terminal), token: token, amount: amount});
433
436
 
437
+ // Save any previous payer so nested reentrant calls through pay hooks restore correctly.
438
+ address previousPayer = originalPayer;
439
+
434
440
  // Store the original payer in transient storage so downstream router terminals can refund partial-fill
435
441
  // leftovers to the true payer.
436
442
  originalPayer = _msgSender();
@@ -452,8 +458,8 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
452
458
  // Revoke any leftover allowance the terminal did not pull.
453
459
  if (token != JBConstants.NATIVE_TOKEN) IERC20(token).forceApprove({spender: address(terminal), value: 0});
454
460
 
455
- // Clear transient storage.
456
- originalPayer = address(0);
461
+ // Restore the previous payer (supports nested reentrant calls through pay hooks).
462
+ originalPayer = previousPayer;
457
463
  }
458
464
 
459
465
  /// @notice Set the default terminal.
@@ -98,6 +98,42 @@ interface IJBPayRouteResolver {
98
98
  view
99
99
  returns (address tokenOut, IJBTerminal destTerminal);
100
100
 
101
+ /// @notice External self-call wrapper that previews the fallback route in an isolated context.
102
+ /// @dev Called via `self.previewFallbackRoute(...)` so `try/catch` can absorb reverts from broken
103
+ /// terminals or price feeds without bricking the entire best-route preview.
104
+ /// @param routePreviewer The router terminal whose preview helpers are used to simulate the route.
105
+ /// @param destProjectId The project being paid through the fallback route.
106
+ /// @param tokenIn The token the payer is sending.
107
+ /// @param amountIn The amount of `tokenIn` being routed.
108
+ /// @param beneficiary The address that would receive minted project tokens.
109
+ /// @param metadata Arbitrary bytes forwarded into route and terminal pay previews.
110
+ /// @return destTerminal The terminal the fallback route would deliver funds to.
111
+ /// @return tokenOut The token `destTerminal` would receive after any intermediate swaps.
112
+ /// @return amountOut The amount of `tokenOut` that would arrive at `destTerminal`.
113
+ /// @return ruleset The ruleset that would govern the terminal pay.
114
+ /// @return beneficiaryTokenCount The number of project tokens `beneficiary` would receive.
115
+ /// @return reservedTokenCount The number of project tokens that would be reserved.
116
+ /// @return hookSpecifications Any pay-hook specifications returned by the terminal preview.
117
+ function previewFallbackRoute(
118
+ IJBPayRoutePreviewer routePreviewer,
119
+ uint256 destProjectId,
120
+ address tokenIn,
121
+ uint256 amountIn,
122
+ address beneficiary,
123
+ bytes calldata metadata
124
+ )
125
+ external
126
+ view
127
+ returns (
128
+ IJBTerminal destTerminal,
129
+ address tokenOut,
130
+ uint256 amountOut,
131
+ JBRuleset memory ruleset,
132
+ uint256 beneficiaryTokenCount,
133
+ uint256 reservedTokenCount,
134
+ JBPayHookSpecification[] memory hookSpecifications
135
+ );
136
+
101
137
  /// @notice Resolve a project's primary terminal only when the router can safely forward into it.
102
138
  /// @param router The router whose forwarding-terminal rules should be applied.
103
139
  /// @param projectId The project whose primary terminal should be checked.
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ /// @notice Minimal harness that isolates the tick-rounding arithmetic from JBRouterTerminal._getV4Tick().
7
+ /// @dev The TWAP window is 120 seconds, matching `_TWAP_WINDOW` in production.
8
+ contract TickRoundingHarness {
9
+ int56 public constant PERIOD = 120;
10
+
11
+ /// @notice Old (buggy) logic: Solidity truncation toward zero.
12
+ /// For negative non-exact deltas this rounds toward zero instead of toward negative infinity.
13
+ function oldTick(int56 tickDelta) external pure returns (int24 tick) {
14
+ tick = int24(tickDelta / PERIOD);
15
+ }
16
+
17
+ /// @notice New (fixed) logic: explicit floor-division for negative ticks (Uniswap convention).
18
+ function newTick(int56 tickDelta) external pure returns (int24 tick) {
19
+ tick = int24(tickDelta / PERIOD);
20
+ // Round towards negative infinity for negative ticks (Uniswap convention).
21
+ if (tickDelta < 0 && (tickDelta % PERIOD != 0)) tick--;
22
+ }
23
+ }
24
+
25
+ /// @notice Regression tests proving the negative-tick rounding fix from audit finding Pass-12.
26
+ /// @dev Uniswap's arithmetic-mean tick must be floor-divided (rounded toward negative infinity).
27
+ /// Solidity integer division truncates toward zero, which is incorrect for negative non-exact values.
28
+ /// Example: -12001 / 120 in Solidity = -100 (truncation), but Uniswap expects -101 (floor).
29
+ contract NegativeTickRoundingTest is Test {
30
+ TickRoundingHarness harness;
31
+
32
+ function setUp() public {
33
+ harness = new TickRoundingHarness();
34
+ }
35
+
36
+ // ------------------------------------------------------------------
37
+ // Positive exact: tickDelta = 12000 (100 * 120) -> tick = 100
38
+ // ------------------------------------------------------------------
39
+ function test_positiveExact() public view {
40
+ int56 tickDelta = 12_000; // 100 * 120, divides evenly.
41
+
42
+ assertEq(harness.oldTick(tickDelta), 100, "old: positive exact");
43
+ assertEq(harness.newTick(tickDelta), 100, "new: positive exact");
44
+ }
45
+
46
+ // ------------------------------------------------------------------
47
+ // Positive non-exact: tickDelta = 12001 -> tick = 100
48
+ // Truncation equals floor for positive values, so both are correct.
49
+ // ------------------------------------------------------------------
50
+ function test_positiveNonExact() public view {
51
+ int56 tickDelta = 12_001;
52
+
53
+ assertEq(harness.oldTick(tickDelta), 100, "old: positive non-exact");
54
+ assertEq(harness.newTick(tickDelta), 100, "new: positive non-exact");
55
+ }
56
+
57
+ // ------------------------------------------------------------------
58
+ // Negative exact: tickDelta = -12000 -> tick = -100
59
+ // Exact division — no rounding difference.
60
+ // ------------------------------------------------------------------
61
+ function test_negativeExact() public view {
62
+ int56 tickDelta = -12_000; // -100 * 120, divides evenly.
63
+
64
+ assertEq(harness.oldTick(tickDelta), -100, "old: negative exact");
65
+ assertEq(harness.newTick(tickDelta), -100, "new: negative exact");
66
+ }
67
+
68
+ // ------------------------------------------------------------------
69
+ // Negative non-exact (THE BUG CASE): tickDelta = -12001 -> tick should be -101
70
+ // Old logic: -12001 / 120 = -100 (truncation toward zero — WRONG).
71
+ // New logic: -12001 / 120 = -100, then tick-- = -101 (floor — CORRECT).
72
+ // ------------------------------------------------------------------
73
+ function test_negativeNonExact_bugCase() public view {
74
+ int56 tickDelta = -12_001;
75
+
76
+ // Old logic truncates toward zero: WRONG for Uniswap.
77
+ assertEq(harness.oldTick(tickDelta), -100, "old: negative non-exact truncates toward zero");
78
+
79
+ // New logic floors toward negative infinity: CORRECT for Uniswap.
80
+ assertEq(harness.newTick(tickDelta), -101, "new: negative non-exact floors toward -inf");
81
+ }
82
+
83
+ // ------------------------------------------------------------------
84
+ // Zero: tickDelta = 0 -> tick = 0
85
+ // ------------------------------------------------------------------
86
+ function test_zero() public view {
87
+ int56 tickDelta = 0;
88
+
89
+ assertEq(harness.oldTick(tickDelta), 0, "old: zero");
90
+ assertEq(harness.newTick(tickDelta), 0, "new: zero");
91
+ }
92
+
93
+ // ------------------------------------------------------------------
94
+ // Small negative: tickDelta = -1 -> floor(-1/120) = -1, NOT 0
95
+ // Old logic: -1 / 120 = 0 (truncation toward zero — WRONG).
96
+ // New logic: -1 / 120 = 0, then tick-- = -1 (floor — CORRECT).
97
+ // ------------------------------------------------------------------
98
+ function test_smallNegative() public view {
99
+ int56 tickDelta = -1;
100
+
101
+ // Old logic truncates toward zero: WRONG for Uniswap.
102
+ assertEq(harness.oldTick(tickDelta), 0, "old: small negative truncates to zero");
103
+
104
+ // New logic floors toward negative infinity: CORRECT for Uniswap.
105
+ assertEq(harness.newTick(tickDelta), -1, "new: small negative floors to -1");
106
+ }
107
+
108
+ // ------------------------------------------------------------------
109
+ // Additional edge case: tickDelta = -120 (exactly -1 tick period).
110
+ // Exact division — both should agree at -1.
111
+ // ------------------------------------------------------------------
112
+ function test_negativeExactOnePeriod() public view {
113
+ int56 tickDelta = -120;
114
+
115
+ assertEq(harness.oldTick(tickDelta), -1, "old: -120 exact");
116
+ assertEq(harness.newTick(tickDelta), -1, "new: -120 exact");
117
+ }
118
+
119
+ // ------------------------------------------------------------------
120
+ // Additional edge case: tickDelta = -119 -> floor(-119/120) = -1
121
+ // Old logic: -119 / 120 = 0 (truncation — WRONG).
122
+ // New logic: 0, then tick-- = -1 (floor — CORRECT).
123
+ // ------------------------------------------------------------------
124
+ function test_negativeAlmostOnePeriod() public view {
125
+ int56 tickDelta = -119;
126
+
127
+ assertEq(harness.oldTick(tickDelta), 0, "old: -119 truncates to 0");
128
+ assertEq(harness.newTick(tickDelta), -1, "new: -119 floors to -1");
129
+ }
130
+ }
@@ -176,11 +176,12 @@ contract RouterTerminalERC2771Test is Test {
176
176
  // forge-lint: disable-next-line(unsafe-typecast)
177
177
  JBAccountingContext({token: address(token), decimals: 18, currency: uint32(uint160(address(token)))});
178
178
  vm.mockCall(mockTerminal, abi.encodeCall(IJBTerminal.accountingContextsOf, (projectId)), abi.encode(contexts));
179
- // Mock terminalOf so _isForwardingTerminal returns true and circular-terminal check sees a non-router target.
179
+ // Mock terminalOf so _isForwardingTerminal returns true and the 5-hop circular check ends cleanly.
180
+ // Return a distinct non-contract address (not mockTerminal itself) so the forwarding chain terminates.
180
181
  vm.mockCall(
181
182
  mockTerminal,
182
183
  abi.encodeWithSelector(IJBForwardingTerminal.terminalOf.selector, projectId),
183
- abi.encode(mockTerminal)
184
+ abi.encode(IJBTerminal(address(1)))
184
185
  );
185
186
 
186
187
  // Mock terminalsOf so the _routeForPay path finds the project's terminals.
@@ -268,11 +269,12 @@ contract RouterTerminalERC2771Test is Test {
268
269
  // forge-lint: disable-next-line(unsafe-typecast)
269
270
  JBAccountingContext({token: address(token), decimals: 18, currency: uint32(uint160(address(token)))});
270
271
  vm.mockCall(mockTerminal, abi.encodeCall(IJBTerminal.accountingContextsOf, (projectId)), abi.encode(contexts));
271
- // Mock terminalOf so _isForwardingTerminal returns true and circular-terminal check sees a non-router target.
272
+ // Mock terminalOf so _isForwardingTerminal returns true and the 5-hop circular check ends cleanly.
273
+ // Return a distinct non-contract address (not mockTerminal itself) so the forwarding chain terminates.
272
274
  vm.mockCall(
273
275
  mockTerminal,
274
276
  abi.encodeWithSelector(IJBForwardingTerminal.terminalOf.selector, projectId),
275
- abi.encode(mockTerminal)
277
+ abi.encode(IJBTerminal(address(1)))
276
278
  );
277
279
 
278
280
  // Mock terminalsOf so the _routeForPay path finds the project's terminals.
@@ -246,11 +246,13 @@ contract TestAuditGaps is Test {
246
246
  // forge-lint: disable-next-line(unsafe-typecast)
247
247
  ctx[0] = JBAccountingContext({token: tokenOut, decimals: 18, currency: uint32(uint160(tokenOut))});
248
248
  vm.mockCall(destTerminal, abi.encodeCall(IJBTerminal.accountingContextsOf, (projectId)), abi.encode(ctx));
249
- // Mock terminalOf so _isForwardingTerminal returns true and circular-terminal check sees a non-router target.
249
+ // Mock terminalOf so _isForwardingTerminal returns true and the 5-hop circular-terminal check ends cleanly.
250
+ // Return a distinct non-contract address (not destTerminal itself) so the forwarding chain terminates
251
+ // after one hop instead of looping back to destTerminal indefinitely.
250
252
  vm.mockCall(
251
253
  destTerminal,
252
254
  abi.encodeWithSelector(IJBForwardingTerminal.terminalOf.selector, projectId),
253
- abi.encode(destTerminal)
255
+ abi.encode(IJBTerminal(address(1)))
254
256
  );
255
257
  vm.mockCall(
256
258
  destTerminal,
@@ -351,9 +353,11 @@ contract TestAuditGaps is Test {
351
353
  // Mock approve for dest terminal (actual received amount = 4900).
352
354
  vm.mockCall(tokenIn, abi.encodeCall(IERC20.approve, (dest, 4900)), abi.encode(true));
353
355
  vm.mockCall(dest, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(42)));
354
- // Mock terminalOf so _isForwardingTerminal returns true and circular-terminal check sees a non-router target.
356
+ // Mock terminalOf so _isForwardingTerminal returns true and the 5-hop circular check ends cleanly.
355
357
  vm.mockCall(
356
- dest, abi.encodeWithSelector(IJBForwardingTerminal.terminalOf.selector, uint256(1)), abi.encode(dest)
358
+ dest,
359
+ abi.encodeWithSelector(IJBForwardingTerminal.terminalOf.selector, uint256(1)),
360
+ abi.encode(IJBTerminal(address(1)))
357
361
  );
358
362
  // Mock previewPayFor for route scoring.
359
363
  vm.mockCall(
@@ -473,7 +477,8 @@ contract TestAuditGaps is Test {
473
477
  // GAP 3: Short TWAP Windows
474
478
  // ═══════════════════════════════════════════════════════════════════════
475
479
 
476
- /// @notice When oldest observation is 0 seconds ago, _getV3TwapQuote reverts NoObservationHistory.
480
+ /// @notice When oldest observation is 0 seconds ago, the swap path reverts. The TWAP error is caught by the
481
+ /// route resolver's try/catch fallback (F3), causing the pay to revert with a no-route failure downstream.
477
482
  function test_shortTwap_revertsNoObservationHistory() public {
478
483
  MockERC20Std tok = new MockERC20Std();
479
484
  address tokenIn = address(tok);
@@ -512,8 +517,10 @@ contract TestAuditGaps is Test {
512
517
  vm.prank(payer);
513
518
  tok.approve(address(router), 100);
514
519
 
520
+ // The NoObservationHistory error is caught by the route resolver's try/catch (F3),
521
+ // so the specific error doesn't propagate — the pay still reverts because no valid route is found.
515
522
  vm.prank(payer);
516
- vm.expectRevert(JBRouterTerminal.JBRouterTerminal_NoObservationHistory.selector);
523
+ vm.expectRevert();
517
524
  router.pay(1, tokenIn, 100, payer, 0, "", "");
518
525
  }
519
526
 
@@ -566,7 +573,9 @@ contract TestAuditGaps is Test {
566
573
  assertEq(result, 77);
567
574
  }
568
575
 
569
- /// @notice [L-17] After MIN_TWAP_WINDOW enforcement, a 1-second observation window now reverts.
576
+ /// @notice [L-17] After MIN_TWAP_WINDOW enforcement, a 1-second observation window causes the swap to fail.
577
+ /// The InsufficientTwapHistory error is caught by the route resolver's try/catch fallback (F3),
578
+ /// so the pay reverts with a downstream no-route failure rather than the specific TWAP error.
570
579
  function test_shortTwap_clampsTo1Second_nowRevertsAfterMinWindow() public {
571
580
  MockERC20Std tok = new MockERC20Std();
572
581
  address tokenIn = address(tok);
@@ -604,9 +613,10 @@ contract TestAuditGaps is Test {
604
613
  vm.prank(payer);
605
614
  tok.approve(address(router), 100);
606
615
 
607
- // 1s < MIN_TWAP_WINDOW (120s) => reverts.
616
+ // 1s < MIN_TWAP_WINDOW (120s) => the TWAP check fails during route preview, caught by try/catch (F3).
617
+ // The pay still reverts because no valid route is found after the fallback failure.
608
618
  vm.prank(payer);
609
- vm.expectRevert(JBRouterTerminal.JBRouterTerminal_InsufficientTwapHistory.selector);
619
+ vm.expectRevert();
610
620
  router.pay(1, tokenIn, 100, payer, 0, "", "");
611
621
  }
612
622
 
@@ -619,7 +629,9 @@ contract TestAuditGaps is Test {
619
629
  assertEq(router.MIN_TWAP_WINDOW(), 120);
620
630
  }
621
631
 
622
- /// @notice Observation window of 119s (just below MIN_TWAP_WINDOW) reverts.
632
+ /// @notice Observation window of 119s (just below MIN_TWAP_WINDOW) causes the swap to fail.
633
+ /// The InsufficientTwapHistory error is caught by the route resolver's try/catch fallback (F3),
634
+ /// so the pay reverts with a downstream no-route failure rather than the specific TWAP error.
623
635
  function test_shortTwap_revertsAt119Seconds() public {
624
636
  vm.warp(1000);
625
637
  MockERC20Std tok = new MockERC20Std();
@@ -658,8 +670,10 @@ contract TestAuditGaps is Test {
658
670
  vm.prank(payer);
659
671
  tok.approve(address(router), 100);
660
672
 
673
+ // 119s < MIN_TWAP_WINDOW (120s) => the TWAP check fails during route preview, caught by try/catch (F3).
674
+ // The pay still reverts because no valid route is found after the fallback failure.
661
675
  vm.prank(payer);
662
- vm.expectRevert(JBRouterTerminal.JBRouterTerminal_InsufficientTwapHistory.selector);
676
+ vm.expectRevert();
663
677
  router.pay(1, tokenIn, 100, payer, 0, "", "");
664
678
  }
665
679
 
@@ -175,11 +175,13 @@ contract MultiHopForwardCycleTest is Test {
175
175
  );
176
176
  }
177
177
 
178
- function test_routerDoesNotRejectTwoHopForwardCycle() public {
178
+ /// @notice With 5-hop bounded loop detection, a 2-hop cycle (ForwarderA -> ForwarderB -> router) IS detected
179
+ /// and rejected during route resolution, preventing funds from ever reaching ForwarderB.
180
+ function test_routerRejectsTwoHopForwardCycle() public {
179
181
  vm.deal(payer, AMOUNT);
180
182
 
181
183
  vm.prank(payer);
182
- vm.expectRevert(ForwarderB.CycleReached.selector);
184
+ vm.expectRevert();
183
185
  router.pay{value: AMOUNT}(PROJECT_ID, JBConstants.NATIVE_TOKEN, AMOUNT, payer, 0, "", "");
184
186
  }
185
187
  }
@@ -410,8 +410,8 @@ contract RevertingTerminalRouteDiscoveryTest is Test {
410
410
  }
411
411
 
412
412
  // ─────────────────────────────────────────────────────────────────────────
413
- // Test 5: previewBestPayRoute with only reverting terminals produces no
414
- // route (reverts).
413
+ // Test 5: previewBestPayRoute with only reverting terminals returns
414
+ // zero/empty values (fallback is wrapped in try/catch per F3).
415
415
  // ─────────────────────────────────────────────────────────────────────────
416
416
 
417
417
  function test_previewBestPayRoute_allTerminalsRevert_noRoute() public {
@@ -449,9 +449,8 @@ contract RevertingTerminalRouteDiscoveryTest is Test {
449
449
  abi.encode(address(0))
450
450
  );
451
451
 
452
- // Should revert since no candidates are found.
453
- vm.expectRevert();
454
- resolver.previewBestPayRoute({
452
+ // With the try/catch fallback (F3), the function no longer reverts — it returns zero/empty values.
453
+ (IJBTerminal destTerminal, address resolvedTokenOut,,, uint256 beneficiaryTokenCount,,) = resolver.previewBestPayRoute({
455
454
  router: router,
456
455
  projectId: PROJECT_ID,
457
456
  tokenIn: tokenIn,
@@ -459,5 +458,9 @@ contract RevertingTerminalRouteDiscoveryTest is Test {
459
458
  beneficiary: makeAddr("beneficiary"),
460
459
  metadata: ""
461
460
  });
461
+
462
+ assertEq(address(destTerminal), address(0), "destTerminal should be zero when no route is found");
463
+ assertEq(resolvedTokenOut, address(0), "tokenOut should be zero when no route is found");
464
+ assertEq(beneficiaryTokenCount, 0, "beneficiaryTokenCount should be zero when no route is found");
462
465
  }
463
466
  }