@bananapus/router-terminal-v6 0.0.32 → 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.
- package/README.md +2 -2
- package/package.json +20 -10
- package/src/JBPayRouteResolver.sol +127 -36
- package/src/JBRouterTerminal.sol +53 -39
- package/src/JBRouterTerminalRegistry.sol +38 -10
- package/src/libraries/JBForwardingCheck.sol +44 -0
- package/ADMINISTRATION.md +0 -80
- package/ARCHITECTURE.md +0 -94
- package/AUDIT_INSTRUCTIONS.md +0 -87
- package/RISKS.md +0 -44
- package/SKILLS.md +0 -46
- package/STYLE_GUIDE.md +0 -610
- package/USER_JOURNEYS.md +0 -181
- package/slither-ci.config.json +0 -10
- package/test/NegativeTickRounding.t.sol +0 -130
- package/test/RouterTerminal.t.sol +0 -2669
- package/test/RouterTerminalBuybackHookFork.t.sol +0 -402
- package/test/RouterTerminalCashOutFork.t.sol +0 -555
- package/test/RouterTerminalCreditCashout.t.sol +0 -774
- package/test/RouterTerminalERC2771.t.sol +0 -374
- package/test/RouterTerminalFeeCashOutFork.t.sol +0 -439
- package/test/RouterTerminalFork.t.sol +0 -501
- package/test/RouterTerminalMultihopFork.t.sol +0 -354
- package/test/RouterTerminalPreviewFork.t.sol +0 -339
- package/test/RouterTerminalReentrancy.t.sol +0 -477
- package/test/RouterTerminalRegistry.t.sol +0 -389
- package/test/RouterTerminalSandwichFork.t.sol +0 -813
- package/test/TestAuditGaps.sol +0 -974
- package/test/audit/CreditCashoutPreferredTokenBypass.t.sol +0 -161
- package/test/audit/CreditCashoutSpoofedPayer.t.sol +0 -249
- package/test/audit/DeployBuybackHookZero.t.sol +0 -421
- package/test/audit/LeftoverRefund.t.sol +0 -525
- package/test/audit/MultiHopForwardCycle.t.sol +0 -187
- package/test/audit/PayerTrackerRefund.t.sol +0 -187
- package/test/audit/Permit2AllowanceFailed.t.sol +0 -185
- package/test/audit/PreviewCashOutShortcircuitDivergence.t.sol +0 -206
- package/test/audit/PreviewPrimaryTerminalMismatch.t.sol +0 -239
- package/test/audit/RefundToBeneficiary.t.sol +0 -101
- package/test/audit/RegistryAddToBalancePartialFill.t.sol +0 -482
- package/test/audit/RevertingTerminalRouteDiscovery.t.sol +0 -466
- package/test/codex/CashOutCircularPrimaryTerminal.t.sol +0 -326
- package/test/codex/CashOutFallbackPrefersRecursiveLoop.t.sol +0 -363
- package/test/codex/RegistryForwardingLossyToken.t.sol +0 -229
- package/test/codex/RegistrySelfLockDoS.t.sol +0 -72
- package/test/codex/RouterRegistryReceiptMismatch.t.sol +0 -207
- package/test/codex/V4HookedPoolIgnored.t.sol +0 -101
- package/test/codex/V4WethInputUsesStuckEth.t.sol +0 -330
- package/test/fork/RouterTerminalFOTFork.t.sol +0 -363
- package/test/fork/V4QuoteAndSettlementFork.t.sol +0 -690
- package/test/invariant/RouterTerminalInvariant.t.sol +0 -649
- package/test/regression/CashOutLoopLimit.t.sol +0 -213
- package/test/regression/LockTerminalRace.t.sol +0 -93
- package/test/regression/RouterTerminalEdgeCases.t.sol +0 -495
- package/test/regression/V4SpotPriceSlippage.t.sol +0 -401
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/router-terminal-v6",
|
|
3
|
-
"version": "0.0.
|
|
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": "
|
|
21
|
-
"@bananapus/core-v6": "
|
|
22
|
-
"@bananapus/permission-ids-v6": "
|
|
23
|
-
"@
|
|
24
|
-
"@
|
|
25
|
-
"@uniswap/
|
|
26
|
-
"@uniswap/v3-
|
|
27
|
-
"@uniswap/
|
|
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": "
|
|
40
|
+
"@sphinx-labs/plugins": "0.33.3"
|
|
31
41
|
}
|
|
32
42
|
}
|
|
@@ -9,8 +9,9 @@ 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
|
-
import {
|
|
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";
|
|
@@ -273,8 +274,17 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
273
274
|
continue;
|
|
274
275
|
}
|
|
275
276
|
|
|
276
|
-
// Decode
|
|
277
|
-
|
|
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
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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;
|
|
@@ -340,7 +380,9 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
340
380
|
return _normalizedTokenOf(tokenA) == _normalizedTokenOf(tokenB);
|
|
341
381
|
}
|
|
342
382
|
|
|
343
|
-
/// @notice Whether previewing through a terminal would
|
|
383
|
+
/// @notice Whether previewing through a terminal would cycle back into the router.
|
|
384
|
+
/// @dev Delegates to `JBForwardingCheck.isCircularTerminal` — shared with `JBRouterTerminal` so that
|
|
385
|
+
/// preview and execution use identical cycle-detection logic.
|
|
344
386
|
/// @param router The router whose preview path is being evaluated.
|
|
345
387
|
/// @param projectId The project whose forwarding terminal would be resolved.
|
|
346
388
|
/// @param terminal The terminal that would receive the previewed route.
|
|
@@ -354,30 +396,7 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
354
396
|
view
|
|
355
397
|
returns (bool isCircular)
|
|
356
398
|
{
|
|
357
|
-
|
|
358
|
-
// A bounded loop prevents infinite gas consumption from longer chains while catching realistic cycles.
|
|
359
|
-
IJBTerminal current = terminal;
|
|
360
|
-
for (uint256 i; i < 5; i++) {
|
|
361
|
-
// Treat routes back to the router as circular.
|
|
362
|
-
if (address(current) == address(router)) return true;
|
|
363
|
-
|
|
364
|
-
// Probe via staticcall so plain terminals degrade cleanly.
|
|
365
|
-
// slither-disable-next-line calls-loop
|
|
366
|
-
(bool success, bytes memory data) =
|
|
367
|
-
address(current).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
|
|
368
|
-
|
|
369
|
-
// Non-forwarding terminals (call fails or returns zero) end the chain — not circular.
|
|
370
|
-
if (!success || data.length < 32) return false;
|
|
371
|
-
IJBTerminal forwardingTarget = abi.decode(data, (IJBTerminal));
|
|
372
|
-
if (address(forwardingTarget) == address(0)) return false;
|
|
373
|
-
|
|
374
|
-
// Follow the forwarding chain one more hop.
|
|
375
|
-
current = forwardingTarget;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// If we followed 5 hops without finding a non-forwarding terminal or the router,
|
|
379
|
-
// treat this as a suspicious deep chain and mark it as circular to be safe.
|
|
380
|
-
return true;
|
|
399
|
+
return JBForwardingCheck.isCircularTerminal({target: address(router), projectId: projectId, terminal: terminal});
|
|
381
400
|
}
|
|
382
401
|
|
|
383
402
|
/// @notice Normalize a token into the form the router uses for routing comparisons.
|
|
@@ -689,6 +708,45 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
689
708
|
return abi.decode(data, (IJBTerminal[]));
|
|
690
709
|
}
|
|
691
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
|
+
|
|
692
750
|
/// @notice Resolve whether the current route input should first be treated as a project-token cashout source.
|
|
693
751
|
/// @param router The router terminal whose project-token lookup should be used.
|
|
694
752
|
/// @param tokenIn The current route input token.
|
|
@@ -719,6 +777,39 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
719
777
|
}
|
|
720
778
|
}
|
|
721
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
|
+
|
|
722
813
|
/// @notice Resolve the usable primary terminal for a discovered candidate token.
|
|
723
814
|
/// @param router The router whose circular-terminal rule should be applied.
|
|
724
815
|
/// @param directory The directory used to resolve primary terminals.
|
package/src/JBRouterTerminal.sol
CHANGED
|
@@ -44,6 +44,7 @@ import {IJBPayRoutePreviewer} from "./interfaces/IJBPayRoutePreviewer.sol";
|
|
|
44
44
|
import {IJBPayRouteResolver} from "./interfaces/IJBPayRouteResolver.sol";
|
|
45
45
|
import {IJBRouterTerminal} from "./interfaces/IJBRouterTerminal.sol";
|
|
46
46
|
import {IWETH9} from "./interfaces/IWETH9.sol";
|
|
47
|
+
import {JBForwardingCheck} from "./libraries/JBForwardingCheck.sol";
|
|
47
48
|
import {JBSwapLib} from "./libraries/JBSwapLib.sol";
|
|
48
49
|
import {JBPayRouteResolver} from "./JBPayRouteResolver.sol";
|
|
49
50
|
import {CashOutPathCandidates} from "./structs/CashOutPathCandidates.sol";
|
|
@@ -74,6 +75,7 @@ contract JBRouterTerminal is
|
|
|
74
75
|
error JBRouterTerminal_AmountOverflow(uint256 amount);
|
|
75
76
|
error JBRouterTerminal_CallerNotPool(address caller);
|
|
76
77
|
error JBRouterTerminal_CallerNotPoolManager(address caller);
|
|
78
|
+
error JBRouterTerminal_CashOutDidNotDeliver(address sourceToken, address tokenToReclaim, uint256 cashOutCount);
|
|
77
79
|
error JBRouterTerminal_CashOutLoopLimit();
|
|
78
80
|
error JBRouterTerminal_InsufficientTwapHistory();
|
|
79
81
|
error JBRouterTerminal_NoCashOutPath(uint256 sourceProjectId, uint256 destProjectId);
|
|
@@ -356,11 +358,6 @@ contract JBRouterTerminal is
|
|
|
356
358
|
// native.
|
|
357
359
|
uint256 payValue = _beforeTransferFor({to: address(destTerminal), token: token, amount: amount});
|
|
358
360
|
|
|
359
|
-
// Snapshot the destination terminal's ERC20 balance and forwarding status for receipt enforcement.
|
|
360
|
-
// Combines both checks into one call to avoid duplicate _isForwardingTerminal probes.
|
|
361
|
-
(uint256 terminalReceiptBaseline, bool isForwarding) =
|
|
362
|
-
_terminalReceiptBaselineOf({terminal: destTerminal, token: token, projectId: projectId});
|
|
363
|
-
|
|
364
361
|
// Execute the final payment on the destination terminal and bubble back the beneficiary token count it
|
|
365
362
|
// returned.
|
|
366
363
|
beneficiaryTokenCount = destTerminal.pay{value: payValue}({
|
|
@@ -375,14 +372,11 @@ contract JBRouterTerminal is
|
|
|
375
372
|
|
|
376
373
|
_afterTransferFor({destTerminal: destTerminal, token: token});
|
|
377
374
|
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
receiptBaseline: terminalReceiptBaseline,
|
|
384
|
-
isForwarding: isForwarding
|
|
385
|
-
});
|
|
375
|
+
// NOTE: No ERC-20 receipt enforcement here (unlike addToBalanceOf).
|
|
376
|
+
// Pay hooks attached to the destination terminal may legitimately consume tokens during
|
|
377
|
+
// pay(), making a balance-delta check produce false reverts. Fee-on-transfer (FOT) tokens
|
|
378
|
+
// are therefore NOT supported for routed payments — the terminal will receive fewer tokens
|
|
379
|
+
// than `amount` but the router cannot detect or prevent this. See RISKS.md for details.
|
|
386
380
|
}
|
|
387
381
|
|
|
388
382
|
/// @notice The Uniswap v3 pool callback where the token transfer is expected to happen.
|
|
@@ -436,7 +430,7 @@ contract JBRouterTerminal is
|
|
|
436
430
|
params: SwapParams({
|
|
437
431
|
zeroForOne: zeroForOne, amountSpecified: amountSpecified, sqrtPriceLimitX96: sqrtPriceLimitX96
|
|
438
432
|
}),
|
|
439
|
-
hookData: ""
|
|
433
|
+
hookData: address(key.hooks) != address(0) ? abi.encode(minAmountOut) : bytes("")
|
|
440
434
|
});
|
|
441
435
|
|
|
442
436
|
// Determine input/output amounts from the delta.
|
|
@@ -456,7 +450,10 @@ contract JBRouterTerminal is
|
|
|
456
450
|
amountOut = uint256(uint128(delta0));
|
|
457
451
|
}
|
|
458
452
|
|
|
459
|
-
|
|
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
|
+
}
|
|
460
457
|
|
|
461
458
|
// Settle input (pay what we owe to the PoolManager).
|
|
462
459
|
Currency inputCurrency = zeroForOne ? key.currency0 : key.currency1;
|
|
@@ -812,15 +809,13 @@ contract JBRouterTerminal is
|
|
|
812
809
|
continue;
|
|
813
810
|
}
|
|
814
811
|
|
|
815
|
-
// Decode only the
|
|
816
|
-
|
|
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,,,,,,) =
|
|
817
815
|
abi.decode(specification.metadata, (uint256, uint256, uint256, int24, uint128, PoolId, uint256));
|
|
818
816
|
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
// Keep whichever understood hook quote implies the strongest previewed cash-out output.
|
|
823
|
-
if (quotedAmount > effectiveAmount) effectiveAmount = quotedAmount;
|
|
817
|
+
// Keep whichever understood executable hook commitment implies the strongest cash-out output.
|
|
818
|
+
if (minimumSwapAmountOut > effectiveAmount) effectiveAmount = minimumSwapAmountOut;
|
|
824
819
|
|
|
825
820
|
unchecked {
|
|
826
821
|
++i;
|
|
@@ -994,13 +989,16 @@ contract JBRouterTerminal is
|
|
|
994
989
|
terminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: token});
|
|
995
990
|
|
|
996
991
|
// Drop terminals that would route straight back into the router (circular).
|
|
997
|
-
if (
|
|
992
|
+
if (
|
|
993
|
+
address(terminal) == address(0)
|
|
994
|
+
|| JBForwardingCheck.isCircularTerminal({
|
|
995
|
+
target: address(this), projectId: projectId, terminal: terminal
|
|
996
|
+
})
|
|
997
|
+
) {
|
|
998
998
|
return IJBTerminal(address(0));
|
|
999
999
|
}
|
|
1000
1000
|
|
|
1001
1001
|
// Check if the terminal is a forwarding layer that routes back into this router.
|
|
1002
|
-
// Uses the same low-level staticcall pattern as _isForwardingTerminal — non-forwarding terminals degrade
|
|
1003
|
-
// cleanly into a no-op (success=false or empty data).
|
|
1004
1002
|
// slither-disable-next-line calls-loop
|
|
1005
1003
|
(bool ok, bytes memory data) =
|
|
1006
1004
|
address(terminal).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
|
|
@@ -1031,13 +1029,6 @@ contract JBRouterTerminal is
|
|
|
1031
1029
|
}
|
|
1032
1030
|
}
|
|
1033
1031
|
|
|
1034
|
-
/// @notice Whether routing through a terminal would cycle back into the router.
|
|
1035
|
-
/// @param terminal The terminal that would receive the route.
|
|
1036
|
-
/// @return isCircular A flag indicating whether `terminal` is this router itself.
|
|
1037
|
-
function _isCircularTerminal(IJBTerminal terminal) internal view returns (bool isCircular) {
|
|
1038
|
-
return address(terminal) == address(this);
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
1032
|
/// @notice Accepts a token being paid in.
|
|
1042
1033
|
/// @param token The address of the token being paid in.
|
|
1043
1034
|
/// @param amount The amount of tokens being paid in.
|
|
@@ -1208,26 +1199,40 @@ contract JBRouterTerminal is
|
|
|
1208
1199
|
sourceProjectId: sourceProjectId, destProjectId: destProjectId, preferredToken: preferredToken
|
|
1209
1200
|
});
|
|
1210
1201
|
|
|
1202
|
+
uint256 cashOutCount = amount;
|
|
1211
1203
|
uint256 balanceBefore = _balanceOf({token: tokenToReclaim, account: address(this)});
|
|
1212
1204
|
|
|
1213
1205
|
// Cash out the source project's tokens.
|
|
1214
1206
|
// Don't rely on the terminal return value here. Buyback-hook sell-side execution returns 0 reclaimAmount
|
|
1215
1207
|
// from nana-core, then transfers the real proceeds during the hook callback.
|
|
1216
|
-
// Pass
|
|
1217
|
-
//
|
|
1208
|
+
// Pass minTokensReclaimed=0 to the terminal because the buyback hook's sell-side delivers tokens via
|
|
1209
|
+
// callback (reclaimAmount=0 from the terminal's perspective), which would fail the terminal's own min
|
|
1210
|
+
// check. The router enforces the user's minimum via the balance-delta check below instead.
|
|
1218
1211
|
// slither-disable-next-line unused-return,calls-loop
|
|
1219
1212
|
cashOutTerminal.cashOutTokensOf({
|
|
1220
1213
|
holder: address(this),
|
|
1221
1214
|
projectId: sourceProjectId,
|
|
1222
1215
|
cashOutCount: amount,
|
|
1223
1216
|
tokenToReclaim: tokenToReclaim,
|
|
1224
|
-
minTokensReclaimed:
|
|
1217
|
+
minTokensReclaimed: 0,
|
|
1225
1218
|
beneficiary: payable(address(this)),
|
|
1226
1219
|
metadata: ""
|
|
1227
1220
|
});
|
|
1228
1221
|
|
|
1222
|
+
// Measure the reclaimed-token balance delta so fee-on-transfer behavior cannot fake delivery.
|
|
1229
1223
|
amount = _balanceOf({token: tokenToReclaim, account: address(this)}) - balanceBefore;
|
|
1230
|
-
|
|
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
|
+
}
|
|
1231
1236
|
|
|
1232
1237
|
// Clear the reclaim minimum after the first hop.
|
|
1233
1238
|
// Multi-hop routes can change token units between hops, so there is no sound generic way to rescale a
|
|
@@ -1384,7 +1389,9 @@ contract JBRouterTerminal is
|
|
|
1384
1389
|
|
|
1385
1390
|
// Enforce slippage protection via realized output vs minimum acceptable output.
|
|
1386
1391
|
// This is strictly more correct than sqrtPriceLimitX96 (which conflates marginal and average price).
|
|
1387
|
-
if (amountOut < minAmountOut)
|
|
1392
|
+
if (amountOut < minAmountOut) {
|
|
1393
|
+
revert JBRouterTerminal_SlippageExceeded({amountOut: amountOut, minAmountOut: minAmountOut});
|
|
1394
|
+
}
|
|
1388
1395
|
}
|
|
1389
1396
|
|
|
1390
1397
|
/// @notice Execute a swap through a V4 pool via PoolManager.unlock().
|
|
@@ -2343,9 +2350,12 @@ contract JBRouterTerminal is
|
|
|
2343
2350
|
// An OOB access in the try-success block panics and is NOT caught by catch{}.
|
|
2344
2351
|
if (tickCumulatives.length >= 2) {
|
|
2345
2352
|
// Derive the arithmetic mean tick: (cumulative_now - cumulative_start) / elapsed_seconds.
|
|
2346
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
2347
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)
|
|
2348
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)
|
|
2349
2359
|
tick = int24(tickDelta / period);
|
|
2350
2360
|
// Round towards negative infinity for negative ticks (Uniswap convention).
|
|
2351
2361
|
if (tickDelta < 0 && (tickDelta % period != 0)) tick--;
|
|
@@ -2576,7 +2586,9 @@ contract JBRouterTerminal is
|
|
|
2576
2586
|
});
|
|
2577
2587
|
|
|
2578
2588
|
// Enforce the caller's minimum reclaim amount only on the first hop.
|
|
2579
|
-
if (amount < minTokensReclaimed)
|
|
2589
|
+
if (amount < minTokensReclaimed) {
|
|
2590
|
+
revert JBRouterTerminal_SlippageExceeded({amountOut: amount, minAmountOut: minTokensReclaimed});
|
|
2591
|
+
}
|
|
2580
2592
|
|
|
2581
2593
|
// Clear the reclaim minimum after the first hop because later hops may operate in different token units.
|
|
2582
2594
|
minTokensReclaimed = 0;
|
|
@@ -2632,6 +2644,8 @@ contract JBRouterTerminal is
|
|
|
2632
2644
|
metadata: ""
|
|
2633
2645
|
});
|
|
2634
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.
|
|
2635
2649
|
reclaimAmount = _effectivePreviewCashOutAmount(reclaimAmount, hookSpecifications);
|
|
2636
2650
|
}
|
|
2637
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
|
|
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),
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.0;
|
|
3
|
+
|
|
4
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
5
|
+
import {IJBForwardingTerminal} from "../interfaces/IJBForwardingTerminal.sol";
|
|
6
|
+
|
|
7
|
+
/// @notice Shared circular-terminal detection used by both `JBRouterTerminal` (execution) and
|
|
8
|
+
/// `JBPayRouteResolver` (preview).
|
|
9
|
+
library JBForwardingCheck {
|
|
10
|
+
/// @notice Whether routing through `terminal` would cycle back to `target` within 5 hops.
|
|
11
|
+
/// @param target The address to detect cycles against (typically the router).
|
|
12
|
+
/// @param projectId The project whose forwarding chain is being followed.
|
|
13
|
+
/// @param terminal The starting terminal to check.
|
|
14
|
+
/// @return isCircular True if the terminal forwards (directly or transitively) back to `target`.
|
|
15
|
+
function isCircularTerminal(
|
|
16
|
+
address target,
|
|
17
|
+
uint256 projectId,
|
|
18
|
+
IJBTerminal terminal
|
|
19
|
+
)
|
|
20
|
+
internal
|
|
21
|
+
view
|
|
22
|
+
returns (bool isCircular)
|
|
23
|
+
{
|
|
24
|
+
IJBTerminal current = terminal;
|
|
25
|
+
for (uint256 i; i < 5; i++) {
|
|
26
|
+
if (address(current) == target) return true;
|
|
27
|
+
|
|
28
|
+
// Probe via staticcall so plain terminals degrade cleanly.
|
|
29
|
+
// slither-disable-next-line calls-loop
|
|
30
|
+
(bool success, bytes memory data) =
|
|
31
|
+
address(current).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
|
|
32
|
+
|
|
33
|
+
// Non-forwarding terminals (call fails or returns zero) end the chain — not circular.
|
|
34
|
+
if (!success || data.length < 32) return false;
|
|
35
|
+
IJBTerminal forwardingTarget = abi.decode(data, (IJBTerminal));
|
|
36
|
+
if (address(forwardingTarget) == address(0)) return false;
|
|
37
|
+
|
|
38
|
+
current = forwardingTarget;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 5 hops without resolution — treat as circular to be safe.
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|