@bananapus/router-terminal-v6 0.0.21 → 0.0.22

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.
@@ -7,7 +7,6 @@ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
7
7
  import {IJBPermissioned} from "@bananapus/core-v6/src/interfaces/IJBPermissioned.sol";
8
8
  import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
9
9
  import {IJBPermitTerminal} from "@bananapus/core-v6/src/interfaces/IJBPermitTerminal.sol";
10
- import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
11
10
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
12
11
  import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
13
12
  import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
@@ -17,6 +16,7 @@ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingCo
17
16
  import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
18
17
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
19
18
  import {JBSingleAllowance} from "@bananapus/core-v6/src/structs/JBSingleAllowance.sol";
19
+ import {mulDiv} from "@prb/math/src/Common.sol";
20
20
  import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
21
21
  import {ERC2771Context} from "@openzeppelin/contracts/metatx/ERC2771Context.sol";
22
22
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
@@ -41,6 +41,7 @@ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
41
41
  import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
42
42
  import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
43
43
 
44
+ import {IGeomeanOracle} from "./interfaces/IGeomeanOracle.sol";
44
45
  import {IJBRouterTerminal} from "./interfaces/IJBRouterTerminal.sol";
45
46
  import {IJBPayerTracker} from "./interfaces/IJBPayerTracker.sol";
46
47
  import {IWETH9} from "./interfaces/IWETH9.sol";
@@ -106,6 +107,9 @@ contract JBRouterTerminal is
106
107
  /// @notice The denominator used for slippage tolerance basis points.
107
108
  uint256 internal constant _SLIPPAGE_DENOMINATOR = 10_000;
108
109
 
110
+ /// @notice The TWAP window (in seconds) used when querying a V4 oracle hook.
111
+ uint32 private constant _TWAP_WINDOW = 30;
112
+
109
113
  //*********************************************************************//
110
114
  // ---------------- public immutable stored properties --------------- //
111
115
  //*********************************************************************//
@@ -113,9 +117,6 @@ contract JBRouterTerminal is
113
117
  /// @notice The directory of terminals and controllers for projects.
114
118
  IJBDirectory public immutable DIRECTORY;
115
119
 
116
- /// @notice Mints ERC-721s that represent project ownership and transfers.
117
- IJBProjects public immutable PROJECTS;
118
-
119
120
  /// @notice Manages minting, burning, and balances of projects' tokens and token credits.
120
121
  IJBTokens public immutable TOKENS;
121
122
 
@@ -152,7 +153,6 @@ contract JBRouterTerminal is
152
153
 
153
154
  /// @param directory A contract storing directories of terminals and controllers for each project.
154
155
  /// @param permissions A contract storing permissions.
155
- /// @param projects A contract which mints ERC-721s that represent project ownership and transfers.
156
156
  /// @param tokens A contract managing project token balances.
157
157
  /// @param permit2 A permit2 utility.
158
158
  /// @param owner The owner of the contract.
@@ -163,7 +163,6 @@ contract JBRouterTerminal is
163
163
  constructor(
164
164
  IJBDirectory directory,
165
165
  IJBPermissions permissions,
166
- IJBProjects projects,
167
166
  IJBTokens tokens,
168
167
  IPermit2 permit2,
169
168
  address owner,
@@ -177,7 +176,6 @@ contract JBRouterTerminal is
177
176
  Ownable(owner)
178
177
  {
179
178
  DIRECTORY = directory;
180
- PROJECTS = projects;
181
179
  TOKENS = tokens;
182
180
  FACTORY = factory;
183
181
  POOL_MANAGER = poolManager;
@@ -306,7 +304,7 @@ contract JBRouterTerminal is
306
304
 
307
305
  // Verify caller is a legitimate pool via the factory.
308
306
  uint24 fee = IUniswapV3Pool(msg.sender).fee();
309
- address expectedPool = FACTORY.getPool({tokenA: normalizedTokenIn, tokenB: normalizedTokenOut, fee: fee});
307
+ address expectedPool = _getPool({tokenA: normalizedTokenIn, tokenB: normalizedTokenOut, fee: fee});
310
308
  if (msg.sender != expectedPool) revert JBRouterTerminal_CallerNotPool(msg.sender);
311
309
 
312
310
  // Calculate the amount of tokens to send to the pool (the positive delta).
@@ -314,7 +312,7 @@ contract JBRouterTerminal is
314
312
  uint256 amountToSendToPool = amount0Delta < 0 ? uint256(amount1Delta) : uint256(amount0Delta);
315
313
 
316
314
  // Wrap native tokens if needed.
317
- if (tokenIn == JBConstants.NATIVE_TOKEN) WETH.deposit{value: amountToSendToPool}();
315
+ if (tokenIn == JBConstants.NATIVE_TOKEN) _wethDeposit(amountToSendToPool);
318
316
 
319
317
  // Transfer the tokens to the pool.
320
318
  IERC20(normalizedTokenIn).safeTransfer({to: msg.sender, value: amountToSendToPool});
@@ -638,14 +636,13 @@ contract JBRouterTerminal is
638
636
  // Check for a user-provided minimum cashout reclaim amount (slippage protection).
639
637
  uint256 minTokensReclaimed = _minReclaimedFrom(metadata);
640
638
 
641
- // Intermediate steps have no per-step slippage protection. The final output amount is
642
- // checked against the user's minReclaimed parameter, providing end-to-end slippage protection. Per-step
643
- // checks would add gas and complexity without improving the overall guarantee.
639
+ // Propagate proportional slippage protection across multi-hop cashouts. Each intermediate step scales the
640
+ // minimum by the ratio of actual output to expected input, maintaining end-to-end slippage guarantees.
644
641
  for (uint256 i; i < _MAX_CASHOUT_ITERATIONS; i++) {
645
642
  // Skip the destination check on the first iteration if we have a credit override.
646
643
  if (sourceProjectIdOverride == 0) {
647
644
  // slither-disable-next-line calls-loop
648
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: destProjectId, token: token});
645
+ destTerminal = _primaryTerminalOf({projectId: destProjectId, token: token});
649
646
  if (address(destTerminal) != address(0) && address(destTerminal) != address(this)) {
650
647
  return (destTerminal, token, amount);
651
648
  }
@@ -653,8 +650,7 @@ contract JBRouterTerminal is
653
650
 
654
651
  // Use the override if provided, otherwise look up the project ID from the token.
655
652
  // slither-disable-next-line calls-loop
656
- uint256 sourceProjectId =
657
- sourceProjectIdOverride != 0 ? sourceProjectIdOverride : TOKENS.projectIdOf(IJBToken(token));
653
+ uint256 sourceProjectId = sourceProjectIdOverride != 0 ? sourceProjectIdOverride : _projectIdOf(token);
658
654
 
659
655
  // If it's not a JB project token, return as-is (caller handles the swap).
660
656
  if (sourceProjectId == 0) return (IJBTerminal(address(0)), token, amount);
@@ -663,20 +659,26 @@ contract JBRouterTerminal is
663
659
  (address tokenToReclaim, IJBCashOutTerminal cashOutTerminal) =
664
660
  _findCashOutPath({sourceProjectId: sourceProjectId, destProjectId: destProjectId});
665
661
 
662
+ // Track the expected amount before cashout so we can scale the minimum proportionally.
663
+ uint256 previousExpectedAmount = amount;
664
+
666
665
  // Cash out the source project's tokens.
667
666
  // slither-disable-next-line calls-loop
668
667
  amount = cashOutTerminal.cashOutTokensOf({
669
668
  holder: address(this),
670
669
  projectId: sourceProjectId,
671
- cashOutCount: amount,
670
+ cashOutCount: previousExpectedAmount,
672
671
  tokenToReclaim: tokenToReclaim,
673
672
  minTokensReclaimed: minTokensReclaimed,
674
673
  beneficiary: payable(address(this)),
675
674
  metadata: ""
676
675
  });
677
676
 
678
- // Only apply the minimum to the first cashout step.
679
- minTokensReclaimed = 0;
677
+ // Scale the minimum proportionally for the next step based on the actual cashout ratio.
678
+ // This propagates slippage protection through multi-hop cashouts instead of dropping it.
679
+ if (minTokensReclaimed != 0 && previousExpectedAmount != 0) {
680
+ minTokensReclaimed = mulDiv(minTokensReclaimed, amount, previousExpectedAmount);
681
+ }
680
682
 
681
683
  // Update for next iteration.
682
684
  token = tokenToReclaim;
@@ -714,8 +716,8 @@ contract JBRouterTerminal is
714
716
 
715
717
  if (nIn == nOut) {
716
718
  // Same underlying token — just wrap or unwrap.
717
- if (tokenIn == JBConstants.NATIVE_TOKEN) WETH.deposit{value: amount}();
718
- else WETH.withdraw(amount);
719
+ if (tokenIn == JBConstants.NATIVE_TOKEN) _wethDeposit(amount);
720
+ else _wethWithdraw(amount);
719
721
  return amount;
720
722
  }
721
723
 
@@ -851,24 +853,21 @@ contract JBRouterTerminal is
851
853
  callbackData: abi.encode(projectId, tokenIn, tokenOut)
852
854
  });
853
855
 
854
- // Any pre-existing ETH in the contract is absorbed into swap leftovers and routed to
855
- // the project. This is acceptable because the contract should not hold ETH between transactions. The
856
- // receive() function or any direct ETH sends are treated as donations.
857
856
  // For native token inputs, wrap any raw ETH remaining from partial fills so the leftover check catches it.
858
857
  // In partial fills, the swap callback only wraps the amount the pool consumed, leaving excess as raw ETH.
859
858
  if (tokenIn == JBConstants.NATIVE_TOKEN && address(this).balance != 0) {
860
- WETH.deposit{value: address(this).balance}();
859
+ _wethDeposit(address(this).balance);
861
860
  }
862
861
 
863
862
  // Unwrap if output is native token.
864
- if (tokenOut == JBConstants.NATIVE_TOKEN) WETH.withdraw(amountOut);
863
+ if (tokenOut == JBConstants.NATIVE_TOKEN) _wethWithdraw(amountOut);
865
864
 
866
- // Return leftover input tokens to payer. The router is stateless — any remaining balance after a swap is
867
- // leftover from a partial fill. Using an absolute balance check (rather than a balance delta) correctly
868
- // handles ERC-20 partial fills where the router already held the full input amount before the swap.
865
+ // Return leftover input tokens to payer. The router is stateless — it should never hold funds between
866
+ // transactions. The full remaining balance is refunded (not a delta) so that any accidentally-sent tokens
867
+ // are recovered rather than permanently stuck. This is intentional: recovery > lockup.
869
868
  uint256 balanceAfter = IERC20(normalizedTokenIn).balanceOf(address(this));
870
869
  if (balanceAfter > 0) {
871
- if (tokenIn == JBConstants.NATIVE_TOKEN) WETH.withdraw(balanceAfter);
870
+ if (tokenIn == JBConstants.NATIVE_TOKEN) _wethWithdraw(balanceAfter);
872
871
  _transferFrom({from: address(this), to: refundTo, token: tokenIn, amount: balanceAfter});
873
872
  }
874
873
  }
@@ -899,7 +898,7 @@ contract JBRouterTerminal is
899
898
  // Check if input is a JB project token (credit or ERC-20).
900
899
  uint256 sourceProjectId = sourceProjectIdOverride;
901
900
  if (sourceProjectId == 0 && tokenIn != JBConstants.NATIVE_TOKEN) {
902
- sourceProjectId = TOKENS.projectIdOf(IJBToken(tokenIn));
901
+ sourceProjectId = _projectIdOf(tokenIn);
903
902
  }
904
903
 
905
904
  if (sourceProjectId != 0) {
@@ -939,7 +938,7 @@ contract JBRouterTerminal is
939
938
  if (Currency.unwrap(currency) == address(0)) {
940
939
  // Unwrap only the WETH deficit (caller may hold partial ETH + partial WETH).
941
940
  uint256 deficit = amount > address(this).balance ? amount - address(this).balance : 0;
942
- if (deficit > 0) WETH.withdraw(deficit);
941
+ if (deficit > 0) _wethWithdraw(deficit);
943
942
  // slither-disable-next-line unused-return
944
943
  POOL_MANAGER.settle{value: amount}();
945
944
  } else {
@@ -956,7 +955,7 @@ contract JBRouterTerminal is
956
955
  POOL_MANAGER.take({currency: currency, to: address(this), amount: amount});
957
956
 
958
957
  // If native ETH output, wrap to WETH (downstream _handleSwap unwraps if needed).
959
- if (Currency.unwrap(currency) == address(0)) WETH.deposit{value: amount}();
958
+ if (Currency.unwrap(currency) == address(0)) _wethDeposit(amount);
960
959
  }
961
960
 
962
961
  /// @notice Transfers tokens.
@@ -984,17 +983,86 @@ contract JBRouterTerminal is
984
983
  PERMIT2.transferFrom({from: from, to: to, amount: uint160(amount), token: token});
985
984
  }
986
985
 
986
+ /// @notice Deposit native tokens into WETH.
987
+ /// @param amount The amount of native tokens to wrap.
988
+ function _wethDeposit(uint256 amount) internal {
989
+ WETH.deposit{value: amount}();
990
+ }
991
+
992
+ /// @notice Withdraw native tokens from WETH.
993
+ /// @param amount The amount of WETH to unwrap.
994
+ function _wethWithdraw(uint256 amount) internal {
995
+ WETH.withdraw(amount);
996
+ }
997
+
987
998
  //*********************************************************************//
988
999
  // ------------------------- internal views -------------------------- //
989
1000
  //*********************************************************************//
990
1001
 
1002
+ /// @notice Look up the best pool address from the V3 factory.
1003
+ /// @param tokenA One token in the pair.
1004
+ /// @param tokenB The other token in the pair.
1005
+ /// @param fee The fee tier to query.
1006
+ /// @return The pool address, or address(0) if none exists.
1007
+ // slither-disable-next-line calls-loop
1008
+ function _getPool(address tokenA, address tokenB, uint24 fee) internal view returns (address) {
1009
+ return FACTORY.getPool({tokenA: tokenA, tokenB: tokenB, fee: fee});
1010
+ }
1011
+
1012
+ /// @notice Look up the in-range liquidity for a V4 pool.
1013
+ /// @param id The pool ID.
1014
+ /// @return The pool's current in-range liquidity.
1015
+ function _getLiquidity(PoolId id) internal view returns (uint128) {
1016
+ return POOL_MANAGER.getLiquidity(id);
1017
+ }
1018
+
1019
+ /// @notice Read slot0 from a V4 pool.
1020
+ /// @param id The pool ID.
1021
+ /// @return sqrtPriceX96 The current sqrt price.
1022
+ /// @return tick The current tick.
1023
+ /// @return protocolFee The protocol fee.
1024
+ /// @return lpFee The LP fee.
1025
+ // slither-disable-next-line unused-return
1026
+ function _getSlot0(PoolId id)
1027
+ internal
1028
+ view
1029
+ returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee)
1030
+ {
1031
+ return POOL_MANAGER.getSlot0(id);
1032
+ }
1033
+
1034
+ /// @notice Look up the primary terminal for a project and token.
1035
+ /// @param projectId The ID of the project.
1036
+ /// @param token The token to look up.
1037
+ /// @return The primary terminal, or IJBTerminal(address(0)) if none.
1038
+ // slither-disable-next-line calls-loop
1039
+ function _primaryTerminalOf(uint256 projectId, address token) internal view returns (IJBTerminal) {
1040
+ return DIRECTORY.primaryTerminalOf({projectId: projectId, token: token});
1041
+ }
1042
+
1043
+ /// @notice Look up the project ID for a token.
1044
+ /// @param token The token address to query.
1045
+ /// @return The project ID, or 0 if the token is not a JB project token.
1046
+ function _projectIdOf(address token) internal view returns (uint256) {
1047
+ return TOKENS.projectIdOf(IJBToken(token));
1048
+ }
1049
+
1050
+ /// @notice Look up all terminals for a project.
1051
+ /// @param projectId The ID of the project.
1052
+ /// @return The array of terminals.
1053
+ // slither-disable-next-line calls-loop
1054
+ function _terminalsOf(uint256 projectId) internal view returns (IJBTerminal[] memory) {
1055
+ return DIRECTORY.terminalsOf(projectId);
1056
+ }
1057
+
991
1058
  /// @notice Find the highest liquidity across all V3 fee tiers and V4 pools for a token pair.
992
1059
  /// @param tokenA One token in the pair.
993
1060
  /// @param tokenB The other token in the pair.
994
1061
  /// @return bestLiquidity The highest liquidity found, or 0 if no pool exists.
1062
+ // slither-disable-next-line calls-loop
995
1063
  function _bestPoolLiquidity(address tokenA, address tokenB) internal view returns (uint128 bestLiquidity) {
996
1064
  PoolInfo memory pool = _discoverPool(tokenA, tokenB);
997
- if (pool.isV4) return POOL_MANAGER.getLiquidity(pool.v4Key.toId());
1065
+ if (pool.isV4) return _getLiquidity(pool.v4Key.toId());
998
1066
  if (address(pool.v3Pool) != address(0)) return pool.v3Pool.liquidity();
999
1067
  }
1000
1068
 
@@ -1035,7 +1103,7 @@ contract JBRouterTerminal is
1035
1103
  returns (address tokenOut, IJBTerminal destTerminal)
1036
1104
  {
1037
1105
  address normalizedTokenIn = _normalize(tokenIn);
1038
- IJBTerminal[] memory terminals = DIRECTORY.terminalsOf(projectId);
1106
+ IJBTerminal[] memory terminals = _terminalsOf(projectId);
1039
1107
 
1040
1108
  uint128 bestLiquidity;
1041
1109
  bool hasFallback;
@@ -1058,7 +1126,8 @@ contract JBRouterTerminal is
1058
1126
  }
1059
1127
 
1060
1128
  // Search for pool with best liquidity (V3 + V4).
1061
- uint128 candidateLiquidity = _bestPoolLiquidity(normalizedTokenIn, normalizedCandidate);
1129
+ uint128 candidateLiquidity =
1130
+ _bestPoolLiquidity({tokenA: normalizedTokenIn, tokenB: normalizedCandidate});
1062
1131
  if (candidateLiquidity > bestLiquidity) {
1063
1132
  bestLiquidity = candidateLiquidity;
1064
1133
  tokenOut = candidateToken;
@@ -1088,8 +1157,7 @@ contract JBRouterTerminal is
1088
1157
  // Search V3.
1089
1158
  for (uint256 i; i < 4; i++) {
1090
1159
  // slither-disable-next-line calls-loop
1091
- address poolAddr =
1092
- FACTORY.getPool({tokenA: normalizedTokenIn, tokenB: normalizedTokenOut, fee: _FEE_TIERS[i]});
1160
+ address poolAddr = _getPool({tokenA: normalizedTokenIn, tokenB: normalizedTokenOut, fee: _FEE_TIERS[i]});
1093
1161
 
1094
1162
  if (poolAddr == address(0)) continue;
1095
1163
 
@@ -1146,12 +1214,12 @@ contract JBRouterTerminal is
1146
1214
  });
1147
1215
 
1148
1216
  // slither-disable-next-line unused-return,calls-loop
1149
- (uint160 sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(key.toId());
1217
+ (uint160 sqrtPriceX96,,,) = _getSlot0(key.toId());
1150
1218
  // slither-disable-next-line incorrect-equality
1151
1219
  if (sqrtPriceX96 == 0) continue;
1152
1220
 
1153
1221
  // slither-disable-next-line calls-loop
1154
- uint128 poolLiquidity = POOL_MANAGER.getLiquidity(key.toId());
1222
+ uint128 poolLiquidity = _getLiquidity(key.toId());
1155
1223
  if (poolLiquidity > currentBestLiquidity) {
1156
1224
  currentBestLiquidity = poolLiquidity;
1157
1225
  bestPool = PoolInfo({isV4: true, v3Pool: IUniswapV3Pool(address(0)), v4Key: key});
@@ -1182,7 +1250,7 @@ contract JBRouterTerminal is
1182
1250
  IJBCashOutTerminal baseFallbackTerminal;
1183
1251
 
1184
1252
  // slither-disable-next-line calls-loop
1185
- IJBTerminal[] memory terminals = DIRECTORY.terminalsOf(sourceProjectId);
1253
+ IJBTerminal[] memory terminals = _terminalsOf(sourceProjectId);
1186
1254
 
1187
1255
  for (uint256 i; i < terminals.length; i++) {
1188
1256
  // Check if this terminal supports the IJBCashOutTerminal interface.
@@ -1204,13 +1272,13 @@ contract JBRouterTerminal is
1204
1272
 
1205
1273
  // Priority 1: Does the destination project directly accept this token?
1206
1274
  // slither-disable-next-line calls-loop
1207
- IJBTerminal destTerminal = DIRECTORY.primaryTerminalOf({projectId: destProjectId, token: contextToken});
1275
+ IJBTerminal destTerminal = _primaryTerminalOf({projectId: destProjectId, token: contextToken});
1208
1276
  if (address(destTerminal) != address(0)) return (contextToken, terminal);
1209
1277
 
1210
1278
  // Priority 2: Is this a JB project token (so we can recurse)?
1211
1279
  if (address(fallbackTerminal) == address(0) && contextToken != JBConstants.NATIVE_TOKEN) {
1212
1280
  // slither-disable-next-line calls-loop
1213
- if (TOKENS.projectIdOf(IJBToken(contextToken)) != 0) {
1281
+ if (_projectIdOf(contextToken) != 0) {
1214
1282
  fallbackToken = contextToken;
1215
1283
  fallbackTerminal = terminal;
1216
1284
  }
@@ -1332,9 +1400,32 @@ contract JBRouterTerminal is
1332
1400
  returns (uint256 minAmountOut)
1333
1401
  {
1334
1402
  PoolId id = key.toId();
1335
- // slither-disable-next-line unused-return
1336
- (, int24 tick,,) = POOL_MANAGER.getSlot0(id);
1337
- uint128 liquidity = POOL_MANAGER.getLiquidity(id);
1403
+ int24 tick;
1404
+ bool usedTwap;
1405
+
1406
+ // Try to use TWAP from the pool's oracle hook (if available) for manipulation resistance.
1407
+ if (address(key.hooks) != address(0)) {
1408
+ uint32[] memory secondsAgos = new uint32[](2);
1409
+ secondsAgos[0] = _TWAP_WINDOW;
1410
+ secondsAgos[1] = 0;
1411
+ // slither-disable-next-line unused-return
1412
+ try IGeomeanOracle(address(key.hooks)).observe(key, secondsAgos) returns (
1413
+ int56[] memory tickCumulatives, uint160[] memory
1414
+ ) {
1415
+ // Compute the arithmetic mean tick from the TWAP window.
1416
+ // forge-lint: disable-next-line(unsafe-typecast)
1417
+ tick = int24((tickCumulatives[1] - tickCumulatives[0]) / int56(int32(_TWAP_WINDOW)));
1418
+ usedTwap = true;
1419
+ } catch {}
1420
+ }
1421
+
1422
+ // Fall back to spot price if TWAP was not available.
1423
+ if (!usedTwap) {
1424
+ // slither-disable-next-line unused-return
1425
+ (, tick,,) = _getSlot0(id);
1426
+ }
1427
+
1428
+ uint128 liquidity = _getLiquidity(id);
1338
1429
 
1339
1430
  if (liquidity == 0) revert JBRouterTerminal_NoLiquidity();
1340
1431
 
@@ -1479,7 +1570,7 @@ contract JBRouterTerminal is
1479
1570
  // Only probe direct destination acceptance when there is no one-shot source override to consume first.
1480
1571
  if (sourceProjectIdOverride == 0) {
1481
1572
  // Ask the directory whether the destination already has a primary terminal for the current token.
1482
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: destProjectId, token: token});
1573
+ destTerminal = _primaryTerminalOf({projectId: destProjectId, token: token});
1483
1574
 
1484
1575
  // If a real external terminal accepts this token, the preview route is complete and exact.
1485
1576
  if (address(destTerminal) != address(0) && address(destTerminal) != address(this)) {
@@ -1488,8 +1579,7 @@ contract JBRouterTerminal is
1488
1579
  }
1489
1580
 
1490
1581
  // Use the override once when present; otherwise infer the source project from the current JB token.
1491
- uint256 sourceProjectId =
1492
- sourceProjectIdOverride != 0 ? sourceProjectIdOverride : TOKENS.projectIdOf(IJBToken(token));
1582
+ uint256 sourceProjectId = sourceProjectIdOverride != 0 ? sourceProjectIdOverride : _projectIdOf(token);
1493
1583
 
1494
1584
  // If this is no longer a JB project token, stop cashing out and let the caller continue routing from it.
1495
1585
  if (sourceProjectId == 0) return (IJBTerminal(address(0)), token, amount);
@@ -1497,15 +1587,21 @@ contract JBRouterTerminal is
1497
1587
  // Hold the token produced by the next previewed cashout hop.
1498
1588
  address tokenToReclaim;
1499
1589
 
1590
+ // Track the expected amount before cashout so we can scale the minimum proportionally.
1591
+ uint256 previousExpectedAmount = amount;
1592
+
1500
1593
  // Preview the next cashout hop to learn which base token and amount would come out.
1501
- (tokenToReclaim, amount) =
1502
- _previewCashOutStep({sourceProjectId: sourceProjectId, destProjectId: destProjectId, amount: amount});
1594
+ (tokenToReclaim, amount) = _previewCashOutStep({
1595
+ sourceProjectId: sourceProjectId, destProjectId: destProjectId, amount: previousExpectedAmount
1596
+ });
1503
1597
 
1504
- // Enforce the caller's minimum reclaim amount on the first hop only.
1598
+ // Enforce the caller's minimum reclaim amount on this hop.
1505
1599
  if (amount < minTokensReclaimed) revert JBRouterTerminal_SlippageExceeded(amount, minTokensReclaimed);
1506
1600
 
1507
- // Clear the first-hop minimum so deeper hops are evaluated without per-step slippage guards.
1508
- minTokensReclaimed = 0;
1601
+ // Scale the minimum proportionally for the next step based on the actual cashout ratio.
1602
+ if (minTokensReclaimed != 0 && previousExpectedAmount != 0) {
1603
+ minTokensReclaimed = mulDiv(minTokensReclaimed, amount, previousExpectedAmount);
1604
+ }
1509
1605
 
1510
1606
  // Continue previewing from the token reclaimed in this hop.
1511
1607
  token = tokenToReclaim;
@@ -1541,7 +1637,7 @@ contract JBRouterTerminal is
1541
1637
  _findCashOutPath({sourceProjectId: sourceProjectId, destProjectId: destProjectId});
1542
1638
 
1543
1639
  // Ask that terminal how much of the reclaim token this cashout count would return.
1544
- // slither-disable-next-line unused-return
1640
+ // slither-disable-next-line unused-return,calls-loop
1545
1641
  (, reclaimAmount,,) = cashOutTerminal.previewCashOutFrom({
1546
1642
  holder: address(this),
1547
1643
  projectId: sourceProjectId,
@@ -1581,7 +1677,7 @@ contract JBRouterTerminal is
1581
1677
 
1582
1678
  // Otherwise, infer the source project from the input token when it is not the native-token sentinel.
1583
1679
  if (sourceProjectId == 0 && tokenIn != JBConstants.NATIVE_TOKEN) {
1584
- sourceProjectId = TOKENS.projectIdOf(IJBToken(tokenIn));
1680
+ sourceProjectId = _projectIdOf(tokenIn);
1585
1681
  }
1586
1682
 
1587
1683
  // JB project tokens and credit routes must be previewed through the cash-out path first.
@@ -1689,13 +1785,13 @@ contract JBRouterTerminal is
1689
1785
  JBMetadataResolver.getDataFor({id: JBMetadataResolver.getId("routeTokenOut"), metadata: metadata});
1690
1786
  if (exists) {
1691
1787
  (tokenOut) = abi.decode(routeData, (address));
1692
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: tokenOut});
1788
+ destTerminal = _primaryTerminalOf({projectId: projectId, token: tokenOut});
1693
1789
  if (address(destTerminal) == address(0)) revert JBRouterTerminal_NoRouteFound(projectId, tokenOut);
1694
1790
  return (tokenOut, destTerminal);
1695
1791
  }
1696
1792
 
1697
1793
  // 2. Direct acceptance — project accepts tokenIn as-is.
1698
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: tokenIn});
1794
+ destTerminal = _primaryTerminalOf({projectId: projectId, token: tokenIn});
1699
1795
  if (address(destTerminal) != address(0) && destTerminal.accountingContextsOf(projectId).length != 0) {
1700
1796
  return (tokenIn, destTerminal);
1701
1797
  }
@@ -1703,7 +1799,7 @@ contract JBRouterTerminal is
1703
1799
  // 2b. Check NATIVE_TOKEN <-> WETH equivalence.
1704
1800
  if (tokenIn == JBConstants.NATIVE_TOKEN || tokenIn == address(WETH)) {
1705
1801
  tokenOut = tokenIn == JBConstants.NATIVE_TOKEN ? address(WETH) : JBConstants.NATIVE_TOKEN;
1706
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: tokenOut});
1802
+ destTerminal = _primaryTerminalOf({projectId: projectId, token: tokenOut});
1707
1803
  if (address(destTerminal) != address(0)) return (tokenOut, destTerminal);
1708
1804
  }
1709
1805
 
@@ -108,13 +108,6 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
108
108
  PERMIT2 = permit2;
109
109
  }
110
110
 
111
- //*********************************************************************//
112
- // ------------------------- receive -------------------------------- //
113
- //*********************************************************************//
114
-
115
- /// @notice Accept native token refunds from the router on partial swap fills.
116
- receive() external payable {}
117
-
118
111
  //*********************************************************************//
119
112
  // ------------------------- external views -------------------------- //
120
113
  //*********************************************************************//
@@ -259,6 +252,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
259
252
  /// @param shouldReturnHeldFees A boolean to indicate whether held fees should be returned.
260
253
  /// @param memo A memo to pass along to the emitted event.
261
254
  /// @param metadata Bytes in `JBMetadataResolver`'s format.
255
+ // slither-disable-next-line reentrancy-benign
262
256
  function addToBalanceOf(
263
257
  uint256 projectId,
264
258
  address token,
@@ -374,6 +368,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
374
368
  /// @param memo A memo to pass along to the emitted event.
375
369
  /// @param metadata Bytes in `JBMetadataResolver`'s format.
376
370
  /// @return result The number of tokens received.
371
+ // slither-disable-next-line reentrancy-benign
377
372
  function pay(
378
373
  uint256 projectId,
379
374
  address token,
@@ -464,6 +459,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
464
459
  /// @param amount The amount of tokens being paid in.
465
460
  /// @param metadata The metadata in which `permit2` context is provided.
466
461
  /// @return amount The amount of tokens that have been accepted.
462
+ // slither-disable-next-line reentrancy-events
467
463
  function _acceptFundsFor(address token, uint256 amount, bytes calldata metadata) internal returns (uint256) {
468
464
  // If native tokens are being paid in, return the `msg.value`.
469
465
  if (token == JBConstants.NATIVE_TOKEN) return msg.value;
@@ -0,0 +1,21 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.0;
3
+
4
+ import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
5
+
6
+ /// @notice Minimal interface for a Uniswap V4 TWAP oracle hook (e.g. GeomeanOracle).
7
+ /// @dev Used by JBRouterTerminal to attempt TWAP-based quoting on V4 pools whose hook supports it.
8
+ interface IGeomeanOracle {
9
+ /// @notice Returns cumulative tick and seconds-per-liquidity values for the given observation timestamps.
10
+ /// @param key The pool key to observe.
11
+ /// @param secondsAgos An array of seconds-ago offsets (e.g., [30, 0] for a 30-second TWAP).
12
+ /// @return tickCumulatives The cumulative tick values at each observation point.
13
+ /// @return secondsPerLiquidityCumulativeX128s The cumulative seconds-per-liquidity values.
14
+ function observe(
15
+ PoolKey calldata key,
16
+ uint32[] calldata secondsAgos
17
+ )
18
+ external
19
+ view
20
+ returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s);
21
+ }