@bananapus/router-terminal-v6 0.0.59 → 0.0.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,7 @@ It can route through:
26
26
  - Uniswap V3 or V4 swaps
27
27
  - recursive Juicebox token cash outs when the input is itself a project token
28
28
 
29
- Projects can use the registry to choose, and optionally lock, a project-specific router terminal or fall back to the registry's default. The default is cohort-pinned: when the registry owner calls `setDefaultTerminal` again, the new default applies only to projects created after that call; existing projects continue to resolve against the default that was current when their ID range was active (see `defaultTerminalFor(projectId)`).
29
+ Projects can use the registry to choose, and optionally lock, a project-specific router terminal or fall back to the registry's default. The first `setDefaultTerminal` serves every project that already existed when it was called — including the canonical fee project (ID 1) — so those pre-existing projects can route tokens through the default. After that, the default is cohort-pinned: when the registry owner calls `setDefaultTerminal` again, the new default applies only to projects created after that call; existing projects continue to resolve against the default that was current when their ID range was active (see `defaultTerminalFor(projectId)`).
30
30
 
31
31
  Use this repo when UX requires "pay with many tokens, settle into the right one." Do not use it as a replacement for downstream terminal accounting or as an authoritative decimal source.
32
32
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/router-terminal-v6",
3
- "version": "0.0.59",
3
+ "version": "0.0.61",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,9 +24,9 @@
24
24
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-router-terminal-v6'"
25
25
  },
26
26
  "dependencies": {
27
- "@bananapus/buyback-hook-v6": "^0.0.64",
28
- "@bananapus/core-v6": "^0.0.72",
29
- "@bananapus/permission-ids-v6": "^0.0.27",
27
+ "@bananapus/buyback-hook-v6": "^0.0.66",
28
+ "@bananapus/core-v6": "^0.0.78",
29
+ "@bananapus/permission-ids-v6": "^0.0.28",
30
30
  "@bananapus/univ4-router-v6": "^0.0.46",
31
31
  "@openzeppelin/contracts": "5.6.1",
32
32
  "@uniswap/permit2": "github:Uniswap/permit2#cc56ad0f3439c502c246fc5cfcc3db92bb8b7219",
@@ -79,6 +79,7 @@ contract JBRouterTerminal is
79
79
  error JBRouterTerminal_CashOutDidNotDeliver(address sourceToken, address tokenToReclaim, uint256 cashOutCount);
80
80
  error JBRouterTerminal_CashOutLoopLimit(uint256 maxIterations);
81
81
  error JBRouterTerminal_InsufficientTwapHistory(address pool, uint256 twapWindow, uint256 minTwapWindow);
82
+ error JBRouterTerminal_ManipulationResistantQuoteRequired(PoolId poolId);
82
83
  error JBRouterTerminal_NoCashOutPath(uint256 sourceProjectId, uint256 destProjectId);
83
84
  error JBRouterTerminal_NoLiquidity(address pool, PoolId poolId);
84
85
  error JBRouterTerminal_NoMsgValueAllowed(uint256 value);
@@ -191,6 +192,19 @@ contract JBRouterTerminal is
191
192
  // ---------------------- internal stored properties ----------------- //
192
193
  //*********************************************************************//
193
194
 
195
+ //*********************************************************************//
196
+ // ------------------- transient stored properties ------------------- //
197
+ //*********************************************************************//
198
+
199
+ /// @notice Transient flag: when set, a quote-less swap may NOT fall back to a manipulable spot price — if the
200
+ /// selected pool exposes no manipulation-resistant oracle (a vanilla V4 pool) and the caller supplied no `pay`
201
+ /// quote, `_getV4SpotQuote` reverts and the caller must provide a quote.
202
+ /// @dev Set true by `addToBalanceOf` (whose swap has no downstream `minReturnedTokens` backstop) and false by
203
+ /// `pay` (whose top-level `minReturnedTokens` guards the entire routed result end-to-end). Read at quote time,
204
+ /// which is synchronous and always precedes the swap's pool callbacks, so it reflects the originating entrypoint.
205
+ /// Off-chain previews leave it at its default (false), so estimates still resolve. Transient, so it auto-clears.
206
+ bool internal transient _strictSwapQuote;
207
+
194
208
  //*********************************************************************//
195
209
  // -------------------------- constructor ---------------------------- //
196
210
  //*********************************************************************//
@@ -266,6 +280,11 @@ contract JBRouterTerminal is
266
280
  payable
267
281
  override
268
282
  {
283
+ // This leg settles via `addToBalanceOf`, which has no `minReturnedTokens` (or any downstream) backstop, so a
284
+ // quote-less swap must not silently price against a manipulable spot. Require a manipulation-resistant source
285
+ // (a canonical-hook V4 oracle, a V3 TWAP) or an explicit `pay` quote — see `_getV4SpotQuote`.
286
+ _strictSwapQuote = true;
287
+
269
288
  // Keep a reference to the terminal that will ultimately receive the routed funds.
270
289
  IJBTerminal destTerminal;
271
290
 
@@ -355,6 +374,10 @@ contract JBRouterTerminal is
355
374
  override
356
375
  returns (uint256 beneficiaryTokenCount)
357
376
  {
377
+ // The top-level `minReturnedTokens` guards the entire routed result end-to-end (a bad intermediate swap
378
+ // yields fewer final tokens and reverts here), so this leg may use the bounded spot-quote convenience path.
379
+ _strictSwapQuote = false;
380
+
358
381
  // Keep a reference to the terminal that will receive the routed payment.
359
382
  IJBTerminal destTerminal;
360
383
 
@@ -599,7 +622,9 @@ contract JBRouterTerminal is
599
622
  return pool;
600
623
  }
601
624
 
602
- /// @notice Public wrapper for V3-only _discoverPool, useful for off-chain queries.
625
+ /// @notice The best Uniswap V3 pool for a token pair, useful for off-chain queries.
626
+ /// @dev V3-only by design: returns the deepest V3 pool whenever one exists, independent of whether a deeper V4
627
+ /// pool exists for the same pair. Reverts only when no V3 pool exists at all.
603
628
  /// @param normalizedTokenIn The input token (wrapped if native).
604
629
  /// @param normalizedTokenOut The output token (wrapped if native).
605
630
  /// @return pool The V3 pool with the highest liquidity.
@@ -612,12 +637,10 @@ contract JBRouterTerminal is
612
637
  override
613
638
  returns (IUniswapV3Pool pool)
614
639
  {
615
- PoolInfo memory info =
616
- _discoverPool({normalizedTokenIn: normalizedTokenIn, normalizedTokenOut: normalizedTokenOut});
617
- if (!info.isV4 && address(info.v3Pool) == address(0)) {
640
+ pool = _discoverV3Pool({normalizedTokenIn: normalizedTokenIn, normalizedTokenOut: normalizedTokenOut});
641
+ if (address(pool) == address(0)) {
618
642
  revert JBRouterTerminal_NoPoolFound({tokenIn: normalizedTokenIn, tokenOut: normalizedTokenOut});
619
643
  }
620
- if (!info.isV4) pool = info.v3Pool;
621
644
  }
622
645
 
623
646
  /// @notice Preview a payment by simulating the router's routing logic in view context.
@@ -1910,9 +1933,49 @@ contract JBRouterTerminal is
1910
1933
  view
1911
1934
  returns (PoolInfo memory bestPool)
1912
1935
  {
1936
+ // Search V3 for the deepest pool and its liquidity.
1913
1937
  uint128 bestLiquidity;
1938
+ (bestPool.v3Pool, bestLiquidity) =
1939
+ _discoverV3PoolAndLiquidity({normalizedTokenIn: normalizedTokenIn, normalizedTokenOut: normalizedTokenOut});
1914
1940
 
1915
- // Search V3.
1941
+ // Search V4, promoting a V4 pool only when it is deeper than the best V3 pool found above.
1942
+ bestPool = _discoverV4Pool({
1943
+ normalizedTokenIn: normalizedTokenIn,
1944
+ normalizedTokenOut: normalizedTokenOut,
1945
+ currentBestLiquidity: bestLiquidity,
1946
+ bestPool: bestPool
1947
+ });
1948
+ }
1949
+
1950
+ /// @notice Find the highest-liquidity Uniswap V3 pool for a token pair across the common fee tiers.
1951
+ /// @param normalizedTokenIn The input token (wrapped if native).
1952
+ /// @param normalizedTokenOut The output token (wrapped if native).
1953
+ /// @return pool The V3 pool with the highest liquidity, or address(0) if none exists.
1954
+ function _discoverV3Pool(
1955
+ address normalizedTokenIn,
1956
+ address normalizedTokenOut
1957
+ )
1958
+ internal
1959
+ view
1960
+ returns (IUniswapV3Pool pool)
1961
+ {
1962
+ (pool,) =
1963
+ _discoverV3PoolAndLiquidity({normalizedTokenIn: normalizedTokenIn, normalizedTokenOut: normalizedTokenOut});
1964
+ }
1965
+
1966
+ /// @notice Find the highest-liquidity Uniswap V3 pool for a token pair along with its liquidity.
1967
+ /// @param normalizedTokenIn The input token (wrapped if native).
1968
+ /// @param normalizedTokenOut The output token (wrapped if native).
1969
+ /// @return pool The V3 pool with the highest liquidity, or address(0) if none exists.
1970
+ /// @return liquidity The liquidity of the returned pool, or 0 if none exists.
1971
+ function _discoverV3PoolAndLiquidity(
1972
+ address normalizedTokenIn,
1973
+ address normalizedTokenOut
1974
+ )
1975
+ internal
1976
+ view
1977
+ returns (IUniswapV3Pool pool, uint128 liquidity)
1978
+ {
1916
1979
  for (uint256 i; i < 4;) {
1917
1980
  address poolAddr = _getPool({tokenA: normalizedTokenIn, tokenB: normalizedTokenOut, fee: _feeTier(i)});
1918
1981
 
@@ -1925,23 +1988,15 @@ contract JBRouterTerminal is
1925
1988
 
1926
1989
  uint128 poolLiquidity = IUniswapV3Pool(poolAddr).liquidity();
1927
1990
 
1928
- if (poolLiquidity > bestLiquidity) {
1929
- bestLiquidity = poolLiquidity;
1930
- bestPool.v3Pool = IUniswapV3Pool(poolAddr);
1991
+ if (poolLiquidity > liquidity) {
1992
+ liquidity = poolLiquidity;
1993
+ pool = IUniswapV3Pool(poolAddr);
1931
1994
  }
1932
1995
 
1933
1996
  unchecked {
1934
1997
  ++i;
1935
1998
  }
1936
1999
  }
1937
-
1938
- // Search V4.
1939
- bestPool = _discoverV4Pool({
1940
- normalizedTokenIn: normalizedTokenIn,
1941
- normalizedTokenOut: normalizedTokenOut,
1942
- currentBestLiquidity: bestLiquidity,
1943
- bestPool: bestPool
1944
- });
1945
2000
  }
1946
2001
 
1947
2002
  /// @notice Search supported V4 pools and update the best pool candidate if a deeper V4 pool exists.
@@ -2264,9 +2319,12 @@ contract JBRouterTerminal is
2264
2319
  }
2265
2320
 
2266
2321
  /// @notice Get an automatic V4 quote with dynamic slippage.
2267
- /// @dev Prefers a hook-provided geomean/TWAP quote when available. Falls back to the pool's spot tick otherwise.
2268
- /// This fallback is an accepted product risk for programmatic integrations that cannot provide an external quote,
2269
- /// but it should be understood as a bounded-convenience path rather than a fully manipulation-resistant one.
2322
+ /// @dev Prefers a hook-provided geomean/TWAP quote when available. For a pool with no such oracle it falls back to
2323
+ /// the pool's spot tick ONLY when `_strictSwapQuote` is false (the `pay` leg backstopped end-to-end by
2324
+ /// `minReturnedTokens` and off-chain previews). When `_strictSwapQuote` is true (the `addToBalanceOf` and
2325
+ /// cash-out-swap legs, which have no downstream backstop) it instead reverts
2326
+ /// `JBRouterTerminal_ManipulationResistantQuoteRequired`, forcing the caller to supply a `pay` quote. The spot
2327
+ /// fallback is a bounded-convenience path, not a fully manipulation-resistant one.
2270
2328
  ///
2271
2329
  /// SECURITY NOTE: The spot price read from `poolManager.getSlot0(id)` is an instantaneous value
2272
2330
  /// that can be manipulated within the same block (e.g. via sandwich attacks or flash loans). Unlike V3 pools,
@@ -2291,11 +2349,15 @@ contract JBRouterTerminal is
2291
2349
  /// scales up to the 88% ceiling via a continuous sigmoid curve.
2292
2350
  /// 4. Pool discovery (`_discoverPool`) may select a V3 pool with TWAP if it has more liquidity, avoiding
2293
2351
  /// this V4 spot-price path altogether.
2352
+ /// 5. On legs with no downstream `minReturnedTokens` backstop (`addToBalanceOf`, and cash-out routes that
2353
+ /// settle via add-to-balance), `_strictSwapQuote` is set and this spot fallback is REFUSED: the call reverts
2354
+ /// `JBRouterTerminal_ManipulationResistantQuoteRequired` unless the caller supplied a `pay` quote.
2294
2355
  ///
2295
2356
  /// Despite these mitigations, the spot-based fallback does NOT provide full MEV protection. Integrators and
2296
2357
  /// front-ends should supply `pay` swap-quote metadata for V4 swaps whenever possible so the user's slippage
2297
2358
  /// tolerance reflects a recent, off-chain-verified price. When no external quote can be provided, this fallback
2298
- /// is still available as an accepted-risk convenience path.
2359
+ /// remains available as a bounded convenience path ONLY for the backstopped `pay` leg and off-chain previews; the
2360
+ /// un-backstopped `addToBalanceOf` and cash-out-swap legs refuse it (mitigation 5).
2299
2361
  /// @param key The V4 pool key describing the pool to quote against.
2300
2362
  /// @param normalizedTokenIn The normalized token to sell into the pool.
2301
2363
  /// @param normalizedTokenOut The normalized token to buy from the pool.
@@ -2348,8 +2410,14 @@ contract JBRouterTerminal is
2348
2410
  } catch {}
2349
2411
  }
2350
2412
 
2351
- // If no TWAP was available (no hook, or hook doesn't implement observe), use the instantaneous spot tick.
2413
+ // If no TWAP was available (no hook, or hook doesn't implement observe), there is no manipulation-resistant
2414
+ // price source for this pool. For legs with a downstream backstop (pay's `minReturnedTokens`) or for off-chain
2415
+ // previews, fall back to the instantaneous spot tick (a bounded-convenience path). For legs with NO downstream
2416
+ // backstop (`addToBalanceOf`, and cash-out routes that settle via add-to-balance), refuse: a self-referential
2417
+ // spot floor against an attacker-initialized vanilla pool offers no real protection, so require the caller to
2418
+ // supply a `pay` quote instead.
2352
2419
  if (!usedTwap) {
2420
+ if (_strictSwapQuote) revert JBRouterTerminal_ManipulationResistantQuoteRequired({poolId: id});
2353
2421
  (, tick,,) = _getSlot0(id);
2354
2422
  }
2355
2423
 
@@ -74,9 +74,11 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
74
74
  /// @notice The `PROJECTS.count()` snapshot at the moment of the last `setDefaultTerminal` call.
75
75
  /// Projects with `ID <= defaultTerminalProjectIdThreshold` (i.e. already existing when the most
76
76
  /// recent default was set) DO NOT pick up `defaultTerminal` on fall-through; instead they
77
- /// resolve against the historical entry in `_defaultTerminalHistory` that covers their ID.
77
+ /// resolve against the historical entry in `_defaultTerminalHistory` that covers their ID. The
78
+ /// first default's segment covers every project that already existed when it was set (so those
79
+ /// projects route through it), while later segments pin each outgoing default to its own cohort.
78
80
  /// This prevents the registry owner from silently rerouting payments for already-deployed
79
- /// projects via a default change.
81
+ /// projects via a later default change.
80
82
  uint256 public override defaultTerminalProjectIdThreshold;
81
83
 
82
84
  /// @notice Whether the terminal for a given project has been locked against future updates.
@@ -91,10 +93,11 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
91
93
  // --------------------- internal stored properties ------------------ //
92
94
  //*********************************************************************//
93
95
 
94
- /// @notice Append-only history of previous defaults captured at each `setDefaultTerminal` call. Each `segment[i]`
95
- /// applies to projectIds in `[<previous threshold> + 1, segment[i].maxProjectId]`. Resolution walks the array
96
- /// forward and returns the first segment whose `maxProjectId` covers the queried `projectId`. New defaults push
97
- /// the current default onto this history before updating `defaultTerminal`.
96
+ /// @notice Append-only history of default-terminal cohorts captured at each `setDefaultTerminal` call. Each
97
+ /// `segment[i]` applies to projectIds in `[<previous threshold> + 1, segment[i].maxProjectId]`. Resolution walks
98
+ /// the array forward and returns the first segment whose `maxProjectId` covers the queried `projectId`. The first
99
+ /// call records the projects that already existed mapped to the new default; later calls push the outgoing default
100
+ /// onto this history before updating `defaultTerminal`.
98
101
  DefaultTerminalSegment[] internal _defaultTerminalHistory;
99
102
 
100
103
  /// @notice The terminal explicitly configured for a project before default-terminal fallback is applied.
@@ -360,10 +363,10 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
360
363
  if (projectId > defaultTerminalProjectIdThreshold) return defaultTerminal;
361
364
 
362
365
  // Older projects walk the history. Each segment covers a half-open range
363
- // `(minProjectIdExclusive, maxProjectId]` exactly the cohort that was issued while that segment's terminal
364
- // was the active default. Projects whose IDs pre-date every recorded default (the cold-start cohort)
365
- // do not match any segment and resolve to `address(0)` preserving the documented "the default only applies
366
- // to projects created AFTER it was set" property at registry cold-start.
366
+ // `(minProjectIdExclusive, maxProjectId]`. The first segment covers every project that already existed when the
367
+ // first default was set (mapped to that first default, so they route through it); later segments each cover the
368
+ // cohort issued while their terminal was the active default. A project only resolves to `address(0)` here when
369
+ // no default has ever been set.
367
370
  uint256 len = _defaultTerminalHistory.length;
368
371
  for (uint256 i; i < len; ++i) {
369
372
  DefaultTerminalSegment storage segment = _defaultTerminalHistory[i];
@@ -388,7 +391,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
388
391
  }
389
392
 
390
393
  /// @notice Resolve the effective terminal for call paths that need to forward into a real terminal.
391
- /// @dev `terminalOf`/`defaultTerminalFor` may intentionally return zero for the cold-start cohort. Transactional
394
+ /// @dev `terminalOf`/`defaultTerminalFor` return zero only when no default has ever been set. Transactional
392
395
  /// and passthrough view paths must fail before accepting funds or calling address(0).
393
396
  /// @param projectId The project to resolve the terminal for.
394
397
  /// @return terminal The project-specific terminal or threshold-resolved default.
@@ -623,12 +626,14 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
623
626
 
624
627
  /// @notice Change the registry-wide default terminal for projects created AFTER this call.
625
628
  /// @dev Only the registry owner can call this. Automatically allowlists the new default.
626
- /// Existing projects (ID <= current `PROJECTS.count()` at call time) keep their historical
627
- /// default — the previous `defaultTerminal` is pushed onto `_defaultTerminalHistory` so
628
- /// fall-through resolution for those projects continues to return what was current when
629
- /// their cohort was last addressed. This eliminates the silent-reroute attack vector
630
- /// where the registry owner could redirect payments for already-deployed projects that
631
- /// never set an explicit `_terminalOf` override.
629
+ /// The very first call also maps every project that already existed onto the new default (via a
630
+ /// history segment) so those pre-existing projects including the canonical fee project (ID 1)
631
+ /// can route tokens through it. Existing projects (ID <= current `PROJECTS.count()` at call time)
632
+ /// keep their historical default on later changes the previous `defaultTerminal` is pushed onto
633
+ /// `_defaultTerminalHistory` so fall-through resolution for those projects continues to return what
634
+ /// was current when their cohort was last addressed. This means a later default change never
635
+ /// silently reroutes payments for already-deployed projects that never set an explicit
636
+ /// `_terminalOf` override.
632
637
  /// @param terminal The terminal to set as the default for future projects.
633
638
  function setDefaultTerminal(IJBTerminal terminal) external onlyOwner {
634
639
  if (address(terminal) == address(0)) revert JBRouterTerminalRegistry_ZeroAddress(address(terminal));
@@ -641,20 +646,20 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
641
646
  // default is what unconfigured (and all-future) projects will resolve to.
642
647
  _requireNonCircularTerminalFor({projectId: count, terminal: terminal});
643
648
 
644
- // Snapshot the OUTGOING default so projects whose IDs were issued while it was active keep resolving to it.
645
- // The segment encodes the half-open range `(prevThreshold, currentCount]` i.e. exactly the cohort that was
646
- // assigned the outgoing default at creation time. The first call ever has `defaultTerminal == 0` and nothing
647
- // to snapshot; in that case projects whose IDs already existed remain unaffected by the new default, keeping
648
- // the cold-start cohort outside any retroactive routing change.
649
- if (address(defaultTerminal) != address(0)) {
650
- _defaultTerminalHistory.push(
651
- DefaultTerminalSegment({
652
- minProjectIdExclusive: defaultTerminalProjectIdThreshold,
653
- maxProjectId: count,
654
- terminal: defaultTerminal
655
- })
656
- );
657
- }
649
+ // Record a history segment for the cohort whose IDs fall in the half-open range `(prevThreshold,
650
+ // currentCount]`. On the first call ever (`defaultTerminal == 0`) the segment maps the projects that already
651
+ // existed — including the canonical fee project (ID 1) onto the NEW default so they can route tokens
652
+ // through
653
+ // it instead of resolving to nothing. On every later call the segment instead pins the OUTGOING default to its
654
+ // own cohort, so projects whose IDs were issued while it was active keep resolving to it and a default change
655
+ // never silently reroutes an already-deployed project.
656
+ _defaultTerminalHistory.push(
657
+ DefaultTerminalSegment({
658
+ minProjectIdExclusive: defaultTerminalProjectIdThreshold,
659
+ maxProjectId: count,
660
+ terminal: address(defaultTerminal) != address(0) ? defaultTerminal : terminal
661
+ })
662
+ );
658
663
 
659
664
  defaultTerminal = terminal;
660
665
  defaultTerminalProjectIdThreshold = count;