@bananapus/router-terminal-v6 0.0.21 → 0.0.23

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});
@@ -545,9 +543,18 @@ contract JBRouterTerminal is
545
543
 
546
544
  (uint256 sourceProjectId, uint256 creditAmount) = abi.decode(creditData, (uint256, uint256));
547
545
 
548
- // Pull credits from the payer (requires payer to have granted TRANSFER_CREDITS to this contract).
546
+ // Resolve the actual credit holder. When called via an intermediary (e.g. JBRouterTerminalRegistry),
547
+ // _msgSender() is the intermediary — use its originalPayer to find the true holder.
548
+ address holder = _msgSender();
549
+ if (msg.sender.code.length > 0) {
550
+ try IJBPayerTracker(msg.sender).originalPayer() returns (address payer) {
551
+ if (payer != address(0)) holder = payer;
552
+ } catch {}
553
+ }
554
+
555
+ // Pull credits from the holder (requires holder to have granted TRANSFER_CREDITS to this contract).
549
556
  TOKENS.transferCreditsFrom({
550
- holder: _msgSender(), projectId: sourceProjectId, recipient: address(this), count: creditAmount
557
+ holder: holder, projectId: sourceProjectId, recipient: address(this), count: creditAmount
551
558
  });
552
559
 
553
560
  return creditAmount;
@@ -638,14 +645,13 @@ contract JBRouterTerminal is
638
645
  // Check for a user-provided minimum cashout reclaim amount (slippage protection).
639
646
  uint256 minTokensReclaimed = _minReclaimedFrom(metadata);
640
647
 
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.
648
+ // Propagate proportional slippage protection across multi-hop cashouts. Each intermediate step scales the
649
+ // minimum by the ratio of actual output to expected input, maintaining end-to-end slippage guarantees.
644
650
  for (uint256 i; i < _MAX_CASHOUT_ITERATIONS; i++) {
645
651
  // Skip the destination check on the first iteration if we have a credit override.
646
652
  if (sourceProjectIdOverride == 0) {
647
653
  // slither-disable-next-line calls-loop
648
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: destProjectId, token: token});
654
+ destTerminal = _primaryTerminalOf({projectId: destProjectId, token: token});
649
655
  if (address(destTerminal) != address(0) && address(destTerminal) != address(this)) {
650
656
  return (destTerminal, token, amount);
651
657
  }
@@ -653,8 +659,7 @@ contract JBRouterTerminal is
653
659
 
654
660
  // Use the override if provided, otherwise look up the project ID from the token.
655
661
  // slither-disable-next-line calls-loop
656
- uint256 sourceProjectId =
657
- sourceProjectIdOverride != 0 ? sourceProjectIdOverride : TOKENS.projectIdOf(IJBToken(token));
662
+ uint256 sourceProjectId = sourceProjectIdOverride != 0 ? sourceProjectIdOverride : _projectIdOf(token);
658
663
 
659
664
  // If it's not a JB project token, return as-is (caller handles the swap).
660
665
  if (sourceProjectId == 0) return (IJBTerminal(address(0)), token, amount);
@@ -663,20 +668,28 @@ contract JBRouterTerminal is
663
668
  (address tokenToReclaim, IJBCashOutTerminal cashOutTerminal) =
664
669
  _findCashOutPath({sourceProjectId: sourceProjectId, destProjectId: destProjectId});
665
670
 
671
+ // Track the expected amount before cashout so we can scale the minimum proportionally.
672
+ uint256 previousExpectedAmount = amount;
673
+
666
674
  // Cash out the source project's tokens.
667
675
  // slither-disable-next-line calls-loop
668
676
  amount = cashOutTerminal.cashOutTokensOf({
669
677
  holder: address(this),
670
678
  projectId: sourceProjectId,
671
- cashOutCount: amount,
679
+ cashOutCount: previousExpectedAmount,
672
680
  tokenToReclaim: tokenToReclaim,
673
681
  minTokensReclaimed: minTokensReclaimed,
674
682
  beneficiary: payable(address(this)),
675
683
  metadata: ""
676
684
  });
677
685
 
678
- // Only apply the minimum to the first cashout step.
679
- minTokensReclaimed = 0;
686
+ // Scale the minimum proportionally for the next step based on the actual cashout ratio.
687
+ // This propagates slippage protection through multi-hop cashouts instead of dropping it.
688
+ if (minTokensReclaimed != 0 && previousExpectedAmount != 0) {
689
+ minTokensReclaimed = mulDiv(minTokensReclaimed, amount, previousExpectedAmount);
690
+ // minTokensReclaimed may round to 0 here — that is intentional.
691
+ // A 0 minimum is valid and means no slippage protection for this hop.
692
+ }
680
693
 
681
694
  // Update for next iteration.
682
695
  token = tokenToReclaim;
@@ -714,8 +727,8 @@ contract JBRouterTerminal is
714
727
 
715
728
  if (nIn == nOut) {
716
729
  // Same underlying token — just wrap or unwrap.
717
- if (tokenIn == JBConstants.NATIVE_TOKEN) WETH.deposit{value: amount}();
718
- else WETH.withdraw(amount);
730
+ if (tokenIn == JBConstants.NATIVE_TOKEN) _wethDeposit(amount);
731
+ else _wethWithdraw(amount);
719
732
  return amount;
720
733
  }
721
734
 
@@ -851,24 +864,21 @@ contract JBRouterTerminal is
851
864
  callbackData: abi.encode(projectId, tokenIn, tokenOut)
852
865
  });
853
866
 
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
867
  // For native token inputs, wrap any raw ETH remaining from partial fills so the leftover check catches it.
858
868
  // In partial fills, the swap callback only wraps the amount the pool consumed, leaving excess as raw ETH.
859
869
  if (tokenIn == JBConstants.NATIVE_TOKEN && address(this).balance != 0) {
860
- WETH.deposit{value: address(this).balance}();
870
+ _wethDeposit(address(this).balance);
861
871
  }
862
872
 
863
873
  // Unwrap if output is native token.
864
- if (tokenOut == JBConstants.NATIVE_TOKEN) WETH.withdraw(amountOut);
874
+ if (tokenOut == JBConstants.NATIVE_TOKEN) _wethWithdraw(amountOut);
865
875
 
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.
876
+ // Return leftover input tokens to payer. The router is stateless — it should never hold funds between
877
+ // transactions. The full remaining balance is refunded (not a delta) so that any accidentally-sent tokens
878
+ // are recovered rather than permanently stuck. This is intentional: recovery > lockup.
869
879
  uint256 balanceAfter = IERC20(normalizedTokenIn).balanceOf(address(this));
870
880
  if (balanceAfter > 0) {
871
- if (tokenIn == JBConstants.NATIVE_TOKEN) WETH.withdraw(balanceAfter);
881
+ if (tokenIn == JBConstants.NATIVE_TOKEN) _wethWithdraw(balanceAfter);
872
882
  _transferFrom({from: address(this), to: refundTo, token: tokenIn, amount: balanceAfter});
873
883
  }
874
884
  }
@@ -899,7 +909,7 @@ contract JBRouterTerminal is
899
909
  // Check if input is a JB project token (credit or ERC-20).
900
910
  uint256 sourceProjectId = sourceProjectIdOverride;
901
911
  if (sourceProjectId == 0 && tokenIn != JBConstants.NATIVE_TOKEN) {
902
- sourceProjectId = TOKENS.projectIdOf(IJBToken(tokenIn));
912
+ sourceProjectId = _projectIdOf(tokenIn);
903
913
  }
904
914
 
905
915
  if (sourceProjectId != 0) {
@@ -939,7 +949,7 @@ contract JBRouterTerminal is
939
949
  if (Currency.unwrap(currency) == address(0)) {
940
950
  // Unwrap only the WETH deficit (caller may hold partial ETH + partial WETH).
941
951
  uint256 deficit = amount > address(this).balance ? amount - address(this).balance : 0;
942
- if (deficit > 0) WETH.withdraw(deficit);
952
+ if (deficit > 0) _wethWithdraw(deficit);
943
953
  // slither-disable-next-line unused-return
944
954
  POOL_MANAGER.settle{value: amount}();
945
955
  } else {
@@ -956,7 +966,7 @@ contract JBRouterTerminal is
956
966
  POOL_MANAGER.take({currency: currency, to: address(this), amount: amount});
957
967
 
958
968
  // If native ETH output, wrap to WETH (downstream _handleSwap unwraps if needed).
959
- if (Currency.unwrap(currency) == address(0)) WETH.deposit{value: amount}();
969
+ if (Currency.unwrap(currency) == address(0)) _wethDeposit(amount);
960
970
  }
961
971
 
962
972
  /// @notice Transfers tokens.
@@ -984,17 +994,86 @@ contract JBRouterTerminal is
984
994
  PERMIT2.transferFrom({from: from, to: to, amount: uint160(amount), token: token});
985
995
  }
986
996
 
997
+ /// @notice Deposit native tokens into WETH.
998
+ /// @param amount The amount of native tokens to wrap.
999
+ function _wethDeposit(uint256 amount) internal {
1000
+ WETH.deposit{value: amount}();
1001
+ }
1002
+
1003
+ /// @notice Withdraw native tokens from WETH.
1004
+ /// @param amount The amount of WETH to unwrap.
1005
+ function _wethWithdraw(uint256 amount) internal {
1006
+ WETH.withdraw(amount);
1007
+ }
1008
+
987
1009
  //*********************************************************************//
988
1010
  // ------------------------- internal views -------------------------- //
989
1011
  //*********************************************************************//
990
1012
 
1013
+ /// @notice Look up the best pool address from the V3 factory.
1014
+ /// @param tokenA One token in the pair.
1015
+ /// @param tokenB The other token in the pair.
1016
+ /// @param fee The fee tier to query.
1017
+ /// @return The pool address, or address(0) if none exists.
1018
+ // slither-disable-next-line calls-loop
1019
+ function _getPool(address tokenA, address tokenB, uint24 fee) internal view returns (address) {
1020
+ return FACTORY.getPool({tokenA: tokenA, tokenB: tokenB, fee: fee});
1021
+ }
1022
+
1023
+ /// @notice Look up the in-range liquidity for a V4 pool.
1024
+ /// @param id The pool ID.
1025
+ /// @return The pool's current in-range liquidity.
1026
+ function _getLiquidity(PoolId id) internal view returns (uint128) {
1027
+ return POOL_MANAGER.getLiquidity(id);
1028
+ }
1029
+
1030
+ /// @notice Read slot0 from a V4 pool.
1031
+ /// @param id The pool ID.
1032
+ /// @return sqrtPriceX96 The current sqrt price.
1033
+ /// @return tick The current tick.
1034
+ /// @return protocolFee The protocol fee.
1035
+ /// @return lpFee The LP fee.
1036
+ // slither-disable-next-line unused-return
1037
+ function _getSlot0(PoolId id)
1038
+ internal
1039
+ view
1040
+ returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee)
1041
+ {
1042
+ return POOL_MANAGER.getSlot0(id);
1043
+ }
1044
+
1045
+ /// @notice Look up the primary terminal for a project and token.
1046
+ /// @param projectId The ID of the project.
1047
+ /// @param token The token to look up.
1048
+ /// @return The primary terminal, or IJBTerminal(address(0)) if none.
1049
+ // slither-disable-next-line calls-loop
1050
+ function _primaryTerminalOf(uint256 projectId, address token) internal view returns (IJBTerminal) {
1051
+ return DIRECTORY.primaryTerminalOf({projectId: projectId, token: token});
1052
+ }
1053
+
1054
+ /// @notice Look up the project ID for a token.
1055
+ /// @param token The token address to query.
1056
+ /// @return The project ID, or 0 if the token is not a JB project token.
1057
+ function _projectIdOf(address token) internal view returns (uint256) {
1058
+ return TOKENS.projectIdOf(IJBToken(token));
1059
+ }
1060
+
1061
+ /// @notice Look up all terminals for a project.
1062
+ /// @param projectId The ID of the project.
1063
+ /// @return The array of terminals.
1064
+ // slither-disable-next-line calls-loop
1065
+ function _terminalsOf(uint256 projectId) internal view returns (IJBTerminal[] memory) {
1066
+ return DIRECTORY.terminalsOf(projectId);
1067
+ }
1068
+
991
1069
  /// @notice Find the highest liquidity across all V3 fee tiers and V4 pools for a token pair.
992
1070
  /// @param tokenA One token in the pair.
993
1071
  /// @param tokenB The other token in the pair.
994
1072
  /// @return bestLiquidity The highest liquidity found, or 0 if no pool exists.
1073
+ // slither-disable-next-line calls-loop
995
1074
  function _bestPoolLiquidity(address tokenA, address tokenB) internal view returns (uint128 bestLiquidity) {
996
1075
  PoolInfo memory pool = _discoverPool(tokenA, tokenB);
997
- if (pool.isV4) return POOL_MANAGER.getLiquidity(pool.v4Key.toId());
1076
+ if (pool.isV4) return _getLiquidity(pool.v4Key.toId());
998
1077
  if (address(pool.v3Pool) != address(0)) return pool.v3Pool.liquidity();
999
1078
  }
1000
1079
 
@@ -1035,7 +1114,7 @@ contract JBRouterTerminal is
1035
1114
  returns (address tokenOut, IJBTerminal destTerminal)
1036
1115
  {
1037
1116
  address normalizedTokenIn = _normalize(tokenIn);
1038
- IJBTerminal[] memory terminals = DIRECTORY.terminalsOf(projectId);
1117
+ IJBTerminal[] memory terminals = _terminalsOf(projectId);
1039
1118
 
1040
1119
  uint128 bestLiquidity;
1041
1120
  bool hasFallback;
@@ -1058,7 +1137,8 @@ contract JBRouterTerminal is
1058
1137
  }
1059
1138
 
1060
1139
  // Search for pool with best liquidity (V3 + V4).
1061
- uint128 candidateLiquidity = _bestPoolLiquidity(normalizedTokenIn, normalizedCandidate);
1140
+ uint128 candidateLiquidity =
1141
+ _bestPoolLiquidity({tokenA: normalizedTokenIn, tokenB: normalizedCandidate});
1062
1142
  if (candidateLiquidity > bestLiquidity) {
1063
1143
  bestLiquidity = candidateLiquidity;
1064
1144
  tokenOut = candidateToken;
@@ -1088,8 +1168,7 @@ contract JBRouterTerminal is
1088
1168
  // Search V3.
1089
1169
  for (uint256 i; i < 4; i++) {
1090
1170
  // slither-disable-next-line calls-loop
1091
- address poolAddr =
1092
- FACTORY.getPool({tokenA: normalizedTokenIn, tokenB: normalizedTokenOut, fee: _FEE_TIERS[i]});
1171
+ address poolAddr = _getPool({tokenA: normalizedTokenIn, tokenB: normalizedTokenOut, fee: _FEE_TIERS[i]});
1093
1172
 
1094
1173
  if (poolAddr == address(0)) continue;
1095
1174
 
@@ -1146,12 +1225,12 @@ contract JBRouterTerminal is
1146
1225
  });
1147
1226
 
1148
1227
  // slither-disable-next-line unused-return,calls-loop
1149
- (uint160 sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(key.toId());
1228
+ (uint160 sqrtPriceX96,,,) = _getSlot0(key.toId());
1150
1229
  // slither-disable-next-line incorrect-equality
1151
1230
  if (sqrtPriceX96 == 0) continue;
1152
1231
 
1153
1232
  // slither-disable-next-line calls-loop
1154
- uint128 poolLiquidity = POOL_MANAGER.getLiquidity(key.toId());
1233
+ uint128 poolLiquidity = _getLiquidity(key.toId());
1155
1234
  if (poolLiquidity > currentBestLiquidity) {
1156
1235
  currentBestLiquidity = poolLiquidity;
1157
1236
  bestPool = PoolInfo({isV4: true, v3Pool: IUniswapV3Pool(address(0)), v4Key: key});
@@ -1182,7 +1261,7 @@ contract JBRouterTerminal is
1182
1261
  IJBCashOutTerminal baseFallbackTerminal;
1183
1262
 
1184
1263
  // slither-disable-next-line calls-loop
1185
- IJBTerminal[] memory terminals = DIRECTORY.terminalsOf(sourceProjectId);
1264
+ IJBTerminal[] memory terminals = _terminalsOf(sourceProjectId);
1186
1265
 
1187
1266
  for (uint256 i; i < terminals.length; i++) {
1188
1267
  // Check if this terminal supports the IJBCashOutTerminal interface.
@@ -1204,13 +1283,13 @@ contract JBRouterTerminal is
1204
1283
 
1205
1284
  // Priority 1: Does the destination project directly accept this token?
1206
1285
  // slither-disable-next-line calls-loop
1207
- IJBTerminal destTerminal = DIRECTORY.primaryTerminalOf({projectId: destProjectId, token: contextToken});
1286
+ IJBTerminal destTerminal = _primaryTerminalOf({projectId: destProjectId, token: contextToken});
1208
1287
  if (address(destTerminal) != address(0)) return (contextToken, terminal);
1209
1288
 
1210
1289
  // Priority 2: Is this a JB project token (so we can recurse)?
1211
1290
  if (address(fallbackTerminal) == address(0) && contextToken != JBConstants.NATIVE_TOKEN) {
1212
1291
  // slither-disable-next-line calls-loop
1213
- if (TOKENS.projectIdOf(IJBToken(contextToken)) != 0) {
1292
+ if (_projectIdOf(contextToken) != 0) {
1214
1293
  fallbackToken = contextToken;
1215
1294
  fallbackTerminal = terminal;
1216
1295
  }
@@ -1332,9 +1411,39 @@ contract JBRouterTerminal is
1332
1411
  returns (uint256 minAmountOut)
1333
1412
  {
1334
1413
  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);
1414
+
1415
+ // The tick used for quoting — prefer TWAP over spot for MEV resistance.
1416
+ int24 tick;
1417
+
1418
+ // Track whether the oracle hook provided a TWAP so we know whether to fall back to spot.
1419
+ bool usedTwap;
1420
+
1421
+ // If the pool has a hook, try querying it as a geomean oracle (e.g., JBUniswapV4Hook implements this).
1422
+ if (address(key.hooks) != address(0)) {
1423
+ // Build the two-element lookback array: [_TWAP_WINDOW seconds ago, now].
1424
+ uint32[] memory secondsAgos = new uint32[](2);
1425
+ secondsAgos[0] = _TWAP_WINDOW; // Start of the window (30 seconds ago).
1426
+ secondsAgos[1] = 0; // End of the window (current block).
1427
+
1428
+ // Ask the hook for cumulative tick data over the window. Silently catch if it doesn't support it.
1429
+ // slither-disable-next-line unused-return
1430
+ try IGeomeanOracle(address(key.hooks)).observe(key, secondsAgos) returns (
1431
+ int56[] memory tickCumulatives, uint160[] memory
1432
+ ) {
1433
+ // Derive the arithmetic mean tick: (cumulative_now - cumulative_start) / elapsed_seconds.
1434
+ // forge-lint: disable-next-line(unsafe-typecast)
1435
+ tick = int24((tickCumulatives[1] - tickCumulatives[0]) / int56(int32(_TWAP_WINDOW)));
1436
+ usedTwap = true;
1437
+ } catch {}
1438
+ }
1439
+
1440
+ // If no TWAP was available (no hook, or hook doesn't implement observe), use the instantaneous spot tick.
1441
+ if (!usedTwap) {
1442
+ // slither-disable-next-line unused-return
1443
+ (, tick,,) = _getSlot0(id);
1444
+ }
1445
+
1446
+ uint128 liquidity = _getLiquidity(id);
1338
1447
 
1339
1448
  if (liquidity == 0) revert JBRouterTerminal_NoLiquidity();
1340
1449
 
@@ -1418,6 +1527,12 @@ contract JBRouterTerminal is
1418
1527
 
1419
1528
  if (exists) {
1420
1529
  (minAmountOut) = abi.decode(quote, (uint256));
1530
+ }
1531
+
1532
+ // Treat a decoded value of 0 the same as "not provided" so that a stale or default-zero quote
1533
+ // does not silently disable slippage protection. Fall through to automatic quoting.
1534
+ if (minAmountOut != 0) {
1535
+ // User-provided quote is valid; skip automatic quoting.
1421
1536
  } else if (pool.isV4) {
1422
1537
  minAmountOut = _getV4SpotQuote({
1423
1538
  key: pool.v4Key,
@@ -1479,7 +1594,7 @@ contract JBRouterTerminal is
1479
1594
  // Only probe direct destination acceptance when there is no one-shot source override to consume first.
1480
1595
  if (sourceProjectIdOverride == 0) {
1481
1596
  // Ask the directory whether the destination already has a primary terminal for the current token.
1482
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: destProjectId, token: token});
1597
+ destTerminal = _primaryTerminalOf({projectId: destProjectId, token: token});
1483
1598
 
1484
1599
  // If a real external terminal accepts this token, the preview route is complete and exact.
1485
1600
  if (address(destTerminal) != address(0) && address(destTerminal) != address(this)) {
@@ -1488,8 +1603,7 @@ contract JBRouterTerminal is
1488
1603
  }
1489
1604
 
1490
1605
  // 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));
1606
+ uint256 sourceProjectId = sourceProjectIdOverride != 0 ? sourceProjectIdOverride : _projectIdOf(token);
1493
1607
 
1494
1608
  // If this is no longer a JB project token, stop cashing out and let the caller continue routing from it.
1495
1609
  if (sourceProjectId == 0) return (IJBTerminal(address(0)), token, amount);
@@ -1497,15 +1611,23 @@ contract JBRouterTerminal is
1497
1611
  // Hold the token produced by the next previewed cashout hop.
1498
1612
  address tokenToReclaim;
1499
1613
 
1614
+ // Track the expected amount before cashout so we can scale the minimum proportionally.
1615
+ uint256 previousExpectedAmount = amount;
1616
+
1500
1617
  // 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});
1618
+ (tokenToReclaim, amount) = _previewCashOutStep({
1619
+ sourceProjectId: sourceProjectId, destProjectId: destProjectId, amount: previousExpectedAmount
1620
+ });
1503
1621
 
1504
- // Enforce the caller's minimum reclaim amount on the first hop only.
1622
+ // Enforce the caller's minimum reclaim amount on this hop.
1505
1623
  if (amount < minTokensReclaimed) revert JBRouterTerminal_SlippageExceeded(amount, minTokensReclaimed);
1506
1624
 
1507
- // Clear the first-hop minimum so deeper hops are evaluated without per-step slippage guards.
1508
- minTokensReclaimed = 0;
1625
+ // Scale the minimum proportionally for the next step based on the actual cashout ratio.
1626
+ if (minTokensReclaimed != 0 && previousExpectedAmount != 0) {
1627
+ minTokensReclaimed = mulDiv(minTokensReclaimed, amount, previousExpectedAmount);
1628
+ // minTokensReclaimed may round to 0 here — that is intentional.
1629
+ // A 0 minimum is valid and means no slippage protection for this hop.
1630
+ }
1509
1631
 
1510
1632
  // Continue previewing from the token reclaimed in this hop.
1511
1633
  token = tokenToReclaim;
@@ -1541,7 +1663,7 @@ contract JBRouterTerminal is
1541
1663
  _findCashOutPath({sourceProjectId: sourceProjectId, destProjectId: destProjectId});
1542
1664
 
1543
1665
  // Ask that terminal how much of the reclaim token this cashout count would return.
1544
- // slither-disable-next-line unused-return
1666
+ // slither-disable-next-line unused-return,calls-loop
1545
1667
  (, reclaimAmount,,) = cashOutTerminal.previewCashOutFrom({
1546
1668
  holder: address(this),
1547
1669
  projectId: sourceProjectId,
@@ -1581,7 +1703,7 @@ contract JBRouterTerminal is
1581
1703
 
1582
1704
  // Otherwise, infer the source project from the input token when it is not the native-token sentinel.
1583
1705
  if (sourceProjectId == 0 && tokenIn != JBConstants.NATIVE_TOKEN) {
1584
- sourceProjectId = TOKENS.projectIdOf(IJBToken(tokenIn));
1706
+ sourceProjectId = _projectIdOf(tokenIn);
1585
1707
  }
1586
1708
 
1587
1709
  // JB project tokens and credit routes must be previewed through the cash-out path first.
@@ -1689,13 +1811,13 @@ contract JBRouterTerminal is
1689
1811
  JBMetadataResolver.getDataFor({id: JBMetadataResolver.getId("routeTokenOut"), metadata: metadata});
1690
1812
  if (exists) {
1691
1813
  (tokenOut) = abi.decode(routeData, (address));
1692
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: tokenOut});
1814
+ destTerminal = _primaryTerminalOf({projectId: projectId, token: tokenOut});
1693
1815
  if (address(destTerminal) == address(0)) revert JBRouterTerminal_NoRouteFound(projectId, tokenOut);
1694
1816
  return (tokenOut, destTerminal);
1695
1817
  }
1696
1818
 
1697
1819
  // 2. Direct acceptance — project accepts tokenIn as-is.
1698
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: tokenIn});
1820
+ destTerminal = _primaryTerminalOf({projectId: projectId, token: tokenIn});
1699
1821
  if (address(destTerminal) != address(0) && destTerminal.accountingContextsOf(projectId).length != 0) {
1700
1822
  return (tokenIn, destTerminal);
1701
1823
  }
@@ -1703,7 +1825,7 @@ contract JBRouterTerminal is
1703
1825
  // 2b. Check NATIVE_TOKEN <-> WETH equivalence.
1704
1826
  if (tokenIn == JBConstants.NATIVE_TOKEN || tokenIn == address(WETH)) {
1705
1827
  tokenOut = tokenIn == JBConstants.NATIVE_TOKEN ? address(WETH) : JBConstants.NATIVE_TOKEN;
1706
- destTerminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: tokenOut});
1828
+ destTerminal = _primaryTerminalOf({projectId: projectId, token: tokenOut});
1707
1829
  if (address(destTerminal) != address(0)) return (tokenOut, destTerminal);
1708
1830
  }
1709
1831
 
@@ -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
+ }