@bananapus/router-terminal-v6 0.0.33 → 0.0.34

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 (58) hide show
  1. package/README.md +2 -2
  2. package/package.json +20 -10
  3. package/src/JBPayRouteResolver.sol +122 -10
  4. package/src/JBRouterTerminal.sol +36 -12
  5. package/src/JBRouterTerminalRegistry.sol +38 -10
  6. package/ADMINISTRATION.md +0 -80
  7. package/ARCHITECTURE.md +0 -94
  8. package/AUDIT_INSTRUCTIONS.md +0 -87
  9. package/RISKS.md +0 -49
  10. package/SKILLS.md +0 -46
  11. package/STYLE_GUIDE.md +0 -610
  12. package/USER_JOURNEYS.md +0 -181
  13. package/slither-ci.config.json +0 -10
  14. package/test/NegativeTickRounding.t.sol +0 -130
  15. package/test/RouterTerminal.t.sol +0 -2669
  16. package/test/RouterTerminalBuybackHookFork.t.sol +0 -402
  17. package/test/RouterTerminalCashOutFork.t.sol +0 -555
  18. package/test/RouterTerminalCreditCashout.t.sol +0 -776
  19. package/test/RouterTerminalERC2771.t.sol +0 -374
  20. package/test/RouterTerminalFeeCashOutFork.t.sol +0 -439
  21. package/test/RouterTerminalFork.t.sol +0 -501
  22. package/test/RouterTerminalMultihopFork.t.sol +0 -354
  23. package/test/RouterTerminalPreviewFork.t.sol +0 -339
  24. package/test/RouterTerminalReentrancy.t.sol +0 -477
  25. package/test/RouterTerminalRegistry.t.sol +0 -389
  26. package/test/RouterTerminalSandwichFork.t.sol +0 -813
  27. package/test/TestAuditGaps.sol +0 -974
  28. package/test/audit/CashOutCircularPrimaryTerminal.t.sol +0 -326
  29. package/test/audit/CashOutFallbackPrefersRecursiveLoop.t.sol +0 -363
  30. package/test/audit/CodexNemesisPayHookReceiptDoS.t.sol +0 -169
  31. package/test/audit/CreditCashoutPreferredTokenBypass.t.sol +0 -161
  32. package/test/audit/CreditCashoutSpoofedPayer.t.sol +0 -249
  33. package/test/audit/DeployBuybackHookZero.t.sol +0 -421
  34. package/test/audit/HookDataEncoding.t.sol +0 -154
  35. package/test/audit/LeftoverRefund.t.sol +0 -525
  36. package/test/audit/MultiHopCashOutCycle.t.sol +0 -350
  37. package/test/audit/MultiHopForwardCycle.t.sol +0 -187
  38. package/test/audit/Pass12M39.t.sol +0 -202
  39. package/test/audit/Pass13RouterFixes.t.sol +0 -141
  40. package/test/audit/PayerTrackerRefund.t.sol +0 -187
  41. package/test/audit/Permit2AllowanceFailed.t.sol +0 -185
  42. package/test/audit/PreviewCashOutShortcircuitDivergence.t.sol +0 -206
  43. package/test/audit/PreviewPrimaryTerminalMismatch.t.sol +0 -239
  44. package/test/audit/RefundToBeneficiary.t.sol +0 -101
  45. package/test/audit/RegistryAddToBalancePartialFill.t.sol +0 -482
  46. package/test/audit/RegistryForwardingLossyToken.t.sol +0 -238
  47. package/test/audit/RegistrySelfLockDoS.t.sol +0 -72
  48. package/test/audit/RevertingTerminalRouteDiscovery.t.sol +0 -466
  49. package/test/audit/RouterRegistryReceiptMismatch.t.sol +0 -207
  50. package/test/audit/V4HookedPoolIgnored.t.sol +0 -101
  51. package/test/audit/V4WethInputUsesStuckEth.t.sol +0 -330
  52. package/test/fork/RouterTerminalFOTFork.t.sol +0 -360
  53. package/test/fork/V4QuoteAndSettlementFork.t.sol +0 -690
  54. package/test/invariant/RouterTerminalInvariant.t.sol +0 -649
  55. package/test/regression/CashOutLoopLimit.t.sol +0 -283
  56. package/test/regression/LockTerminalRace.t.sol +0 -93
  57. package/test/regression/RouterTerminalEdgeCases.t.sol +0 -495
  58. 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.34",
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,6 +9,7 @@ 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";
@@ -273,8 +274,17 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
273
274
  continue;
274
275
  }
275
276
 
276
- // Decode only the minimum token-count commitments needed to score the buyback-enhanced preview.
277
- (,,,,,,,,,, uint256 minimumBeneficiaryTokenCount, uint256 minimumReservedTokenCount,) = abi.decode(
277
+ // Decode the buyback hook's routing metadata. When the hook mints in `afterPayRecordedWith`, the terminal
278
+ // preview returns zero token counts and the router must score the route from the hook's commitments.
279
+ (
280
+ ,
281
+ uint256 amountToMintWith,
282
+ uint256 minimumSwapAmountOut,,,
283
+ uint256 tokenCountWithoutHook,,,,,
284
+ uint256 minimumBeneficiaryTokenCount,
285
+ uint256 minimumReservedTokenCount,
286
+ uint256 rawSwapQuote
287
+ ) = abi.decode(
278
288
  specification.metadata,
279
289
  (
280
290
  bool,
@@ -293,14 +303,44 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
293
303
  )
294
304
  );
295
305
 
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;
306
+ // The hook's beneficiary/reserved commitments are only for the AMM leg. If the hook leaves part of the
307
+ // payment to mint directly, estimate that direct-mint leg at the same issuance rate used for the swapped
308
+ // amount so the router compares a whole-route token count against ordinary terminal previews.
309
+ uint256 directMintTokenCount;
310
+ if (amountToMintWith != 0 && specification.amount != 0 && tokenCountWithoutHook != 0) {
311
+ directMintTokenCount =
312
+ mulDiv({x: amountToMintWith, y: tokenCountWithoutHook, denominator: specification.amount});
313
+ }
314
+
315
+ // Score the executable floor first. This supports callers that only provide a minimum and no live quote.
316
+ (uint256 candidateBeneficiaryTokenCount, uint256 candidateReservedTokenCount) = _scaledPreviewPayTokenCounts({
317
+ tokenCount: minimumSwapAmountOut + directMintTokenCount,
318
+ referenceTokenCount: minimumSwapAmountOut,
319
+ referenceBeneficiaryTokenCount: minimumBeneficiaryTokenCount,
320
+ referenceReservedTokenCount: minimumReservedTokenCount
321
+ });
322
+ (effectiveBeneficiaryTokenCount, effectiveReservedTokenCount) = _strongerPreviewPayTokenCounts({
323
+ currentBeneficiaryTokenCount: effectiveBeneficiaryTokenCount,
324
+ currentReservedTokenCount: effectiveReservedTokenCount,
325
+ candidateBeneficiaryTokenCount: candidateBeneficiaryTokenCount,
326
+ candidateReservedTokenCount: candidateReservedTokenCount
327
+ });
328
+
329
+ // If the hook also surfaced a stronger live quote, score it too. This lets programmatic buyback routes win
330
+ // when the expected executable output is better than the conservative minimum.
331
+ if (rawSwapQuote > minimumSwapAmountOut) {
332
+ (candidateBeneficiaryTokenCount, candidateReservedTokenCount) = _scaledPreviewPayTokenCounts({
333
+ tokenCount: rawSwapQuote + directMintTokenCount,
334
+ referenceTokenCount: minimumSwapAmountOut,
335
+ referenceBeneficiaryTokenCount: minimumBeneficiaryTokenCount,
336
+ referenceReservedTokenCount: minimumReservedTokenCount
337
+ });
338
+ (effectiveBeneficiaryTokenCount, effectiveReservedTokenCount) = _strongerPreviewPayTokenCounts({
339
+ currentBeneficiaryTokenCount: effectiveBeneficiaryTokenCount,
340
+ currentReservedTokenCount: effectiveReservedTokenCount,
341
+ candidateBeneficiaryTokenCount: candidateBeneficiaryTokenCount,
342
+ candidateReservedTokenCount: candidateReservedTokenCount
343
+ });
304
344
  }
305
345
  unchecked {
306
346
  ++i;
@@ -668,6 +708,45 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
668
708
  return abi.decode(data, (IJBTerminal[]));
669
709
  }
670
710
 
711
+ /// @notice Scale a known beneficiary/reserved token split to a different total token count.
712
+ /// @param tokenCount The total token count being scored.
713
+ /// @param referenceTokenCount The total token count the reference split was computed from.
714
+ /// @param referenceBeneficiaryTokenCount The beneficiary share of the reference split.
715
+ /// @param referenceReservedTokenCount The reserved share of the reference split.
716
+ /// @return beneficiaryTokenCount The scaled beneficiary token count.
717
+ /// @return reservedTokenCount The scaled reserved token count.
718
+ function _scaledPreviewPayTokenCounts(
719
+ uint256 tokenCount,
720
+ uint256 referenceTokenCount,
721
+ uint256 referenceBeneficiaryTokenCount,
722
+ uint256 referenceReservedTokenCount
723
+ )
724
+ internal
725
+ pure
726
+ returns (uint256 beneficiaryTokenCount, uint256 reservedTokenCount)
727
+ {
728
+ // A zero candidate means there is no stronger route output to scale, so preserve the known reference split.
729
+ if (tokenCount == 0) {
730
+ return (referenceBeneficiaryTokenCount, referenceReservedTokenCount);
731
+ }
732
+
733
+ // Prefer the already-previewed beneficiary/reserved total because it includes the destination's reserve logic.
734
+ uint256 referenceTotal = referenceBeneficiaryTokenCount + referenceReservedTokenCount;
735
+
736
+ // Fall back to the original token count when previewed counts were unavailable but the hook reported a floor.
737
+ if (referenceTotal == 0) referenceTotal = referenceTokenCount;
738
+
739
+ // If both reference totals are zero, treat the whole candidate as beneficiary tokens so the route stays
740
+ // comparable instead of disappearing from scoring.
741
+ if (referenceTotal == 0) return (tokenCount, 0);
742
+
743
+ // Scale the beneficiary share proportionally from the reference split to the candidate total being scored.
744
+ beneficiaryTokenCount = mulDiv({x: tokenCount, y: referenceBeneficiaryTokenCount, denominator: referenceTotal});
745
+
746
+ // Assign the residual to reserved tokens so rounding cannot lose supply during route comparison.
747
+ reservedTokenCount = tokenCount - beneficiaryTokenCount;
748
+ }
749
+
671
750
  /// @notice Resolve whether the current route input should first be treated as a project-token cashout source.
672
751
  /// @param router The router terminal whose project-token lookup should be used.
673
752
  /// @param tokenIn The current route input token.
@@ -698,6 +777,39 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
698
777
  }
699
778
  }
700
779
 
780
+ /// @notice Choose the stronger preview outcome using beneficiary tokens first and reserved tokens as a tie-break.
781
+ /// @param currentBeneficiaryTokenCount The beneficiary token count from the strongest route so far.
782
+ /// @param currentReservedTokenCount The reserved token count from the strongest route so far.
783
+ /// @param candidateBeneficiaryTokenCount The beneficiary token count from the candidate route.
784
+ /// @param candidateReservedTokenCount The reserved token count from the candidate route.
785
+ /// @return beneficiaryTokenCount The beneficiary token count to keep.
786
+ /// @return reservedTokenCount The reserved token count to keep.
787
+ function _strongerPreviewPayTokenCounts(
788
+ uint256 currentBeneficiaryTokenCount,
789
+ uint256 currentReservedTokenCount,
790
+ uint256 candidateBeneficiaryTokenCount,
791
+ uint256 candidateReservedTokenCount
792
+ )
793
+ internal
794
+ pure
795
+ returns (uint256 beneficiaryTokenCount, uint256 reservedTokenCount)
796
+ {
797
+ // Prefer the route that gives the beneficiary more tokens, since that is the user's primary output.
798
+ if (
799
+ candidateBeneficiaryTokenCount > currentBeneficiaryTokenCount
800
+ || (
801
+ // When beneficiary output ties, keep the route that also mints more reserved tokens.
802
+ candidateBeneficiaryTokenCount == currentBeneficiaryTokenCount
803
+ && candidateReservedTokenCount > currentReservedTokenCount
804
+ )
805
+ ) {
806
+ return (candidateBeneficiaryTokenCount, candidateReservedTokenCount);
807
+ }
808
+
809
+ // Keep the current winner when the candidate does not improve beneficiary output or the reserved tie-break.
810
+ return (currentBeneficiaryTokenCount, currentReservedTokenCount);
811
+ }
812
+
701
813
  /// @notice Resolve the usable primary terminal for a discovered candidate token.
702
814
  /// @param router The router whose circular-terminal rule should be applied.
703
815
  /// @param directory The directory used to resolve primary terminals.
@@ -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().
@@ -2333,9 +2350,12 @@ contract JBRouterTerminal is
2333
2350
  // An OOB access in the try-success block panics and is NOT caught by catch{}.
2334
2351
  if (tickCumulatives.length >= 2) {
2335
2352
  // Derive the arithmetic mean tick: (cumulative_now - cumulative_start) / elapsed_seconds.
2336
- // forge-lint: disable-next-line(unsafe-typecast)
2337
2353
  int56 tickDelta = tickCumulatives[1] - tickCumulatives[0];
2354
+ // The TWAP window is a small protocol constant that fits in int32 and int56.
2355
+ // forge-lint: disable-next-line(unsafe-typecast)
2338
2356
  int56 period = int56(int32(_TWAP_WINDOW));
2357
+ // The cumulative tick values come from Uniswap observations, whose average tick is int24-bounded.
2358
+ // forge-lint: disable-next-line(unsafe-typecast)
2339
2359
  tick = int24(tickDelta / period);
2340
2360
  // Round towards negative infinity for negative ticks (Uniswap convention).
2341
2361
  if (tickDelta < 0 && (tickDelta % period != 0)) tick--;
@@ -2566,7 +2586,9 @@ contract JBRouterTerminal is
2566
2586
  });
2567
2587
 
2568
2588
  // Enforce the caller's minimum reclaim amount only on the first hop.
2569
- if (amount < minTokensReclaimed) revert JBRouterTerminal_SlippageExceeded(amount, minTokensReclaimed);
2589
+ if (amount < minTokensReclaimed) {
2590
+ revert JBRouterTerminal_SlippageExceeded({amountOut: amount, minAmountOut: minTokensReclaimed});
2591
+ }
2570
2592
 
2571
2593
  // Clear the reclaim minimum after the first hop because later hops may operate in different token units.
2572
2594
  minTokensReclaimed = 0;
@@ -2622,6 +2644,8 @@ contract JBRouterTerminal is
2622
2644
  metadata: ""
2623
2645
  });
2624
2646
 
2647
+ // Deployment config makes this router a feeless cash-out beneficiary, so previews use the terminal's raw
2648
+ // reclaim amount and avoid carrying fee-discovery bytecode in the router.
2625
2649
  reclaimAmount = _effectivePreviewCashOutAmount(reclaimAmount, hookSpecifications);
2626
2650
  }
2627
2651
 
@@ -226,6 +226,13 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
226
226
  return super._contextSuffixLength();
227
227
  }
228
228
 
229
+ /// @notice Prevent the registry from forwarding straight back into its immediate caller.
230
+ /// @param terminal The terminal the registry is about to forward into.
231
+ function _enforceNoCircularForward(IJBTerminal terminal) internal view {
232
+ // Reject immediate caller cycles so router -> registry -> same router cannot recurse indefinitely.
233
+ if (msg.sender == address(terminal)) revert JBRouterTerminalRegistry_CircularForward(terminal);
234
+ }
235
+
229
236
  /// @notice The calldata. Preferred to use over `msg.data`.
230
237
  /// @return calldata The `msg.data` of this call.
231
238
  function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) {
@@ -238,6 +245,29 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
238
245
  return ERC2771Context._msgSender();
239
246
  }
240
247
 
248
+ /// @notice Reject terminal choices that would forward the project back into this registry.
249
+ /// @param projectId The project whose forwarding target is being validated.
250
+ /// @param terminal The terminal being configured or locked.
251
+ function _requireNonCircularTerminalFor(uint256 projectId, IJBTerminal terminal) internal view {
252
+ // Reject direct self-selection so the registry cannot forward a project to itself.
253
+ if (address(terminal) == address(this)) revert JBRouterTerminalRegistry_CircularForward(terminal);
254
+
255
+ // Externally owned accounts cannot implement `terminalOf`, so there is no forwarding route to inspect.
256
+ if (address(terminal).code.length == 0) return;
257
+
258
+ // If the candidate is another forwarding terminal, ask where this project would end up.
259
+ try IJBForwardingTerminal(address(terminal)).terminalOf({projectId: projectId}) returns (
260
+ IJBTerminal downstreamTerminal
261
+ ) {
262
+ // Reject one-hop forwarding cycles that bounce this project back into the registry.
263
+ if (address(downstreamTerminal) == address(this)) {
264
+ revert JBRouterTerminalRegistry_CircularForward(terminal);
265
+ }
266
+ } catch {
267
+ // Non-forwarding terminals are valid choices; failed interface probes should not block them.
268
+ }
269
+ }
270
+
241
271
  /// @notice Resolve the effective terminal for a project, falling back to the default terminal when unset.
242
272
  /// @param projectId The project whose terminal should be resolved.
243
273
  /// @return terminal The project-specific terminal, or the default terminal if no override exists.
@@ -249,13 +279,6 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
249
279
  if (terminal == IJBTerminal(address(0))) terminal = defaultTerminal;
250
280
  }
251
281
 
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
282
  //*********************************************************************//
260
283
  // ---------------------- external transactions ---------------------- //
261
284
  //*********************************************************************//
@@ -357,9 +380,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
357
380
 
358
381
  /// @notice Lock a terminal for a project.
359
382
  /// @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.
383
+ /// @dev Circular or self-referential terminals are rejected before the irreversible lock is written.
363
384
  /// @param projectId The ID of the project to lock the terminal for.
364
385
  /// @param expectedTerminal The terminal the caller expects to lock. Prevents race conditions where the default
365
386
  /// changes between transaction submission and execution.
@@ -384,6 +405,9 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
384
405
  revert JBRouterTerminalRegistry_TerminalMismatch(terminal, expectedTerminal);
385
406
  }
386
407
 
408
+ // Reject a terminal that would make this irreversible lock forward back into the registry.
409
+ _requireNonCircularTerminalFor({projectId: projectId, terminal: terminal});
410
+
387
411
  hasLockedTerminal[projectId] = true;
388
412
 
389
413
  emit JBRouterTerminalRegistry_LockTerminal(projectId, _msgSender());
@@ -467,6 +491,7 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
467
491
  /// @param terminal The terminal to set as the default.
468
492
  function setDefaultTerminal(IJBTerminal terminal) external onlyOwner {
469
493
  if (address(terminal) == address(0)) revert JBRouterTerminalRegistry_ZeroAddress();
494
+ if (address(terminal) == address(this)) revert JBRouterTerminalRegistry_CircularForward(terminal);
470
495
 
471
496
  defaultTerminal = terminal;
472
497
 
@@ -486,6 +511,9 @@ contract JBRouterTerminalRegistry is IJBRouterTerminalRegistry, JBPermissioned,
486
511
 
487
512
  if (!isTerminalAllowed[terminal]) revert JBRouterTerminalRegistry_TerminalNotAllowed(terminal);
488
513
 
514
+ // Reject a terminal that would forward this project back into the registry before saving it.
515
+ _requireNonCircularTerminalFor({projectId: projectId, terminal: terminal});
516
+
489
517
  // Enforce permissions.
490
518
  _requirePermissionFrom({
491
519
  account: PROJECTS.ownerOf(projectId),
package/ADMINISTRATION.md DELETED
@@ -1,80 +0,0 @@
1
- # Administration
2
-
3
- ## At A Glance
4
-
5
- | Item | Details |
6
- | --- | --- |
7
- | Scope | Global router terminal allowlisting and project-local terminal selection |
8
- | Control posture | Mixed registry-owner and project-local delegated control |
9
- | Highest-risk actions | Changing the default terminal, locking a project to the wrong terminal, and relying on misconfigured credit-cashout routing |
10
- | Recovery posture | Unlocked projects can move; locked projects and immutable router wiring limit recovery |
11
-
12
- ## Purpose
13
-
14
- `nana-router-terminal-v6` splits administration between a global registry and project-local terminal selection. The router logic itself is mostly immutable. The mutable control plane lives in `JBRouterTerminalRegistry`.
15
-
16
- ## Control Model
17
-
18
- - `JBRouterTerminalRegistry` is globally `Ownable`
19
- - project owners or delegates choose and can lock their router terminal
20
- - `JBRouterTerminal` has immutable routing dependencies and no owner-controlled strategy knobs
21
- - some transaction paths depend on project-local `JBPermissions`, such as `TRANSFER_CREDITS`
22
-
23
- ## Roles
24
-
25
- | Role | How Assigned | Scope | Notes |
26
- | --- | --- | --- | --- |
27
- | Registry owner | `Ownable(owner)` | Global | Controls allowlist and default terminal |
28
- | Project owner | `JBProjects.ownerOf(projectId)` | Per project | May delegate `SET_ROUTER_TERMINAL` |
29
- | Terminal delegate | `JBPermissions` grant | Per project | Usually `SET_ROUTER_TERMINAL` |
30
- | Payer | Per transaction | Per payment | May need `TRANSFER_CREDITS` for credit-cashout routing |
31
-
32
- ## Privileged Surfaces
33
-
34
- | Contract | Function | Who Can Call | Effect |
35
- | --- | --- | --- | --- |
36
- | `JBRouterTerminalRegistry` | `allowTerminal(...)`, `disallowTerminal(...)`, `setDefaultTerminal(...)` | Registry owner | Controls global terminal availability and fallback |
37
- | `JBRouterTerminalRegistry` | `setTerminalFor(...)` | Project owner or `SET_ROUTER_TERMINAL` delegate | Sets a project's explicit router terminal |
38
- | `JBRouterTerminalRegistry` | `lockTerminalFor(...)` | Project owner or `SET_ROUTER_TERMINAL` delegate | Irreversibly locks the resolved terminal for a project |
39
-
40
- ## Immutable And One-Way
41
-
42
- - `lockTerminalFor(...)` is irreversible
43
- - constructor dependencies on the router are immutable
44
- - the current default terminal must move before the old default can be disallowed
45
-
46
- ## Operational Notes
47
-
48
- - keep the terminal allowlist small and explicit
49
- - change the default terminal carefully because unconfigured projects inherit it
50
- - encourage projects to lock only after validating the resolved terminal and routing behavior
51
- - review credit-cashout routing permissions before relying on that path
52
- - distinguish configuration risk from quote-quality risk
53
-
54
- ## Machine Notes
55
-
56
- - do not treat registry ownership as authority to override a locked project choice
57
- - inspect `src/JBRouterTerminalRegistry.sol` and `src/JBRouterTerminal.sol` separately; they govern different control boundaries
58
- - if effective terminal resolution and the documented default differ, resolve registry state before further actions
59
- - if route previews are falling back to weaker discovery or quote paths, do not describe the router as offering uniform oracle-quality guarantees
60
-
61
- ## Recovery
62
-
63
- - unlocked projects can switch to another allowlisted terminal
64
- - locked projects cannot be unlocked by the registry
65
- - bad immutable router behavior means replacement infrastructure, not in-place edits
66
- - quote-path weakness is usually mitigated operationally with better pool choice, external quoting, or replacement routing infrastructure
67
-
68
- ## Admin Boundaries
69
-
70
- - the registry owner cannot unlock or override a locked project terminal
71
- - project operators cannot set a terminal that the registry does not allow
72
- - router maintainers cannot tune routing heuristics or constructor immutables post-deploy
73
- - there is no pause surface in the registry or router
74
-
75
- ## Source Map
76
-
77
- - `src/JBRouterTerminalRegistry.sol`
78
- - `src/JBRouterTerminal.sol`
79
- - `src/JBPayRouteResolver.sol`
80
- - `test/`
package/ARCHITECTURE.md DELETED
@@ -1,94 +0,0 @@
1
- # Architecture
2
-
3
- ## Purpose
4
-
5
- `nana-router-terminal-v6` lets a payer fund a Juicebox project with a token the project does not directly accept. It discovers the destination token, wraps or unwraps native assets when needed, can recursively cash out upstream JB project tokens, and swaps through bounded Uniswap V3 or V4 routes before forwarding value to the destination terminal.
6
-
7
- The router is intentionally heuristic. It does not search every possible route for a globally optimal price.
8
-
9
- ## System Overview
10
-
11
- `JBRouterTerminal` is a terminal-shaped adapter, not an accounting source of truth. `JBRouterTerminalRegistry` is both a registry and a stable project-facing proxy surface: projects can point at the registry while the registry resolves, and can later lock, the actual router terminal implementation to use. `JBPayRouteResolver` expands preview candidates without forcing the main router contract to carry all preview complexity inline.
12
-
13
- Final accounting still happens in the downstream terminal selected through `nana-core-v6`.
14
-
15
- ## Core Invariants
16
-
17
- - the router's own accounting context is synthetic and must not be treated as the project ledger
18
- - preview route discovery and live execution must stay aligned
19
- - refund behavior is part of correctness, not just UX
20
- - registry locking prevents silent migration to untrusted router implementations
21
- - final terminal-facing ERC-20 hops only support standard, non-lossy transfers
22
- - recursive project-token cashout routing is intentionally bounded
23
- - caller reclaim minima only apply to the first cashout hop, because later hops may change token units
24
- - circular `router -> registry -> same router` forwarding remains blocked in the registry
25
-
26
- ## Modules
27
-
28
- | Module | Responsibility | Notes |
29
- | --- | --- | --- |
30
- | `JBRouterTerminal` | Intake, route discovery, swap execution, forwarding, and refunds | Main runtime surface |
31
- | `JBRouterTerminalRegistry` | Project-level router selection, locking, and proxy forwarding to the resolved router terminal | Governance, safety, and proxy surface |
32
- | `JBPayRouteResolver` | Preview candidate evaluation | Helper to keep runtime size bounded |
33
- | `JBSwapLib` and routing structs | Pool discovery, quoting, and route metadata | Shared routing logic |
34
-
35
- ## Trust Boundaries
36
-
37
- - final accounting remains in the downstream terminal selected through `JBDirectory`
38
- - the router trusts Uniswap V3, Uniswap V4, Permit2, and optional payer trackers for routing-side behavior
39
- - fee-on-transfer tokens are only tolerated on ingress where received-balance deltas can be reconciled
40
- - the registry is trusted to resolve and forward into the intended router implementation for a project
41
-
42
- ## Critical Flows
43
-
44
- ### Route And Pay
45
-
46
- ```text
47
- router pay call
48
- -> accept native, ERC-20, or JB-token-like input
49
- -> if input is a project token, recursively cash it out first
50
- -> resolve the destination token the project terminal actually accepts
51
- -> choose the best direct, wrap/unwrap, or swap path under the router's bounded candidate-discovery heuristic
52
- -> execute the route and forward the result to the downstream terminal
53
- -> refund leftover input when possible
54
- ```
55
-
56
- ## Accounting Model
57
-
58
- The router does not own project balances. It owns transient route accounting: input reconciliation, swap execution, forwarded amount, and refund resolution.
59
-
60
- Preview and execution share the same conceptual route shape: optional recursive cashout first, then destination-token resolution, then final conversion and forwarding.
61
-
62
- ## Security Model
63
-
64
- - native-asset handling and refunds are the most failure-prone paths
65
- - V3 and V4 discovery must stay synchronized between preview and live execution
66
- - V4 discovery intentionally considers both vanilla pools and pools using the canonical `UNIV4_HOOK`
67
- - the router's "best route" claim is only as strong as its bounded discovery set and external-terminal safety checks
68
- - recursive cashout behavior, preferred-token handling, and one-shot source overrides are tightly coupled
69
-
70
- ## Safe Change Guide
71
-
72
- - keep route discovery and route execution semantics paired
73
- - be conservative with native wrapping, unwrapping, and refund behavior
74
- - if recursive cash-out logic changes, review hop limits and failure handling together
75
- - if metadata semantics change, re-check first-hop reclaim minima, one-shot source overrides, and preferred-token routing together
76
- - do not turn the router into a persistent treasury layer
77
-
78
- ## Canonical Checks
79
-
80
- - bounded recursive cash-out behavior:
81
- `test/regression/CashOutLoopLimit.t.sol`
82
- - preview versus execution terminal alignment:
83
- `test/audit/PreviewPrimaryTerminalMismatch.t.sol`
84
- - router-wide route and refund invariants:
85
- `test/invariant/RouterTerminalInvariant.t.sol`
86
-
87
- ## Source Map
88
-
89
- - `src/JBRouterTerminal.sol`
90
- - `src/JBRouterTerminalRegistry.sol`
91
- - `src/JBPayRouteResolver.sol`
92
- - `test/regression/CashOutLoopLimit.t.sol`
93
- - `test/audit/PreviewPrimaryTerminalMismatch.t.sol`
94
- - `test/invariant/RouterTerminalInvariant.t.sol`