@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 +28 -97
- package/package.json +1 -1
- package/src/JBPayRouteResolver.sol +172 -99
- package/src/JBRouterTerminal.sol +56 -23
- package/src/JBRouterTerminalRegistry.sol +12 -6
- package/src/interfaces/IJBPayRouteResolver.sol +36 -0
- package/test/NegativeTickRounding.t.sol +130 -0
- package/test/RouterTerminalERC2771.t.sol +6 -4
- package/test/TestAuditGaps.sol +25 -11
- package/test/audit/MultiHopForwardCycle.t.sol +4 -2
- package/test/audit/RevertingTerminalRouteDiscovery.t.sol +8 -5
package/RISKS.md
CHANGED
|
@@ -1,113 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Accepted Security Risks
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Documented risks that were reviewed and accepted.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Oracle & Slippage Risks
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
27
|
+
## Registry & Forwarding Risks
|
|
41
28
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
##
|
|
38
|
+
## Minor Configuration Risks
|
|
59
39
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
@@ -340,20 +340,30 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
340
340
|
view
|
|
341
341
|
returns (bool isCircular)
|
|
342
342
|
{
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
address(
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
//
|
|
356
|
-
|
|
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
|
-
//
|
|
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:
|
|
929
|
+
router: routePreviewer, destProjectId: destProjectId, tokenIn: tokenIn, amount: amountIn, metadata: metadata
|
|
936
930
|
});
|
|
937
931
|
|
|
938
|
-
//
|
|
939
|
-
(ruleset, beneficiaryTokenCount, reservedTokenCount, hookSpecifications) =
|
|
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:
|
|
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
|
|
942
|
+
// Normalize counts to account for buyback-hook overrides.
|
|
949
943
|
(beneficiaryTokenCount, reservedTokenCount) = _effectivePreviewPayTokenCounts({
|
|
950
|
-
buybackHook:
|
|
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
|
}
|
package/src/JBRouterTerminal.sol
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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
|
-
//
|
|
1381
|
-
//
|
|
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
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
//
|
|
2339
|
-
//
|
|
2340
|
-
|
|
2341
|
-
|
|
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
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
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
|
-
//
|
|
324
|
-
originalPayer =
|
|
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
|
-
//
|
|
456
|
-
originalPayer =
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
277
|
+
abi.encode(IJBTerminal(address(1)))
|
|
276
278
|
);
|
|
277
279
|
|
|
278
280
|
// Mock terminalsOf so the _routeForPay path finds the project's terminals.
|
package/test/TestAuditGaps.sol
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
356
|
+
// Mock terminalOf so _isForwardingTerminal returns true and the 5-hop circular check ends cleanly.
|
|
355
357
|
vm.mockCall(
|
|
356
|
-
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,
|
|
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(
|
|
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
|
|
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) =>
|
|
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(
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
414
|
-
//
|
|
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
|
-
//
|
|
453
|
-
|
|
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
|
}
|