@bananapus/router-terminal-v6 0.0.28 → 0.0.30
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/RISKS.md +8 -0
- package/package.json +1 -1
- package/src/JBRouterTerminal.sol +46 -43
- package/test/RouterTerminalCreditCashout.t.sol +25 -5
- package/test/audit/CreditCashoutPreferredTokenBypass.t.sol +161 -0
- package/test/audit/CreditCashoutSpoofedPayer.t.sol +249 -0
- package/test/audit/PreviewCashOutShortcircuitDivergence.t.sol +206 -0
package/RISKS.md
CHANGED
|
@@ -103,3 +103,11 @@ The router and resolver no longer contain registry-specific circular-resolution
|
|
|
103
103
|
### 8.6 V4 spot fallback is an accepted risk for programmatic integrations
|
|
104
104
|
|
|
105
105
|
Automatic V4 quoting first tries a hook-provided oracle observation and falls back to spot only when that quote is unavailable. The fallback is still manipulable and is accepted only as a bounded on-chain quoting path for integrations that cannot provide `quoteForSwap` metadata.
|
|
106
|
+
|
|
107
|
+
### 8.7 Credit cash-outs only work when calling the router terminal directly
|
|
108
|
+
|
|
109
|
+
The credit-cashout path in `_acceptFundsFor` uses `holder = _msgSender()` — the direct caller — as the credit holder. This means credit cash-outs **do not work through the `JBRouterTerminalRegistry`**, because when the registry forwards a `pay()` call, `_msgSender()` inside the router terminal resolves to the registry's address, not the original user. The registry has no credits, so `transferCreditsFrom` would fail.
|
|
110
|
+
|
|
111
|
+
This is intentional. The previous design used `_resolveOriginalPayer(sender)` to recover the original user from the registry's transient `originalPayer` storage. However, any contract implementing `IJBPayerTracker.originalPayer()` could spoof a victim's address and steal their credits (H-24). The fix uses the direct sender unconditionally for credit transfers, closing the spoofing vector at the cost of registry-mediated credit flows.
|
|
112
|
+
|
|
113
|
+
Users who need to cash out credits through the router should call `JBRouterTerminal.pay()` directly with `cashOutSource` metadata, not through the registry.
|
package/package.json
CHANGED
package/src/JBRouterTerminal.sol
CHANGED
|
@@ -1052,9 +1052,9 @@ contract JBRouterTerminal is
|
|
|
1052
1052
|
|
|
1053
1053
|
(uint256 sourceProjectId, uint256 creditAmount) = abi.decode(creditData, (uint256, uint256));
|
|
1054
1054
|
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
address holder =
|
|
1055
|
+
// Use the direct sender as the credit holder. Do NOT resolve via _resolveOriginalPayer here,
|
|
1056
|
+
// because a malicious contract could spoof originalPayer() to steal another user's credits.
|
|
1057
|
+
address holder = sender;
|
|
1058
1058
|
|
|
1059
1059
|
// Pull credits through the project's controller, which enforces holder permissions for credit transfers.
|
|
1060
1060
|
IJBController controller = IJBController(address(DIRECTORY.controllerOf(sourceProjectId)));
|
|
@@ -1177,28 +1177,18 @@ contract JBRouterTerminal is
|
|
|
1177
1177
|
// Walk the cashout path hop by hop until we reach a directly acceptable destination asset or exhaust the
|
|
1178
1178
|
// bounded iteration limit.
|
|
1179
1179
|
for (uint256 i; i < _MAX_CASHOUT_ITERATIONS;) {
|
|
1180
|
-
//
|
|
1181
|
-
//
|
|
1182
|
-
if (
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
(token, amount) =
|
|
1180
|
+
// Skip the destination check on the first iteration if we have a credit override — the forced
|
|
1181
|
+
// cashout must happen before any early return.
|
|
1182
|
+
if (sourceProjectIdOverride == 0) {
|
|
1183
|
+
address routeToken;
|
|
1184
|
+
(destTerminal, routeToken) =
|
|
1185
|
+
_findRouteTerminal({destProjectId: destProjectId, token: token, preferredToken: preferredToken});
|
|
1186
|
+
if (address(destTerminal) != address(0)) {
|
|
1187
|
+
if (preferredToken != address(0)) {
|
|
1188
|
+
(routeToken, amount) =
|
|
1190
1189
|
_alignTokenToPreferredToken({token: token, amount: amount, preferredToken: preferredToken});
|
|
1191
|
-
return (destTerminal, token, amount);
|
|
1192
1190
|
}
|
|
1193
|
-
|
|
1194
|
-
}
|
|
1195
|
-
// Skip the destination check on the first iteration if we have a credit override.
|
|
1196
|
-
else if (sourceProjectIdOverride == 0) {
|
|
1197
|
-
// Ask the directory whether the destination already has a usable primary terminal for the current
|
|
1198
|
-
// token.
|
|
1199
|
-
destTerminal = _usablePrimaryTerminalOf({projectId: destProjectId, token: token});
|
|
1200
|
-
if (address(destTerminal) != address(0)) {
|
|
1201
|
-
return (destTerminal, token, amount);
|
|
1191
|
+
return (destTerminal, routeToken, amount);
|
|
1202
1192
|
}
|
|
1203
1193
|
}
|
|
1204
1194
|
|
|
@@ -1884,6 +1874,33 @@ contract JBRouterTerminal is
|
|
|
1884
1874
|
return super._contextSuffixLength();
|
|
1885
1875
|
}
|
|
1886
1876
|
|
|
1877
|
+
/// @notice Check whether a cashout route can complete at the current destination.
|
|
1878
|
+
/// @dev Shared by _cashOutLoop and _previewCashOutLoop to keep destination logic in sync.
|
|
1879
|
+
/// @param destProjectId The destination project.
|
|
1880
|
+
/// @param token The current token in the route.
|
|
1881
|
+
/// @param preferredToken The caller's preferred output token (or address(0) for none).
|
|
1882
|
+
/// @return terminal The usable terminal if a route was found, or IJBTerminal(address(0)).
|
|
1883
|
+
/// @return resultToken The token accepted by the terminal.
|
|
1884
|
+
function _findRouteTerminal(
|
|
1885
|
+
uint256 destProjectId,
|
|
1886
|
+
address token,
|
|
1887
|
+
address preferredToken
|
|
1888
|
+
)
|
|
1889
|
+
internal
|
|
1890
|
+
view
|
|
1891
|
+
returns (IJBTerminal terminal, address resultToken)
|
|
1892
|
+
{
|
|
1893
|
+
if (preferredToken != address(0)) {
|
|
1894
|
+
if (_hasSameRoutingAsset({tokenA: token, tokenB: preferredToken})) {
|
|
1895
|
+
terminal = _usablePrimaryTerminalOf({projectId: destProjectId, token: preferredToken});
|
|
1896
|
+
if (address(terminal) != address(0)) return (terminal, preferredToken);
|
|
1897
|
+
}
|
|
1898
|
+
} else {
|
|
1899
|
+
terminal = _usablePrimaryTerminalOf({projectId: destProjectId, token: token});
|
|
1900
|
+
if (address(terminal) != address(0)) return (terminal, token);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1887
1904
|
/// @notice Search Uniswap V3 and V4 for the best pool between two tokens.
|
|
1888
1905
|
/// @dev Returns the pool with the highest liquidity across both protocols.
|
|
1889
1906
|
/// @param normalizedTokenIn The input token (wrapped if native).
|
|
@@ -2499,26 +2516,12 @@ contract JBRouterTerminal is
|
|
|
2499
2516
|
|
|
2500
2517
|
// Walk the same cash-out path execution would take, bounded to prevent circular routes.
|
|
2501
2518
|
for (uint256 i; i < _MAX_CASHOUT_ITERATIONS;) {
|
|
2502
|
-
//
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
// If a real external terminal accepts that preferred asset, the preview route is complete.
|
|
2510
|
-
if (address(destTerminal) != address(0)) return (destTerminal, preferredToken, amount);
|
|
2511
|
-
}
|
|
2512
|
-
}
|
|
2513
|
-
// Only probe direct destination acceptance when there is no one-shot source override to consume first.
|
|
2514
|
-
else if (sourceProjectIdOverride == 0) {
|
|
2515
|
-
// Ask the directory whether the destination already has a primary terminal for the current token.
|
|
2516
|
-
destTerminal = _usablePrimaryTerminalOf({projectId: destProjectId, token: token});
|
|
2517
|
-
|
|
2518
|
-
// If a real external terminal accepts this token, the preview route is complete and exact.
|
|
2519
|
-
if (address(destTerminal) != address(0)) {
|
|
2520
|
-
return (destTerminal, token, amount);
|
|
2521
|
-
}
|
|
2519
|
+
// Skip destination checks when the forced first cashout hasn't happened yet (mirrors _cashOutLoop).
|
|
2520
|
+
if (sourceProjectIdOverride == 0) {
|
|
2521
|
+
address routeToken;
|
|
2522
|
+
(destTerminal, routeToken) =
|
|
2523
|
+
_findRouteTerminal({destProjectId: destProjectId, token: token, preferredToken: preferredToken});
|
|
2524
|
+
if (address(destTerminal) != address(0)) return (destTerminal, routeToken, amount);
|
|
2522
2525
|
}
|
|
2523
2526
|
|
|
2524
2527
|
// Use the override once when present; otherwise infer the source project from the current JB token.
|
|
@@ -405,8 +405,9 @@ contract CreditCashoutSpoofingIntermediary is IJBPayerTracker {
|
|
|
405
405
|
assertEq(decodedAmount, creditAmount, "decoded amount should match");
|
|
406
406
|
}
|
|
407
407
|
|
|
408
|
-
/// @notice
|
|
409
|
-
|
|
408
|
+
/// @notice After H-24 fix: credit cashouts through a payer tracker debit the intermediary (msg.sender),
|
|
409
|
+
/// not the spoofed originalPayer. This prevents credit theft.
|
|
410
|
+
function test_creditCashout_debitsIntermediaryNotSpoofedPayer() public {
|
|
410
411
|
address victim = makeAddr("victim");
|
|
411
412
|
address controller = makeAddr("controller");
|
|
412
413
|
uint256 destProjectId = 1;
|
|
@@ -429,17 +430,21 @@ contract CreditCashoutSpoofingIntermediary is IJBPayerTracker {
|
|
|
429
430
|
abi.encodeCall(IJBDirectory.controllerOf, (sourceProjectId)),
|
|
430
431
|
abi.encode(controller)
|
|
431
432
|
);
|
|
433
|
+
|
|
434
|
+
// After fix: transferCreditsFrom is called with the intermediary as holder, NOT the victim.
|
|
432
435
|
vm.mockCall(
|
|
433
436
|
controller,
|
|
434
437
|
abi.encodeCall(
|
|
435
|
-
IJBController.transferCreditsFrom,
|
|
438
|
+
IJBController.transferCreditsFrom,
|
|
439
|
+
(address(intermediary), sourceProjectId, address(routerTerminal), creditAmount)
|
|
436
440
|
),
|
|
437
441
|
abi.encode()
|
|
438
442
|
);
|
|
439
443
|
vm.expectCall(
|
|
440
444
|
controller,
|
|
441
445
|
abi.encodeCall(
|
|
442
|
-
IJBController.transferCreditsFrom,
|
|
446
|
+
IJBController.transferCreditsFrom,
|
|
447
|
+
(address(intermediary), sourceProjectId, address(routerTerminal), creditAmount)
|
|
443
448
|
)
|
|
444
449
|
);
|
|
445
450
|
|
|
@@ -720,7 +725,22 @@ contract CreditCashoutSpoofingIntermediary is IJBPayerTracker {
|
|
|
720
725
|
vm.mockCall(
|
|
721
726
|
mockCashOutTerminal,
|
|
722
727
|
abi.encodeWithSelector(IJBCashOutTerminal.previewCashOutFrom.selector),
|
|
723
|
-
abi.encode(
|
|
728
|
+
abi.encode(
|
|
729
|
+
JBRuleset({
|
|
730
|
+
cycleNumber: 1,
|
|
731
|
+
id: 1,
|
|
732
|
+
basedOnId: 0,
|
|
733
|
+
start: 0,
|
|
734
|
+
duration: 0,
|
|
735
|
+
weight: 0,
|
|
736
|
+
weightCutPercent: 0,
|
|
737
|
+
approvalHook: IJBRulesetApprovalHook(address(0)),
|
|
738
|
+
metadata: 0
|
|
739
|
+
}),
|
|
740
|
+
uint256(0),
|
|
741
|
+
uint256(0),
|
|
742
|
+
new JBCashOutHookSpecification[](0)
|
|
743
|
+
)
|
|
724
744
|
);
|
|
725
745
|
|
|
726
746
|
vm.mockCall(
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBCashOutTerminal} from "@bananapus/core-v6/src/interfaces/IJBCashOutTerminal.sol";
|
|
7
|
+
import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
|
|
8
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
9
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
10
|
+
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
11
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
12
|
+
import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
|
|
13
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
14
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
15
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
16
|
+
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
|
17
|
+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
|
|
18
|
+
|
|
19
|
+
import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
|
|
20
|
+
import {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
21
|
+
|
|
22
|
+
/// @title M26_CreditCashoutPreferredTokenBypass
|
|
23
|
+
/// @notice Before the fix, when sourceProjectIdOverride != 0 and preferredToken matched the
|
|
24
|
+
/// current token, the _cashOutLoop would short-circuit on the first iteration — returning
|
|
25
|
+
/// without cashing out. This test verifies the fix: the forced first cashout must happen.
|
|
26
|
+
contract M26_CreditCashoutPreferredTokenBypass is Test {
|
|
27
|
+
JBRouterTerminal routerTerminal;
|
|
28
|
+
|
|
29
|
+
IJBDirectory mockDirectory;
|
|
30
|
+
IJBTokens mockTokens;
|
|
31
|
+
|
|
32
|
+
address payer = makeAddr("payer");
|
|
33
|
+
address controller = makeAddr("controller");
|
|
34
|
+
address mockDestTerminal = makeAddr("destTerminal");
|
|
35
|
+
address mockCashOutTerminal = makeAddr("cashOutTerminal");
|
|
36
|
+
|
|
37
|
+
uint256 destProjectId = 1;
|
|
38
|
+
uint256 sourceProjectId = 2;
|
|
39
|
+
uint256 creditAmount = 100e18;
|
|
40
|
+
|
|
41
|
+
function setUp() public {
|
|
42
|
+
mockDirectory = IJBDirectory(makeAddr("mockDirectory"));
|
|
43
|
+
vm.etch(address(mockDirectory), hex"00");
|
|
44
|
+
mockTokens = IJBTokens(makeAddr("mockTokens"));
|
|
45
|
+
vm.etch(address(mockTokens), hex"00");
|
|
46
|
+
vm.etch(controller, hex"00");
|
|
47
|
+
vm.etch(mockDestTerminal, hex"00");
|
|
48
|
+
vm.etch(mockCashOutTerminal, hex"00");
|
|
49
|
+
|
|
50
|
+
IPermit2 mockPermit2 = IPermit2(makeAddr("mockPermit2"));
|
|
51
|
+
vm.etch(address(mockPermit2), hex"00");
|
|
52
|
+
IWETH9 mockWeth = IWETH9(makeAddr("mockWeth"));
|
|
53
|
+
vm.etch(address(mockWeth), hex"00");
|
|
54
|
+
IUniswapV3Factory mockFactory = IUniswapV3Factory(makeAddr("mockFactory"));
|
|
55
|
+
vm.etch(address(mockFactory), hex"00");
|
|
56
|
+
IPoolManager mockPoolManager = IPoolManager(makeAddr("mockPoolManager"));
|
|
57
|
+
vm.etch(address(mockPoolManager), hex"00");
|
|
58
|
+
|
|
59
|
+
routerTerminal = new JBRouterTerminal(
|
|
60
|
+
mockDirectory,
|
|
61
|
+
mockTokens,
|
|
62
|
+
mockPermit2,
|
|
63
|
+
mockWeth,
|
|
64
|
+
mockFactory,
|
|
65
|
+
mockPoolManager,
|
|
66
|
+
address(0),
|
|
67
|
+
address(0),
|
|
68
|
+
address(0)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// @notice With the fix, credit cashout with sourceProjectIdOverride and a preferred token
|
|
73
|
+
/// that matches the token must still perform at least one cashout hop.
|
|
74
|
+
function test_creditCashout_forcedHopWithPreferredToken() public {
|
|
75
|
+
// Build metadata: cashOutSource with sourceProjectId + creditAmount,
|
|
76
|
+
// and cashOutPreferredToken with NATIVE_TOKEN.
|
|
77
|
+
bytes memory metadata;
|
|
78
|
+
{
|
|
79
|
+
bytes4 cashOutSourceId = JBMetadataResolver.getId("cashOutSource", address(routerTerminal));
|
|
80
|
+
metadata = JBMetadataResolver.addToMetadata("", cashOutSourceId, abi.encode(sourceProjectId, creditAmount));
|
|
81
|
+
|
|
82
|
+
bytes4 preferredTokenId = JBMetadataResolver.getId("cashOutPreferredToken", address(routerTerminal));
|
|
83
|
+
metadata =
|
|
84
|
+
JBMetadataResolver.addToMetadata(metadata, preferredTokenId, abi.encode(JBConstants.NATIVE_TOKEN));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Mock: controller lookup for sourceProjectId.
|
|
88
|
+
vm.mockCall(
|
|
89
|
+
address(mockDirectory), abi.encodeCall(IJBDirectory.controllerOf, (sourceProjectId)), abi.encode(controller)
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Mock: controller.transferCreditsFrom.
|
|
93
|
+
vm.mockCall(
|
|
94
|
+
controller,
|
|
95
|
+
abi.encodeCall(
|
|
96
|
+
IJBController.transferCreditsFrom, (payer, sourceProjectId, address(routerTerminal), creditAmount)
|
|
97
|
+
),
|
|
98
|
+
abi.encode()
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// Dest project accepts NATIVE_TOKEN.
|
|
102
|
+
vm.mockCall(
|
|
103
|
+
address(mockDirectory),
|
|
104
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (destProjectId, JBConstants.NATIVE_TOKEN)),
|
|
105
|
+
abi.encode(mockDestTerminal)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// Source project's terminal list.
|
|
109
|
+
{
|
|
110
|
+
IJBTerminal[] memory sourceTerminals = new IJBTerminal[](1);
|
|
111
|
+
sourceTerminals[0] = IJBTerminal(mockCashOutTerminal);
|
|
112
|
+
vm.mockCall(
|
|
113
|
+
address(mockDirectory),
|
|
114
|
+
abi.encodeCall(IJBDirectory.terminalsOf, (sourceProjectId)),
|
|
115
|
+
abi.encode(sourceTerminals)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Mock supportsInterface for IJBCashOutTerminal.
|
|
120
|
+
vm.mockCall(
|
|
121
|
+
mockCashOutTerminal,
|
|
122
|
+
abi.encodeCall(IERC165.supportsInterface, (type(IJBCashOutTerminal).interfaceId)),
|
|
123
|
+
abi.encode(true)
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Accounting context: source project terminal accepts NATIVE_TOKEN.
|
|
127
|
+
{
|
|
128
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
129
|
+
contexts[0] = JBAccountingContext({
|
|
130
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
131
|
+
});
|
|
132
|
+
vm.mockCall(
|
|
133
|
+
mockCashOutTerminal,
|
|
134
|
+
abi.encodeCall(IJBTerminal.accountingContextsOf, (sourceProjectId)),
|
|
135
|
+
abi.encode(contexts)
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Mock cashOutTokensOf: returns 60 ETH reclaimed.
|
|
140
|
+
vm.mockCall(
|
|
141
|
+
mockCashOutTerminal,
|
|
142
|
+
abi.encodeWithSelector(IJBCashOutTerminal.cashOutTokensOf.selector),
|
|
143
|
+
abi.encode(uint256(60e18))
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// The cashout terminal sends ETH to the router.
|
|
147
|
+
vm.deal(address(routerTerminal), 60e18);
|
|
148
|
+
|
|
149
|
+
// Mock dest terminal pay.
|
|
150
|
+
vm.mockCall(mockDestTerminal, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(500)));
|
|
151
|
+
|
|
152
|
+
// The key assertion: cashOutTokensOf MUST be called on the source terminal.
|
|
153
|
+
// Before the fix, the preferred-token short-circuit would skip this call entirely.
|
|
154
|
+
vm.expectCall(mockCashOutTerminal, abi.encodeWithSelector(IJBCashOutTerminal.cashOutTokensOf.selector));
|
|
155
|
+
|
|
156
|
+
vm.prank(payer);
|
|
157
|
+
uint256 result = routerTerminal.pay(destProjectId, JBConstants.NATIVE_TOKEN, 0, payer, 0, "", metadata);
|
|
158
|
+
|
|
159
|
+
assertEq(result, 500, "pay should return dest terminal token count");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBCashOutTerminal} from "@bananapus/core-v6/src/interfaces/IJBCashOutTerminal.sol";
|
|
7
|
+
import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
|
|
8
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
9
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
10
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
11
|
+
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
12
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
13
|
+
import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
|
|
14
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
15
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
16
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
17
|
+
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
|
18
|
+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
|
|
19
|
+
|
|
20
|
+
import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
|
|
21
|
+
import {IJBPayerTracker} from "../../src/interfaces/IJBPayerTracker.sol";
|
|
22
|
+
import {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
23
|
+
|
|
24
|
+
/// @notice Attacker contract that spoofs originalPayer() to point at a victim.
|
|
25
|
+
contract AttackerSpoofingPayer is IJBPayerTracker {
|
|
26
|
+
address public override originalPayer;
|
|
27
|
+
|
|
28
|
+
constructor(address victim) {
|
|
29
|
+
originalPayer = victim;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function attackViaPay(
|
|
33
|
+
JBRouterTerminal router,
|
|
34
|
+
uint256 projectId,
|
|
35
|
+
bytes calldata metadata
|
|
36
|
+
)
|
|
37
|
+
external
|
|
38
|
+
returns (uint256)
|
|
39
|
+
{
|
|
40
|
+
return router.pay(projectId, JBConstants.NATIVE_TOKEN, 0, address(this), 0, "", metadata);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// @notice H-24: Verify that spoofing originalPayer() cannot steal another user's credits.
|
|
45
|
+
contract CreditCashoutSpoofedPayerTest is Test {
|
|
46
|
+
JBRouterTerminal routerTerminal;
|
|
47
|
+
|
|
48
|
+
IJBDirectory mockDirectory;
|
|
49
|
+
IJBPermissions mockPermissions;
|
|
50
|
+
IJBTokens mockTokens;
|
|
51
|
+
IPermit2 mockPermit2;
|
|
52
|
+
IWETH9 mockWeth;
|
|
53
|
+
IUniswapV3Factory mockFactory;
|
|
54
|
+
IPoolManager mockPoolManager;
|
|
55
|
+
|
|
56
|
+
function setUp() public {
|
|
57
|
+
mockDirectory = IJBDirectory(makeAddr("mockDirectory"));
|
|
58
|
+
vm.etch(address(mockDirectory), hex"00");
|
|
59
|
+
mockPermissions = IJBPermissions(makeAddr("mockPermissions"));
|
|
60
|
+
vm.etch(address(mockPermissions), hex"00");
|
|
61
|
+
mockTokens = IJBTokens(makeAddr("mockTokens"));
|
|
62
|
+
vm.etch(address(mockTokens), hex"00");
|
|
63
|
+
mockPermit2 = IPermit2(makeAddr("mockPermit2"));
|
|
64
|
+
vm.etch(address(mockPermit2), hex"00");
|
|
65
|
+
mockWeth = IWETH9(makeAddr("mockWeth"));
|
|
66
|
+
vm.etch(address(mockWeth), hex"00");
|
|
67
|
+
mockFactory = IUniswapV3Factory(makeAddr("mockFactory"));
|
|
68
|
+
vm.etch(address(mockFactory), hex"00");
|
|
69
|
+
mockPoolManager = IPoolManager(makeAddr("mockPoolManager"));
|
|
70
|
+
vm.etch(address(mockPoolManager), hex"00");
|
|
71
|
+
|
|
72
|
+
routerTerminal = new JBRouterTerminal(
|
|
73
|
+
mockDirectory,
|
|
74
|
+
mockTokens,
|
|
75
|
+
mockPermit2,
|
|
76
|
+
mockWeth,
|
|
77
|
+
mockFactory,
|
|
78
|
+
mockPoolManager,
|
|
79
|
+
address(0),
|
|
80
|
+
address(0),
|
|
81
|
+
address(0)
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// @notice An attacker contract spoofing originalPayer() should have its own credits debited, not the victim's.
|
|
86
|
+
function test_spoofedOriginalPayer_debitsAttackerNotVictim() public {
|
|
87
|
+
address victim = makeAddr("victim");
|
|
88
|
+
address controller = makeAddr("controller");
|
|
89
|
+
uint256 destProjectId = 1;
|
|
90
|
+
uint256 sourceProjectId = 2;
|
|
91
|
+
uint256 creditAmount = 100e18;
|
|
92
|
+
address mockDestTerminal = makeAddr("destTerminal");
|
|
93
|
+
address mockCashOutTerminal = makeAddr("cashOutTerminal");
|
|
94
|
+
vm.etch(controller, hex"00");
|
|
95
|
+
vm.etch(mockDestTerminal, hex"00");
|
|
96
|
+
vm.etch(mockCashOutTerminal, hex"00");
|
|
97
|
+
|
|
98
|
+
// Attacker contract spoofs originalPayer() to return victim's address.
|
|
99
|
+
AttackerSpoofingPayer attacker = new AttackerSpoofingPayer(victim);
|
|
100
|
+
|
|
101
|
+
bytes4 metadataId = JBMetadataResolver.getId("cashOutSource", address(routerTerminal));
|
|
102
|
+
bytes memory metadata =
|
|
103
|
+
JBMetadataResolver.addToMetadata("", metadataId, abi.encode(sourceProjectId, creditAmount));
|
|
104
|
+
|
|
105
|
+
vm.mockCall(
|
|
106
|
+
address(mockDirectory),
|
|
107
|
+
abi.encodeCall(IJBDirectory.controllerOf, (sourceProjectId)),
|
|
108
|
+
abi.encode(controller)
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// The key assertion: transferCreditsFrom is called with the ATTACKER as holder, NOT the victim.
|
|
112
|
+
vm.mockCall(
|
|
113
|
+
controller,
|
|
114
|
+
abi.encodeCall(
|
|
115
|
+
IJBController.transferCreditsFrom,
|
|
116
|
+
(address(attacker), sourceProjectId, address(routerTerminal), creditAmount)
|
|
117
|
+
),
|
|
118
|
+
abi.encode()
|
|
119
|
+
);
|
|
120
|
+
vm.expectCall(
|
|
121
|
+
controller,
|
|
122
|
+
abi.encodeCall(
|
|
123
|
+
IJBController.transferCreditsFrom,
|
|
124
|
+
(address(attacker), sourceProjectId, address(routerTerminal), creditAmount)
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
vm.mockCall(
|
|
129
|
+
address(mockDirectory),
|
|
130
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (destProjectId, JBConstants.NATIVE_TOKEN)),
|
|
131
|
+
abi.encode(mockDestTerminal)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
IJBTerminal[] memory sourceTerminals = new IJBTerminal[](1);
|
|
135
|
+
sourceTerminals[0] = IJBTerminal(mockCashOutTerminal);
|
|
136
|
+
vm.mockCall(
|
|
137
|
+
address(mockDirectory),
|
|
138
|
+
abi.encodeCall(IJBDirectory.terminalsOf, (sourceProjectId)),
|
|
139
|
+
abi.encode(sourceTerminals)
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
vm.mockCall(
|
|
143
|
+
mockCashOutTerminal,
|
|
144
|
+
abi.encodeCall(IERC165.supportsInterface, (type(IJBCashOutTerminal).interfaceId)),
|
|
145
|
+
abi.encode(true)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
149
|
+
contexts[0] = JBAccountingContext({
|
|
150
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
151
|
+
});
|
|
152
|
+
vm.mockCall(
|
|
153
|
+
mockCashOutTerminal,
|
|
154
|
+
abi.encodeCall(IJBTerminal.accountingContextsOf, (sourceProjectId)),
|
|
155
|
+
abi.encode(contexts)
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
vm.mockCall(
|
|
159
|
+
mockCashOutTerminal,
|
|
160
|
+
abi.encodeWithSelector(IJBCashOutTerminal.cashOutTokensOf.selector),
|
|
161
|
+
abi.encode(uint256(60e18))
|
|
162
|
+
);
|
|
163
|
+
vm.deal(address(routerTerminal), 60e18);
|
|
164
|
+
vm.mockCall(mockDestTerminal, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(500)));
|
|
165
|
+
|
|
166
|
+
attacker.attackViaPay(routerTerminal, destProjectId, metadata);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// @notice Direct EOA credit cashouts still work — the holder is the EOA itself.
|
|
170
|
+
function test_directCreditCashout_stillWorks() public {
|
|
171
|
+
address payer = makeAddr("payer");
|
|
172
|
+
address controller = makeAddr("controller");
|
|
173
|
+
uint256 destProjectId = 1;
|
|
174
|
+
uint256 sourceProjectId = 2;
|
|
175
|
+
uint256 creditAmount = 50e18;
|
|
176
|
+
address mockDestTerminal = makeAddr("destTerminal");
|
|
177
|
+
address mockCashOutTerminal = makeAddr("cashOutTerminal");
|
|
178
|
+
vm.etch(controller, hex"00");
|
|
179
|
+
vm.etch(mockDestTerminal, hex"00");
|
|
180
|
+
vm.etch(mockCashOutTerminal, hex"00");
|
|
181
|
+
|
|
182
|
+
bytes4 metadataId = JBMetadataResolver.getId("cashOutSource", address(routerTerminal));
|
|
183
|
+
bytes memory metadata =
|
|
184
|
+
JBMetadataResolver.addToMetadata("", metadataId, abi.encode(sourceProjectId, creditAmount));
|
|
185
|
+
|
|
186
|
+
vm.mockCall(
|
|
187
|
+
address(mockDirectory),
|
|
188
|
+
abi.encodeCall(IJBDirectory.controllerOf, (sourceProjectId)),
|
|
189
|
+
abi.encode(controller)
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// EOA payer is the holder — direct call, no intermediary.
|
|
193
|
+
vm.mockCall(
|
|
194
|
+
controller,
|
|
195
|
+
abi.encodeCall(
|
|
196
|
+
IJBController.transferCreditsFrom, (payer, sourceProjectId, address(routerTerminal), creditAmount)
|
|
197
|
+
),
|
|
198
|
+
abi.encode()
|
|
199
|
+
);
|
|
200
|
+
vm.expectCall(
|
|
201
|
+
controller,
|
|
202
|
+
abi.encodeCall(
|
|
203
|
+
IJBController.transferCreditsFrom, (payer, sourceProjectId, address(routerTerminal), creditAmount)
|
|
204
|
+
)
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
vm.mockCall(
|
|
208
|
+
address(mockDirectory),
|
|
209
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (destProjectId, JBConstants.NATIVE_TOKEN)),
|
|
210
|
+
abi.encode(mockDestTerminal)
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
IJBTerminal[] memory sourceTerminals = new IJBTerminal[](1);
|
|
214
|
+
sourceTerminals[0] = IJBTerminal(mockCashOutTerminal);
|
|
215
|
+
vm.mockCall(
|
|
216
|
+
address(mockDirectory),
|
|
217
|
+
abi.encodeCall(IJBDirectory.terminalsOf, (sourceProjectId)),
|
|
218
|
+
abi.encode(sourceTerminals)
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
vm.mockCall(
|
|
222
|
+
mockCashOutTerminal,
|
|
223
|
+
abi.encodeCall(IERC165.supportsInterface, (type(IJBCashOutTerminal).interfaceId)),
|
|
224
|
+
abi.encode(true)
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
228
|
+
contexts[0] = JBAccountingContext({
|
|
229
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
230
|
+
});
|
|
231
|
+
vm.mockCall(
|
|
232
|
+
mockCashOutTerminal,
|
|
233
|
+
abi.encodeCall(IJBTerminal.accountingContextsOf, (sourceProjectId)),
|
|
234
|
+
abi.encode(contexts)
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
vm.mockCall(
|
|
238
|
+
mockCashOutTerminal,
|
|
239
|
+
abi.encodeWithSelector(IJBCashOutTerminal.cashOutTokensOf.selector),
|
|
240
|
+
abi.encode(uint256(30e18))
|
|
241
|
+
);
|
|
242
|
+
vm.deal(address(routerTerminal), 30e18);
|
|
243
|
+
vm.mockCall(mockDestTerminal, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(200)));
|
|
244
|
+
|
|
245
|
+
vm.prank(payer);
|
|
246
|
+
uint256 result = routerTerminal.pay(destProjectId, JBConstants.NATIVE_TOKEN, 0, payer, 0, "", metadata);
|
|
247
|
+
assertEq(result, 200);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBCashOutTerminal} from "@bananapus/core-v6/src/interfaces/IJBCashOutTerminal.sol";
|
|
7
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
8
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
9
|
+
import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
|
|
10
|
+
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
11
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
12
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
13
|
+
import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
|
|
14
|
+
import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
|
|
15
|
+
import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
|
|
16
|
+
import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
|
|
17
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
18
|
+
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
|
19
|
+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
|
|
20
|
+
|
|
21
|
+
import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
|
|
22
|
+
import {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
23
|
+
|
|
24
|
+
/// @title M30_PreviewCashOutShortcircuitDivergence
|
|
25
|
+
/// @notice Before the fix, `_previewCashOutLoop` could short-circuit on the preferred-token check even when
|
|
26
|
+
/// `sourceProjectIdOverride != 0`, skipping the forced first cashout hop. The execution path
|
|
27
|
+
/// (`_cashOutLoop`) correctly gates that short-circuit behind `sourceProjectIdOverride == 0`.
|
|
28
|
+
/// This test verifies that the preview now mirrors execution: the forced hop is taken before any
|
|
29
|
+
/// preferred-token early return.
|
|
30
|
+
contract M30_PreviewCashOutShortcircuitDivergence is Test {
|
|
31
|
+
JBRouterTerminal internal router;
|
|
32
|
+
|
|
33
|
+
IJBDirectory internal mockDirectory;
|
|
34
|
+
IJBTokens internal mockTokens;
|
|
35
|
+
|
|
36
|
+
address internal mockCashOutTerminal = makeAddr("cashOutTerminal");
|
|
37
|
+
address internal mockDestTerminal = makeAddr("destTerminal");
|
|
38
|
+
|
|
39
|
+
uint256 internal destProjectId = 1;
|
|
40
|
+
uint256 internal sourceProjectId = 2;
|
|
41
|
+
uint256 internal cashOutAmount = 100e18;
|
|
42
|
+
uint256 internal reclaimAmount = 60e18;
|
|
43
|
+
|
|
44
|
+
function setUp() public {
|
|
45
|
+
mockDirectory = IJBDirectory(makeAddr("directory"));
|
|
46
|
+
mockTokens = IJBTokens(makeAddr("tokens"));
|
|
47
|
+
|
|
48
|
+
vm.etch(address(mockDirectory), hex"00");
|
|
49
|
+
vm.etch(address(mockTokens), hex"00");
|
|
50
|
+
vm.etch(mockCashOutTerminal, hex"00");
|
|
51
|
+
vm.etch(mockDestTerminal, hex"00");
|
|
52
|
+
|
|
53
|
+
IPermit2 mockPermit2 = IPermit2(makeAddr("permit2"));
|
|
54
|
+
vm.etch(address(mockPermit2), hex"00");
|
|
55
|
+
IWETH9 mockWeth = IWETH9(makeAddr("weth"));
|
|
56
|
+
vm.etch(address(mockWeth), hex"00");
|
|
57
|
+
IUniswapV3Factory mockFactory = IUniswapV3Factory(makeAddr("factory"));
|
|
58
|
+
vm.etch(address(mockFactory), hex"00");
|
|
59
|
+
IPoolManager mockPoolManager = IPoolManager(makeAddr("poolManager"));
|
|
60
|
+
vm.etch(address(mockPoolManager), hex"00");
|
|
61
|
+
|
|
62
|
+
router = new JBRouterTerminal({
|
|
63
|
+
directory: mockDirectory,
|
|
64
|
+
tokens: mockTokens,
|
|
65
|
+
permit2: mockPermit2,
|
|
66
|
+
weth: mockWeth,
|
|
67
|
+
factory: mockFactory,
|
|
68
|
+
poolManager: mockPoolManager,
|
|
69
|
+
buybackHook: address(0),
|
|
70
|
+
univ4Hook: address(0),
|
|
71
|
+
trustedForwarder: address(0)
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// --- Mock: source project terminal list ---
|
|
75
|
+
IJBTerminal[] memory sourceTerminals = new IJBTerminal[](1);
|
|
76
|
+
sourceTerminals[0] = IJBTerminal(mockCashOutTerminal);
|
|
77
|
+
vm.mockCall(
|
|
78
|
+
address(mockDirectory),
|
|
79
|
+
abi.encodeCall(IJBDirectory.terminalsOf, (sourceProjectId)),
|
|
80
|
+
abi.encode(sourceTerminals)
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// --- Mock: IJBCashOutTerminal support ---
|
|
84
|
+
vm.mockCall(
|
|
85
|
+
mockCashOutTerminal,
|
|
86
|
+
abi.encodeCall(IERC165.supportsInterface, (type(IJBCashOutTerminal).interfaceId)),
|
|
87
|
+
abi.encode(true)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// --- Mock: source terminal accounting contexts (accepts NATIVE_TOKEN) ---
|
|
91
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
92
|
+
contexts[0] = JBAccountingContext({
|
|
93
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
94
|
+
});
|
|
95
|
+
vm.mockCall(
|
|
96
|
+
mockCashOutTerminal,
|
|
97
|
+
abi.encodeCall(IJBTerminal.accountingContextsOf, (sourceProjectId)),
|
|
98
|
+
abi.encode(contexts)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
// --- Mock: dest project accepts NATIVE_TOKEN via destTerminal ---
|
|
102
|
+
vm.mockCall(
|
|
103
|
+
address(mockDirectory),
|
|
104
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (destProjectId, JBConstants.NATIVE_TOKEN)),
|
|
105
|
+
abi.encode(mockDestTerminal)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
// --- Mock: previewCashOutFrom returns reclaimAmount ---
|
|
109
|
+
JBRuleset memory ruleset = JBRuleset({
|
|
110
|
+
cycleNumber: 1,
|
|
111
|
+
id: 1,
|
|
112
|
+
basedOnId: 0,
|
|
113
|
+
start: 0,
|
|
114
|
+
duration: 0,
|
|
115
|
+
weight: 0,
|
|
116
|
+
weightCutPercent: 0,
|
|
117
|
+
approvalHook: IJBRulesetApprovalHook(address(0)),
|
|
118
|
+
metadata: 0
|
|
119
|
+
});
|
|
120
|
+
JBCashOutHookSpecification[] memory emptyHooks = new JBCashOutHookSpecification[](0);
|
|
121
|
+
vm.mockCall(
|
|
122
|
+
mockCashOutTerminal,
|
|
123
|
+
abi.encodeWithSelector(IJBCashOutTerminal.previewCashOutFrom.selector),
|
|
124
|
+
abi.encode(ruleset, reclaimAmount, uint256(0), emptyHooks)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// --- Mock: tokens.projectIdOf returns 0 for NATIVE_TOKEN (not a JB project token) ---
|
|
128
|
+
vm.mockCall(
|
|
129
|
+
address(mockTokens),
|
|
130
|
+
abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(JBConstants.NATIVE_TOKEN))),
|
|
131
|
+
abi.encode(uint256(0))
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// @notice When sourceProjectIdOverride is set and the preferred token matches the current token, the preview
|
|
136
|
+
/// must NOT short-circuit. It must take the forced cashout hop, then resolve the destination terminal.
|
|
137
|
+
function test_previewDoesNotShortcircuitWithSourceOverride() public view {
|
|
138
|
+
// The token being cashed out is a JB project token (represented by address(0xABC) for this test).
|
|
139
|
+
// The preferred token is NATIVE_TOKEN, and the source terminal reclaims NATIVE_TOKEN.
|
|
140
|
+
// With sourceProjectIdOverride != 0, the preview must NOT short-circuit even though preferredToken
|
|
141
|
+
// matches the reclaim token. It must perform the hop first.
|
|
142
|
+
|
|
143
|
+
// Call previewCashOutLoopOf with:
|
|
144
|
+
// - token = NATIVE_TOKEN (would match preferredToken immediately without the fix)
|
|
145
|
+
// - sourceProjectIdOverride = sourceProjectId (forces a cashout hop)
|
|
146
|
+
// - preferredToken = NATIVE_TOKEN
|
|
147
|
+
(IJBTerminal destTerminal, address finalToken, uint256 finalAmount) = router.previewCashOutLoopOf({
|
|
148
|
+
destProjectId: destProjectId,
|
|
149
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
150
|
+
amount: cashOutAmount,
|
|
151
|
+
sourceProjectIdOverride: sourceProjectId,
|
|
152
|
+
metadata: "",
|
|
153
|
+
preferredToken: JBConstants.NATIVE_TOKEN
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// The preview must have performed the cashout hop, so the final amount should be the reclaim amount,
|
|
157
|
+
// not the original cashOutAmount.
|
|
158
|
+
assertEq(finalAmount, reclaimAmount, "preview must perform the forced cashout hop, not short-circuit");
|
|
159
|
+
assertEq(finalToken, JBConstants.NATIVE_TOKEN, "final token should be NATIVE_TOKEN after the hop");
|
|
160
|
+
assertEq(address(destTerminal), mockDestTerminal, "preview should resolve the dest terminal after the hop");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// @notice When sourceProjectIdOverride is 0 and the preferred token matches, the preview SHOULD
|
|
164
|
+
/// short-circuit immediately (no cashout hop needed).
|
|
165
|
+
function test_previewShortcircuitsWithoutSourceOverride() public view {
|
|
166
|
+
// With sourceProjectIdOverride == 0, the preferred-token check should fire immediately.
|
|
167
|
+
(IJBTerminal destTerminal, address finalToken, uint256 finalAmount) = router.previewCashOutLoopOf({
|
|
168
|
+
destProjectId: destProjectId,
|
|
169
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
170
|
+
amount: cashOutAmount,
|
|
171
|
+
sourceProjectIdOverride: 0,
|
|
172
|
+
metadata: "",
|
|
173
|
+
preferredToken: JBConstants.NATIVE_TOKEN
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// The preview should short-circuit: amount unchanged, terminal resolved directly.
|
|
177
|
+
assertEq(finalAmount, cashOutAmount, "preview should short-circuit and return original amount");
|
|
178
|
+
assertEq(finalToken, JBConstants.NATIVE_TOKEN, "final token should be the preferred token");
|
|
179
|
+
assertEq(address(destTerminal), mockDestTerminal, "dest terminal should be resolved immediately");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/// @notice After sourceProjectIdOverride is consumed on the first hop, the second iteration should
|
|
183
|
+
/// be able to short-circuit via the preferred-token check.
|
|
184
|
+
function test_previewShortcircuitsAfterOverrideConsumed() public view {
|
|
185
|
+
// Set up a scenario where the first hop reclaims NATIVE_TOKEN, then the second iteration
|
|
186
|
+
// should short-circuit because sourceProjectIdOverride is now 0 and preferredToken matches.
|
|
187
|
+
// This is exactly the same as test_previewDoesNotShortcircuitWithSourceOverride — the forced
|
|
188
|
+
// hop happens on iteration 0, then iteration 1 should short-circuit because the reclaimed
|
|
189
|
+
// NATIVE_TOKEN matches the preferred NATIVE_TOKEN and sourceProjectIdOverride is now 0.
|
|
190
|
+
|
|
191
|
+
(IJBTerminal destTerminal, address finalToken, uint256 finalAmount) = router.previewCashOutLoopOf({
|
|
192
|
+
destProjectId: destProjectId,
|
|
193
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
194
|
+
amount: cashOutAmount,
|
|
195
|
+
sourceProjectIdOverride: sourceProjectId,
|
|
196
|
+
metadata: "",
|
|
197
|
+
preferredToken: JBConstants.NATIVE_TOKEN
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// After the forced hop (iteration 0), the override is consumed. On iteration 1, the reclaimed
|
|
201
|
+
// NATIVE_TOKEN matches the preferred token, so the loop short-circuits with the reclaim amount.
|
|
202
|
+
assertEq(finalAmount, reclaimAmount, "second iteration should short-circuit after override consumed");
|
|
203
|
+
assertEq(address(destTerminal), mockDestTerminal, "should resolve dest terminal on second iteration");
|
|
204
|
+
assertEq(finalToken, JBConstants.NATIVE_TOKEN, "final token should be the preferred token");
|
|
205
|
+
}
|
|
206
|
+
}
|