@bananapus/router-terminal-v6 0.0.33 → 0.0.35

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.
Files changed (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +20 -10
  3. package/src/JBPayRouteResolver.sol +126 -13
  4. package/src/JBRouterTerminal.sol +41 -16
  5. package/src/JBRouterTerminalRegistry.sol +97 -34
  6. package/src/libraries/JBSwapLib.sol +4 -3
  7. package/ADMINISTRATION.md +0 -80
  8. package/ARCHITECTURE.md +0 -94
  9. package/AUDIT_INSTRUCTIONS.md +0 -87
  10. package/RISKS.md +0 -49
  11. package/SKILLS.md +0 -46
  12. package/STYLE_GUIDE.md +0 -610
  13. package/USER_JOURNEYS.md +0 -181
  14. package/slither-ci.config.json +0 -10
  15. package/test/NegativeTickRounding.t.sol +0 -130
  16. package/test/RouterTerminal.t.sol +0 -2669
  17. package/test/RouterTerminalBuybackHookFork.t.sol +0 -402
  18. package/test/RouterTerminalCashOutFork.t.sol +0 -555
  19. package/test/RouterTerminalCreditCashout.t.sol +0 -776
  20. package/test/RouterTerminalERC2771.t.sol +0 -374
  21. package/test/RouterTerminalFeeCashOutFork.t.sol +0 -439
  22. package/test/RouterTerminalFork.t.sol +0 -501
  23. package/test/RouterTerminalMultihopFork.t.sol +0 -354
  24. package/test/RouterTerminalPreviewFork.t.sol +0 -339
  25. package/test/RouterTerminalReentrancy.t.sol +0 -477
  26. package/test/RouterTerminalRegistry.t.sol +0 -389
  27. package/test/RouterTerminalSandwichFork.t.sol +0 -813
  28. package/test/TestAuditGaps.sol +0 -974
  29. package/test/audit/CashOutCircularPrimaryTerminal.t.sol +0 -326
  30. package/test/audit/CashOutFallbackPrefersRecursiveLoop.t.sol +0 -363
  31. package/test/audit/CodexNemesisPayHookReceiptDoS.t.sol +0 -169
  32. package/test/audit/CreditCashoutPreferredTokenBypass.t.sol +0 -161
  33. package/test/audit/CreditCashoutSpoofedPayer.t.sol +0 -249
  34. package/test/audit/DeployBuybackHookZero.t.sol +0 -421
  35. package/test/audit/HookDataEncoding.t.sol +0 -154
  36. package/test/audit/LeftoverRefund.t.sol +0 -525
  37. package/test/audit/MultiHopCashOutCycle.t.sol +0 -350
  38. package/test/audit/MultiHopForwardCycle.t.sol +0 -187
  39. package/test/audit/Pass12M39.t.sol +0 -202
  40. package/test/audit/Pass13RouterFixes.t.sol +0 -141
  41. package/test/audit/PayerTrackerRefund.t.sol +0 -187
  42. package/test/audit/Permit2AllowanceFailed.t.sol +0 -185
  43. package/test/audit/PreviewCashOutShortcircuitDivergence.t.sol +0 -206
  44. package/test/audit/PreviewPrimaryTerminalMismatch.t.sol +0 -239
  45. package/test/audit/RefundToBeneficiary.t.sol +0 -101
  46. package/test/audit/RegistryAddToBalancePartialFill.t.sol +0 -482
  47. package/test/audit/RegistryForwardingLossyToken.t.sol +0 -238
  48. package/test/audit/RegistrySelfLockDoS.t.sol +0 -72
  49. package/test/audit/RevertingTerminalRouteDiscovery.t.sol +0 -466
  50. package/test/audit/RouterRegistryReceiptMismatch.t.sol +0 -207
  51. package/test/audit/V4HookedPoolIgnored.t.sol +0 -101
  52. package/test/audit/V4WethInputUsesStuckEth.t.sol +0 -330
  53. package/test/fork/RouterTerminalFOTFork.t.sol +0 -360
  54. package/test/fork/V4QuoteAndSettlementFork.t.sol +0 -690
  55. package/test/invariant/RouterTerminalInvariant.t.sol +0 -649
  56. package/test/regression/CashOutLoopLimit.t.sol +0 -283
  57. package/test/regression/LockTerminalRace.t.sol +0 -93
  58. package/test/regression/RouterTerminalEdgeCases.t.sol +0 -495
  59. package/test/regression/V4SpotPriceSlippage.t.sol +0 -401
package/README.md CHANGED
@@ -91,8 +91,8 @@ npm install @bananapus/router-terminal-v6
91
91
 
92
92
  ```bash
93
93
  npm install
94
- forge build
95
- forge test
94
+ forge build --deny notes
95
+ forge test --deny notes
96
96
  ```
97
97
 
98
98
  Useful scripts:
package/package.json CHANGED
@@ -1,11 +1,20 @@
1
1
  {
2
2
  "name": "@bananapus/router-terminal-v6",
3
- "version": "0.0.33",
3
+ "version": "0.0.35",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/Bananapus/nana-router-terminal-v6"
8
8
  },
9
+ "files": [
10
+ "CHANGELOG.md",
11
+ "foundry.toml",
12
+ "references/",
13
+ "remappings.txt",
14
+ "script/Deploy.s.sol",
15
+ "script/helpers/",
16
+ "src/"
17
+ ],
9
18
  "engines": {
10
19
  "node": ">=20.0.0"
11
20
  },
@@ -17,16 +26,17 @@
17
26
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'nana-router-terminal-v6'"
18
27
  },
19
28
  "dependencies": {
20
- "@bananapus/buyback-hook-v6": "^0.0.30",
21
- "@bananapus/core-v6": "^0.0.36",
22
- "@bananapus/permission-ids-v6": "^0.0.19",
23
- "@openzeppelin/contracts": "^5.6.1",
24
- "@uniswap/permit2": "github:Uniswap/permit2",
25
- "@uniswap/v3-core": "github:Uniswap/v3-core#0.8",
26
- "@uniswap/v3-periphery": "github:Uniswap/v3-periphery#0.8",
27
- "@uniswap/v4-core": "^1.0.0"
29
+ "@bananapus/buyback-hook-v6": "0.0.36",
30
+ "@bananapus/core-v6": "0.0.39",
31
+ "@bananapus/permission-ids-v6": "0.0.22",
32
+ "@bananapus/univ4-router-v6": "0.0.22",
33
+ "@openzeppelin/contracts": "5.6.1",
34
+ "@uniswap/permit2": "github:Uniswap/permit2#cc56ad0f3439c502c246fc5cfcc3db92bb8b7219",
35
+ "@uniswap/v3-core": "github:Uniswap/v3-core#6562c52e8f75f0c10f9deaf44861847585fc8129",
36
+ "@uniswap/v3-periphery": "github:Uniswap/v3-periphery#b325bb0905d922ae61fcc7df85ee802e8df5e96c",
37
+ "@uniswap/v4-core": "1.0.2"
28
38
  },
29
39
  "devDependencies": {
30
- "@sphinx-labs/plugins": "^0.33.2"
40
+ "@sphinx-labs/plugins": "0.33.3"
31
41
  }
32
42
  }
@@ -9,13 +9,15 @@ import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataRes
9
9
  import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
10
10
  import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
11
11
  import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
12
+ import {mulDiv} from "@prb/math/src/Common.sol";
12
13
 
13
14
  import {JBForwardingCheck} from "./libraries/JBForwardingCheck.sol";
14
15
  import {IJBPayRoutePreviewer} from "./interfaces/IJBPayRoutePreviewer.sol";
15
16
  import {IJBPayRouteResolver} from "./interfaces/IJBPayRouteResolver.sol";
16
17
  import {IWETH9} from "./interfaces/IWETH9.sol";
17
18
 
18
- /// @notice Resolves the best pay route preview for `JBRouterTerminal`.
19
+ /// @notice Evaluates every token a destination project accepts and returns the route that yields the most project
20
+ /// tokens for the beneficiary, deployed as a helper to keep `JBRouterTerminal` within runtime size limits.
19
21
  contract JBPayRouteResolver is IJBPayRouteResolver {
20
22
  //*********************************************************************//
21
23
  // --------------------------- custom errors ------------------------- //
@@ -273,8 +275,17 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
273
275
  continue;
274
276
  }
275
277
 
276
- // Decode only the minimum token-count commitments needed to score the buyback-enhanced preview.
277
- (,,,,,,,,,, uint256 minimumBeneficiaryTokenCount, uint256 minimumReservedTokenCount,) = abi.decode(
278
+ // Decode the buyback hook's routing metadata. When the hook mints in `afterPayRecordedWith`, the terminal
279
+ // preview returns zero token counts and the router must score the route from the hook's commitments.
280
+ (
281
+ ,
282
+ uint256 amountToMintWith,
283
+ uint256 minimumSwapAmountOut,,,
284
+ uint256 tokenCountWithoutHook,,,,,
285
+ uint256 minimumBeneficiaryTokenCount,
286
+ uint256 minimumReservedTokenCount,
287
+ uint256 rawSwapQuote
288
+ ) = abi.decode(
278
289
  specification.metadata,
279
290
  (
280
291
  bool,
@@ -293,14 +304,44 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
293
304
  )
294
305
  );
295
306
 
296
- // Keep whichever decoded hook commitment implies the stronger user-visible preview outcome.
297
- if (
298
- minimumBeneficiaryTokenCount > effectiveBeneficiaryTokenCount
299
- || (minimumBeneficiaryTokenCount == effectiveBeneficiaryTokenCount
300
- && minimumReservedTokenCount > effectiveReservedTokenCount)
301
- ) {
302
- effectiveBeneficiaryTokenCount = minimumBeneficiaryTokenCount;
303
- effectiveReservedTokenCount = minimumReservedTokenCount;
307
+ // The hook's beneficiary/reserved commitments are only for the AMM leg. If the hook leaves part of the
308
+ // payment to mint directly, estimate that direct-mint leg at the same issuance rate used for the swapped
309
+ // amount so the router compares a whole-route token count against ordinary terminal previews.
310
+ uint256 directMintTokenCount;
311
+ if (amountToMintWith != 0 && specification.amount != 0 && tokenCountWithoutHook != 0) {
312
+ directMintTokenCount =
313
+ mulDiv({x: amountToMintWith, y: tokenCountWithoutHook, denominator: specification.amount});
314
+ }
315
+
316
+ // Score the executable floor first. This supports callers that only provide a minimum and no live quote.
317
+ (uint256 candidateBeneficiaryTokenCount, uint256 candidateReservedTokenCount) = _scaledPreviewPayTokenCounts({
318
+ tokenCount: minimumSwapAmountOut + directMintTokenCount,
319
+ referenceTokenCount: minimumSwapAmountOut,
320
+ referenceBeneficiaryTokenCount: minimumBeneficiaryTokenCount,
321
+ referenceReservedTokenCount: minimumReservedTokenCount
322
+ });
323
+ (effectiveBeneficiaryTokenCount, effectiveReservedTokenCount) = _strongerPreviewPayTokenCounts({
324
+ currentBeneficiaryTokenCount: effectiveBeneficiaryTokenCount,
325
+ currentReservedTokenCount: effectiveReservedTokenCount,
326
+ candidateBeneficiaryTokenCount: candidateBeneficiaryTokenCount,
327
+ candidateReservedTokenCount: candidateReservedTokenCount
328
+ });
329
+
330
+ // If the hook also surfaced a stronger live quote, score it too. This lets programmatic buyback routes win
331
+ // when the expected executable output is better than the conservative minimum.
332
+ if (rawSwapQuote > minimumSwapAmountOut) {
333
+ (candidateBeneficiaryTokenCount, candidateReservedTokenCount) = _scaledPreviewPayTokenCounts({
334
+ tokenCount: rawSwapQuote + directMintTokenCount,
335
+ referenceTokenCount: minimumSwapAmountOut,
336
+ referenceBeneficiaryTokenCount: minimumBeneficiaryTokenCount,
337
+ referenceReservedTokenCount: minimumReservedTokenCount
338
+ });
339
+ (effectiveBeneficiaryTokenCount, effectiveReservedTokenCount) = _strongerPreviewPayTokenCounts({
340
+ currentBeneficiaryTokenCount: effectiveBeneficiaryTokenCount,
341
+ currentReservedTokenCount: effectiveReservedTokenCount,
342
+ candidateBeneficiaryTokenCount: candidateBeneficiaryTokenCount,
343
+ candidateReservedTokenCount: candidateReservedTokenCount
344
+ });
304
345
  }
305
346
  unchecked {
306
347
  ++i;
@@ -538,8 +579,8 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
538
579
  /// @param amount The current route input amount.
539
580
  /// @param metadata Metadata that may include a cashout-source override.
540
581
  /// @param preferredToken The preferred token to target during any previewed cashout loop.
541
- /// @return resolvedTerminal The terminal found by the previewed cashout loop, or address(0) if conversion should
542
- /// continue. @return routedTokenIn The token that remains to be routed after the previewed cashout step.
582
+ /// @return resolvedTerminal The terminal found by the cashout loop, or address(0) if conversion continues.
583
+ /// @return routedTokenIn The token that remains to be routed after the previewed cashout step.
543
584
  /// @return routedAmountIn The amount of `routedTokenIn` that remains to be routed.
544
585
  function _previewRouteInputFromSource(
545
586
  IJBPayRoutePreviewer router,
@@ -668,6 +709,45 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
668
709
  return abi.decode(data, (IJBTerminal[]));
669
710
  }
670
711
 
712
+ /// @notice Scale a known beneficiary/reserved token split to a different total token count.
713
+ /// @param tokenCount The total token count being scored.
714
+ /// @param referenceTokenCount The total token count the reference split was computed from.
715
+ /// @param referenceBeneficiaryTokenCount The beneficiary share of the reference split.
716
+ /// @param referenceReservedTokenCount The reserved share of the reference split.
717
+ /// @return beneficiaryTokenCount The scaled beneficiary token count.
718
+ /// @return reservedTokenCount The scaled reserved token count.
719
+ function _scaledPreviewPayTokenCounts(
720
+ uint256 tokenCount,
721
+ uint256 referenceTokenCount,
722
+ uint256 referenceBeneficiaryTokenCount,
723
+ uint256 referenceReservedTokenCount
724
+ )
725
+ internal
726
+ pure
727
+ returns (uint256 beneficiaryTokenCount, uint256 reservedTokenCount)
728
+ {
729
+ // A zero candidate means there is no stronger route output to scale, so preserve the known reference split.
730
+ if (tokenCount == 0) {
731
+ return (referenceBeneficiaryTokenCount, referenceReservedTokenCount);
732
+ }
733
+
734
+ // Prefer the already-previewed beneficiary/reserved total because it includes the destination's reserve logic.
735
+ uint256 referenceTotal = referenceBeneficiaryTokenCount + referenceReservedTokenCount;
736
+
737
+ // Fall back to the original token count when previewed counts were unavailable but the hook reported a floor.
738
+ if (referenceTotal == 0) referenceTotal = referenceTokenCount;
739
+
740
+ // If both reference totals are zero, treat the whole candidate as beneficiary tokens so the route stays
741
+ // comparable instead of disappearing from scoring.
742
+ if (referenceTotal == 0) return (tokenCount, 0);
743
+
744
+ // Scale the beneficiary share proportionally from the reference split to the candidate total being scored.
745
+ beneficiaryTokenCount = mulDiv({x: tokenCount, y: referenceBeneficiaryTokenCount, denominator: referenceTotal});
746
+
747
+ // Assign the residual to reserved tokens so rounding cannot lose supply during route comparison.
748
+ reservedTokenCount = tokenCount - beneficiaryTokenCount;
749
+ }
750
+
671
751
  /// @notice Resolve whether the current route input should first be treated as a project-token cashout source.
672
752
  /// @param router The router terminal whose project-token lookup should be used.
673
753
  /// @param tokenIn The current route input token.
@@ -698,6 +778,39 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
698
778
  }
699
779
  }
700
780
 
781
+ /// @notice Choose the stronger preview outcome using beneficiary tokens first and reserved tokens as a tie-break.
782
+ /// @param currentBeneficiaryTokenCount The beneficiary token count from the strongest route so far.
783
+ /// @param currentReservedTokenCount The reserved token count from the strongest route so far.
784
+ /// @param candidateBeneficiaryTokenCount The beneficiary token count from the candidate route.
785
+ /// @param candidateReservedTokenCount The reserved token count from the candidate route.
786
+ /// @return beneficiaryTokenCount The beneficiary token count to keep.
787
+ /// @return reservedTokenCount The reserved token count to keep.
788
+ function _strongerPreviewPayTokenCounts(
789
+ uint256 currentBeneficiaryTokenCount,
790
+ uint256 currentReservedTokenCount,
791
+ uint256 candidateBeneficiaryTokenCount,
792
+ uint256 candidateReservedTokenCount
793
+ )
794
+ internal
795
+ pure
796
+ returns (uint256 beneficiaryTokenCount, uint256 reservedTokenCount)
797
+ {
798
+ // Prefer the route that gives the beneficiary more tokens, since that is the user's primary output.
799
+ if (
800
+ candidateBeneficiaryTokenCount > currentBeneficiaryTokenCount
801
+ || (
802
+ // When beneficiary output ties, keep the route that also mints more reserved tokens.
803
+ candidateBeneficiaryTokenCount == currentBeneficiaryTokenCount
804
+ && candidateReservedTokenCount > currentReservedTokenCount
805
+ )
806
+ ) {
807
+ return (candidateBeneficiaryTokenCount, candidateReservedTokenCount);
808
+ }
809
+
810
+ // Keep the current winner when the candidate does not improve beneficiary output or the reserved tie-break.
811
+ return (currentBeneficiaryTokenCount, currentReservedTokenCount);
812
+ }
813
+
701
814
  /// @notice Resolve the usable primary terminal for a discovered candidate token.
702
815
  /// @param router The router whose circular-terminal rule should be applied.
703
816
  /// @param directory The directory used to resolve primary terminals.
@@ -50,9 +50,9 @@ import {JBPayRouteResolver} from "./JBPayRouteResolver.sol";
50
50
  import {CashOutPathCandidates} from "./structs/CashOutPathCandidates.sol";
51
51
  import {PoolInfo} from "./structs/PoolInfo.sol";
52
52
 
53
- /// @notice The `JBRouterTerminal` accepts any token and dynamically discovers what token each destination project
54
- /// accepts, then routes the payment there — via direct forwarding, Uniswap swap, JB token cashout, or a combination.
55
- /// Supports both Uniswap V3 and V4 pools, choosing whichever offers better liquidity.
53
+ /// @notice A universal payment terminal that accepts any token and automatically converts it into whatever token the
54
+ /// destination project accepts. Routes payments via direct forwarding, Uniswap V3/V4 swaps, recursive JB token
55
+ /// cashouts, or a combination always selecting the path that yields the most project tokens for the beneficiary.
56
56
  /// @custom:benediction DEVS BENEDICAT ET PROTEGAT CONTRACTVS MEAM
57
57
  contract JBRouterTerminal is
58
58
  ERC2771Context,
@@ -75,6 +75,7 @@ contract JBRouterTerminal is
75
75
  error JBRouterTerminal_AmountOverflow(uint256 amount);
76
76
  error JBRouterTerminal_CallerNotPool(address caller);
77
77
  error JBRouterTerminal_CallerNotPoolManager(address caller);
78
+ error JBRouterTerminal_CashOutDidNotDeliver(address sourceToken, address tokenToReclaim, uint256 cashOutCount);
78
79
  error JBRouterTerminal_CashOutLoopLimit();
79
80
  error JBRouterTerminal_InsufficientTwapHistory();
80
81
  error JBRouterTerminal_NoCashOutPath(uint256 sourceProjectId, uint256 destProjectId);
@@ -449,7 +450,10 @@ contract JBRouterTerminal is
449
450
  amountOut = uint256(uint128(delta0));
450
451
  }
451
452
 
452
- if (amountOut < minAmountOut) revert JBRouterTerminal_SlippageExceeded(amountOut, minAmountOut);
453
+ // Enforce the caller's V4 minimum against the realized delta before settling/taking pool balances.
454
+ if (amountOut < minAmountOut) {
455
+ revert JBRouterTerminal_SlippageExceeded({amountOut: amountOut, minAmountOut: minAmountOut});
456
+ }
453
457
 
454
458
  // Settle input (pay what we owe to the PoolManager).
455
459
  Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1;
@@ -805,15 +809,13 @@ contract JBRouterTerminal is
805
809
  continue;
806
810
  }
807
811
 
808
- // Decode only the fields needed to determine the user-visible sell-side output implied by the hook.
809
- (uint256 minimumSwapAmountOut,,,,,, uint256 rawSwapQuote) =
812
+ // Decode only the buyback field used for route scoring. `minimumSwapAmountOut` is executable because the
813
+ // hook will enforce it; the later raw quote word is diagnostic and can overstate what execution can prove.
814
+ (uint256 minimumSwapAmountOut,,,,,,) =
810
815
  abi.decode(specification.metadata, (uint256, uint256, uint256, int24, uint128, PoolId, uint256));
811
816
 
812
- // Prefer the raw quote when present, otherwise fall back to the hook's minimum swap commitment.
813
- uint256 quotedAmount = rawSwapQuote != 0 ? rawSwapQuote : minimumSwapAmountOut;
814
-
815
- // Keep whichever understood hook quote implies the strongest previewed cash-out output.
816
- if (quotedAmount > effectiveAmount) effectiveAmount = quotedAmount;
817
+ // Keep whichever understood executable hook commitment implies the strongest cash-out output.
818
+ if (minimumSwapAmountOut > effectiveAmount) effectiveAmount = minimumSwapAmountOut;
817
819
 
818
820
  unchecked {
819
821
  ++i;
@@ -1197,6 +1199,7 @@ contract JBRouterTerminal is
1197
1199
  sourceProjectId: sourceProjectId, destProjectId: destProjectId, preferredToken: preferredToken
1198
1200
  });
1199
1201
 
1202
+ uint256 cashOutCount = amount;
1200
1203
  uint256 balanceBefore = _balanceOf({token: tokenToReclaim, account: address(this)});
1201
1204
 
1202
1205
  // Cash out the source project's tokens.
@@ -1216,8 +1219,20 @@ contract JBRouterTerminal is
1216
1219
  metadata: ""
1217
1220
  });
1218
1221
 
1222
+ // Measure the reclaimed-token balance delta so fee-on-transfer behavior cannot fake delivery.
1219
1223
  amount = _balanceOf({token: tokenToReclaim, account: address(this)}) - balanceBefore;
1220
- if (amount < minTokensReclaimed) revert JBRouterTerminal_SlippageExceeded(amount, minTokensReclaimed);
1224
+
1225
+ // A non-zero cashout that delivers no reclaim tokens means this hop cannot safely continue.
1226
+ if (cashOutCount != 0 && amount == 0) {
1227
+ revert JBRouterTerminal_CashOutDidNotDeliver({
1228
+ sourceToken: token, tokenToReclaim: tokenToReclaim, cashOutCount: cashOutCount
1229
+ });
1230
+ }
1231
+
1232
+ // Enforce the caller's first-hop reclaim floor against the actual balance delta received.
1233
+ if (amount < minTokensReclaimed) {
1234
+ revert JBRouterTerminal_SlippageExceeded({amountOut: amount, minAmountOut: minTokensReclaimed});
1235
+ }
1221
1236
 
1222
1237
  // Clear the reclaim minimum after the first hop.
1223
1238
  // Multi-hop routes can change token units between hops, so there is no sound generic way to rescale a
@@ -1374,7 +1389,9 @@ contract JBRouterTerminal is
1374
1389
 
1375
1390
  // Enforce slippage protection via realized output vs minimum acceptable output.
1376
1391
  // This is strictly more correct than sqrtPriceLimitX96 (which conflates marginal and average price).
1377
- if (amountOut < minAmountOut) revert JBRouterTerminal_SlippageExceeded(amountOut, minAmountOut);
1392
+ if (amountOut < minAmountOut) {
1393
+ revert JBRouterTerminal_SlippageExceeded({amountOut: amountOut, minAmountOut: minAmountOut});
1394
+ }
1378
1395
  }
1379
1396
 
1380
1397
  /// @notice Execute a swap through a V4 pool via PoolManager.unlock().
@@ -1681,7 +1698,8 @@ contract JBRouterTerminal is
1681
1698
  if (_unwrapCurrency(currency) == address(0)) _wethDeposit(amount);
1682
1699
  }
1683
1700
 
1684
- /// @notice Transfers tokens.
1701
+ /// @notice Transfer tokens from one address to another using direct approval, `safeTransfer`, or Permit2 as a
1702
+ /// fallback.
1685
1703
  /// @param from The address to transfer tokens from.
1686
1704
  /// @param to The address to transfer tokens to.
1687
1705
  /// @param token The address of the token being transferred.
@@ -2333,9 +2351,12 @@ contract JBRouterTerminal is
2333
2351
  // An OOB access in the try-success block panics and is NOT caught by catch{}.
2334
2352
  if (tickCumulatives.length >= 2) {
2335
2353
  // Derive the arithmetic mean tick: (cumulative_now - cumulative_start) / elapsed_seconds.
2336
- // forge-lint: disable-next-line(unsafe-typecast)
2337
2354
  int56 tickDelta = tickCumulatives[1] - tickCumulatives[0];
2355
+ // The TWAP window is a small protocol constant that fits in int32 and int56.
2356
+ // forge-lint: disable-next-line(unsafe-typecast)
2338
2357
  int56 period = int56(int32(_TWAP_WINDOW));
2358
+ // The cumulative tick values come from Uniswap observations, whose average tick is int24-bounded.
2359
+ // forge-lint: disable-next-line(unsafe-typecast)
2339
2360
  tick = int24(tickDelta / period);
2340
2361
  // Round towards negative infinity for negative ticks (Uniswap convention).
2341
2362
  if (tickDelta < 0 && (tickDelta % period != 0)) tick--;
@@ -2566,7 +2587,9 @@ contract JBRouterTerminal is
2566
2587
  });
2567
2588
 
2568
2589
  // Enforce the caller's minimum reclaim amount only on the first hop.
2569
- if (amount < minTokensReclaimed) revert JBRouterTerminal_SlippageExceeded(amount, minTokensReclaimed);
2590
+ if (amount < minTokensReclaimed) {
2591
+ revert JBRouterTerminal_SlippageExceeded({amountOut: amount, minAmountOut: minTokensReclaimed});
2592
+ }
2570
2593
 
2571
2594
  // Clear the reclaim minimum after the first hop because later hops may operate in different token units.
2572
2595
  minTokensReclaimed = 0;
@@ -2622,6 +2645,8 @@ contract JBRouterTerminal is
2622
2645
  metadata: ""
2623
2646
  });
2624
2647
 
2648
+ // Deployment config makes this router a feeless cash-out beneficiary, so previews use the terminal's raw
2649
+ // reclaim amount and avoid carrying fee-discovery bytecode in the router.
2625
2650
  reclaimAmount = _effectivePreviewCashOutAmount(reclaimAmount, hookSpecifications);
2626
2651
  }
2627
2652
 
@@ -27,7 +27,8 @@ import {IJBForwardingTerminal} from "./interfaces/IJBForwardingTerminal.sol";
27
27
  import {IJBRouterTerminalRegistry} from "./interfaces/IJBRouterTerminalRegistry.sol";
28
28
  import {IJBPayerTracker} from "./interfaces/IJBPayerTracker.sol";
29
29
 
30
- /// @notice Lets projects pick, lock, and forward through a router terminal or a shared default router terminal.
30
+ /// @notice A forwarding layer that lets each project choose which router terminal receives its payments, with an
31
+ /// owner-managed default for projects that have not opted in. Projects can lock their choice to guarantee permanence.
31
32
  contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned, Ownable, ERC2771Context {
32
33
  // A library that adds default safety checks to ERC20 functionality.
33
34
  using SafeERC20 for IERC20;
@@ -154,9 +155,29 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
154
155
  return terminal.accountingContextsOf(projectId);
155
156
  }
156
157
 
157
- /// @notice Empty implementation to satisfy the interface. This terminal has no surplus.
158
- /// @return currentSurplus Always returns 0 because the registry is only a forwarding layer.
159
- function currentSurplusOf(uint256, address[] calldata, uint256, uint256) external pure override returns (uint256) {}
158
+ /// @notice Always returns 0 because the registry only forwards funds and does not hold project balances.
159
+ /// @param projectId Unused.
160
+ /// @param tokens Unused.
161
+ /// @param decimals Unused.
162
+ /// @param currency Unused.
163
+ /// @return currentSurplus Always 0.
164
+ function currentSurplusOf(
165
+ uint256 projectId,
166
+ address[] calldata tokens,
167
+ uint256 decimals,
168
+ uint256 currency
169
+ )
170
+ external
171
+ pure
172
+ override
173
+ returns (uint256)
174
+ {
175
+ projectId;
176
+ tokens;
177
+ decimals;
178
+ currency;
179
+ return 0;
180
+ }
160
181
 
161
182
  /// @notice Preview a payment by forwarding the call to the terminal currently resolved for the project.
162
183
  /// @dev Uses the project-specific terminal when set, otherwise falls back to `defaultTerminal`.
@@ -226,6 +247,13 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
226
247
  return super._contextSuffixLength();
227
248
  }
228
249
 
250
+ /// @notice Prevent the registry from forwarding straight back into its immediate caller.
251
+ /// @param terminal The terminal the registry is about to forward into.
252
+ function _enforceNoCircularForward(IJBTerminal terminal) internal view {
253
+ // Reject immediate caller cycles so router -> registry -> same router cannot recurse indefinitely.
254
+ if (msg.sender == address(terminal)) revert JBRouterTerminalRegistry_CircularForward(terminal);
255
+ }
256
+
229
257
  /// @notice The calldata. Preferred to use over `msg.data`.
230
258
  /// @return calldata The `msg.data` of this call.
231
259
  function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) {
@@ -238,6 +266,29 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
238
266
  return ERC2771Context._msgSender();
239
267
  }
240
268
 
269
+ /// @notice Reject terminal choices that would forward the project back into this registry.
270
+ /// @param projectId The project whose forwarding target is being validated.
271
+ /// @param terminal The terminal being configured or locked.
272
+ function _requireNonCircularTerminalFor(uint256 projectId, IJBTerminal terminal) internal view {
273
+ // Reject direct self-selection so the registry cannot forward a project to itself.
274
+ if (address(terminal) == address(this)) revert JBRouterTerminalRegistry_CircularForward(terminal);
275
+
276
+ // Externally owned accounts cannot implement `terminalOf`, so there is no forwarding route to inspect.
277
+ if (address(terminal).code.length == 0) return;
278
+
279
+ // If the candidate is another forwarding terminal, ask where this project would end up.
280
+ try IJBForwardingTerminal(address(terminal)).terminalOf({projectId: projectId}) returns (
281
+ IJBTerminal downstreamTerminal
282
+ ) {
283
+ // Reject one-hop forwarding cycles that bounce this project back into the registry.
284
+ if (address(downstreamTerminal) == address(this)) {
285
+ revert JBRouterTerminalRegistry_CircularForward(terminal);
286
+ }
287
+ } catch {
288
+ // Non-forwarding terminals are valid choices; failed interface probes should not block them.
289
+ }
290
+ }
291
+
241
292
  /// @notice Resolve the effective terminal for a project, falling back to the default terminal when unset.
242
293
  /// @param projectId The project whose terminal should be resolved.
243
294
  /// @return terminal The project-specific terminal, or the default terminal if no override exists.
@@ -249,13 +300,6 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
249
300
  if (terminal == IJBTerminal(address(0))) terminal = defaultTerminal;
250
301
  }
251
302
 
252
- /// @notice Prevent the registry from forwarding straight back into its immediate caller.
253
- /// @param terminal The terminal the registry is about to forward into.
254
- function _enforceNoCircularForward(IJBTerminal terminal) internal view {
255
- // Reject immediate caller cycles so router -> registry -> same router cannot recurse indefinitely.
256
- if (msg.sender == address(terminal)) revert JBRouterTerminalRegistry_CircularForward(terminal);
257
- }
258
-
259
303
  //*********************************************************************//
260
304
  // ---------------------- external transactions ---------------------- //
261
305
  //*********************************************************************//
@@ -271,13 +315,14 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
271
315
  override
272
316
  {}
273
317
 
274
- /// @notice Accept funds for a project and add them to the balance of the resolved terminal.
275
- /// @param projectId The ID of the project for which funds are being accepted.
318
+ /// @notice Add funds to a project's balance by forwarding them through the project's resolved router terminal.
319
+ /// @dev Uses the project-specific terminal when set, otherwise falls back to `defaultTerminal`.
320
+ /// @param projectId The ID of the project receiving the balance addition.
276
321
  /// @param token The address of the token being paid in.
277
322
  /// @param amount The amount of tokens being paid in.
278
- /// @param shouldReturnHeldFees A boolean to indicate whether held fees should be returned.
323
+ /// @param shouldReturnHeldFees Whether held fees should be returned based on the amount added.
279
324
  /// @param memo A memo to pass along to the emitted event.
280
- /// @param metadata Bytes in `JBMetadataResolver`'s format.
325
+ /// @param metadata Bytes in `JBMetadataResolver`'s format (may include `permit2` allowance data).
281
326
  // slither-disable-next-line reentrancy-benign,reentrancy-eth
282
327
  function addToBalanceOf(
283
328
  uint256 projectId,
@@ -327,8 +372,8 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
327
372
  originalPayer = previousPayer;
328
373
  }
329
374
 
330
- /// @notice Allow a terminal.
331
- /// @dev Only the owner can allow a terminal.
375
+ /// @notice Add a terminal to the allowlist so projects can select it as their router.
376
+ /// @dev Only the registry owner can call this.
332
377
  /// @param terminal The terminal to allow.
333
378
  function allowTerminal(IJBTerminal terminal) external onlyOwner {
334
379
  // Mark the terminal as selectable for future project-level configuration.
@@ -338,8 +383,8 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
338
383
  emit JBRouterTerminalRegistry_AllowTerminal(terminal, _msgSender());
339
384
  }
340
385
 
341
- /// @notice Disallow a terminal.
342
- /// @dev Only the owner can disallow a terminal. Cannot disallow the current default terminal — call
386
+ /// @notice Remove a terminal from the allowlist so no new projects can select it.
387
+ /// @dev Only the registry owner can call this. Cannot disallow the current default terminal — call
343
388
  /// `setDefaultTerminal` to change the default first.
344
389
  /// @param terminal The terminal to disallow.
345
390
  function disallowTerminal(IJBTerminal terminal) external onlyOwner {
@@ -355,11 +400,9 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
355
400
  emit JBRouterTerminalRegistry_DisallowTerminal(terminal, _msgSender());
356
401
  }
357
402
 
358
- /// @notice Lock a terminal for a project.
403
+ /// @notice Permanently lock a project's router terminal choice so it can never be changed again.
359
404
  /// @dev Only the project's owner or an address with the `JBPermissionIds.SET_ROUTER_TERMINAL` permission can lock.
360
- /// @dev Locking a circular or self-referential terminal (e.g. one that routes back to this registry) will
361
- /// permanently brick routing for the project through this registry. Because locks are irreversible, callers must
362
- /// verify that the terminal is valid and non-circular before locking.
405
+ /// @dev Circular or self-referential terminals are rejected before the irreversible lock is written.
363
406
  /// @param projectId The ID of the project to lock the terminal for.
364
407
  /// @param expectedTerminal The terminal the caller expects to lock. Prevents race conditions where the default
365
408
  /// changes between transaction submission and execution.
@@ -384,31 +427,46 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
384
427
  revert JBRouterTerminalRegistry_TerminalMismatch(terminal, expectedTerminal);
385
428
  }
386
429
 
430
+ // Reject a terminal that would make this irreversible lock forward back into the registry.
431
+ _requireNonCircularTerminalFor({projectId: projectId, terminal: terminal});
432
+
387
433
  hasLockedTerminal[projectId] = true;
388
434
 
389
435
  emit JBRouterTerminalRegistry_LockTerminal(projectId, _msgSender());
390
436
  }
391
437
 
392
- /// @notice Empty implementation to satisfy the interface.
438
+ /// @notice Always returns 0 because the registry holds no project balances to migrate.
439
+ /// @param projectId Unused.
440
+ /// @param token Unused.
441
+ /// @param to Unused.
442
+ /// @return balance Always 0.
393
443
  function migrateBalanceOf(
394
444
  uint256 projectId,
395
445
  address token,
396
446
  IJBTerminal to
397
447
  )
398
448
  external
449
+ pure
399
450
  override
400
- returns (uint256 balance)
401
- {}
451
+ returns (uint256)
452
+ {
453
+ projectId;
454
+ token;
455
+ to;
456
+ return 0;
457
+ }
402
458
 
403
- /// @notice Pay a project by forwarding the payment to the resolved terminal.
459
+ /// @notice Pay a project by accepting the caller's tokens and forwarding them to the project's resolved router
460
+ /// terminal.
461
+ /// @dev Uses the project-specific terminal when set, otherwise falls back to `defaultTerminal`.
404
462
  /// @param projectId The ID of the project being paid.
405
463
  /// @param token The address of the token being paid in.
406
464
  /// @param amount The amount of tokens being paid in.
407
- /// @param beneficiary The beneficiary address to pass along.
465
+ /// @param beneficiary The address that will receive any project tokens minted by the destination.
408
466
  /// @param minReturnedTokens The minimum number of project tokens expected in return.
409
467
  /// @param memo A memo to pass along to the emitted event.
410
- /// @param metadata Bytes in `JBMetadataResolver`'s format.
411
- /// @return result The number of tokens received.
468
+ /// @param metadata Bytes in `JBMetadataResolver`'s format (may include `permit2` allowance data).
469
+ /// @return result The number of project tokens minted for the beneficiary.
412
470
  // slither-disable-next-line reentrancy-benign,reentrancy-eth
413
471
  function pay(
414
472
  uint256 projectId,
@@ -462,11 +520,12 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
462
520
  originalPayer = previousPayer;
463
521
  }
464
522
 
465
- /// @notice Set the default terminal.
466
- /// @dev Only the owner can set the default terminal.
523
+ /// @notice Change the registry-wide default terminal that all projects without an explicit override will use.
524
+ /// @dev Only the registry owner can call this. Automatically allowlists the new default.
467
525
  /// @param terminal The terminal to set as the default.
468
526
  function setDefaultTerminal(IJBTerminal terminal) external onlyOwner {
469
527
  if (address(terminal) == address(0)) revert JBRouterTerminalRegistry_ZeroAddress();
528
+ if (address(terminal) == address(this)) revert JBRouterTerminalRegistry_CircularForward(terminal);
470
529
 
471
530
  defaultTerminal = terminal;
472
531
 
@@ -476,7 +535,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
476
535
  emit JBRouterTerminalRegistry_SetDefaultTerminal(terminal, _msgSender());
477
536
  }
478
537
 
479
- /// @notice Set the terminal for a project.
538
+ /// @notice Choose which router terminal a project's payments are forwarded through.
480
539
  /// @dev Only the project's owner or an address with the `JBPermissionIds.SET_ROUTER_TERMINAL` permission can set.
481
540
  /// @param projectId The ID of the project to set the terminal for.
482
541
  /// @param terminal The terminal to set for the project.
@@ -486,6 +545,9 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
486
545
 
487
546
  if (!isTerminalAllowed[terminal]) revert JBRouterTerminalRegistry_TerminalNotAllowed(terminal);
488
547
 
548
+ // Reject a terminal that would forward this project back into the registry before saving it.
549
+ _requireNonCircularTerminalFor({projectId: projectId, terminal: terminal});
550
+
489
551
  // Enforce permissions.
490
552
  _requirePermissionFrom({
491
553
  account: PROJECTS.ownerOf(projectId),
@@ -571,7 +633,8 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
571
633
  return 0;
572
634
  }
573
635
 
574
- /// @notice Transfers tokens.
636
+ /// @notice Transfer tokens from one address to another using direct approval, `safeTransfer`, or Permit2 as a
637
+ /// fallback.
575
638
  /// @param from The address to transfer tokens from.
576
639
  /// @param to The address to transfer tokens to.
577
640
  /// @param token The address of the token being transferred.