@bananapus/router-terminal-v6 0.0.32 → 0.0.33
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 +5 -0
- package/package.json +1 -1
- package/src/JBPayRouteResolver.sol +5 -26
- package/src/JBRouterTerminal.sol +17 -27
- package/src/libraries/JBForwardingCheck.sol +44 -0
- package/test/RouterTerminal.t.sol +3 -3
- package/test/RouterTerminalCreditCashout.t.sol +4 -2
- package/test/audit/CodexNemesisPayHookReceiptDoS.t.sol +169 -0
- package/test/audit/HookDataEncoding.t.sol +154 -0
- package/test/audit/MultiHopCashOutCycle.t.sol +350 -0
- package/test/audit/Pass12M39.t.sol +202 -0
- package/test/audit/Pass13RouterFixes.t.sol +141 -0
- package/test/{codex → audit}/RegistryForwardingLossyToken.t.sol +13 -4
- package/test/{codex → audit}/RouterRegistryReceiptMismatch.t.sol +8 -8
- package/test/fork/RouterTerminalFOTFork.t.sol +5 -8
- package/test/regression/CashOutLoopLimit.t.sol +85 -15
- /package/test/{codex → audit}/CashOutCircularPrimaryTerminal.t.sol +0 -0
- /package/test/{codex → audit}/CashOutFallbackPrefersRecursiveLoop.t.sol +0 -0
- /package/test/{codex → audit}/RegistrySelfLockDoS.t.sol +0 -0
- /package/test/{codex → audit}/V4HookedPoolIgnored.t.sol +0 -0
- /package/test/{codex → audit}/V4WethInputUsesStuckEth.t.sol +0 -0
package/RISKS.md
CHANGED
|
@@ -35,6 +35,11 @@ When payments flow through the registry, credits accrue to the registry address,
|
|
|
35
35
|
**Forwarder claim disables receipt check.** *(Minor)*
|
|
36
36
|
Forwarding terminals registered by project owners are trusted to handle receipts correctly, so receipt validation is skipped for these callers.
|
|
37
37
|
|
|
38
|
+
## Token Compatibility Risks
|
|
39
|
+
|
|
40
|
+
**Fee-on-transfer (FOT) tokens not supported for routed payments.** *(Medium)*
|
|
41
|
+
The `pay()` flow does not enforce an ERC-20 receipt check (balance-delta validation) on the destination terminal. This was intentionally removed because pay hooks attached to the destination terminal can legitimately consume tokens during `pay()`, making a balance-delta check produce false reverts for any project with active pay hooks. As a consequence, fee-on-transfer tokens will silently lose value during routing — the terminal receives fewer tokens than `amount` but the router cannot detect this. Projects using FOT tokens should route payments directly to the terminal, bypassing the router. The `addToBalanceOf()` flow retains receipt enforcement since it has no hooks.
|
|
42
|
+
|
|
38
43
|
## Minor Configuration Risks
|
|
39
44
|
|
|
40
45
|
**Unbounded quadratic candidate enumeration.** *(Minor)*
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingCo
|
|
|
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
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {JBForwardingCheck} from "./libraries/JBForwardingCheck.sol";
|
|
14
14
|
import {IJBPayRoutePreviewer} from "./interfaces/IJBPayRoutePreviewer.sol";
|
|
15
15
|
import {IJBPayRouteResolver} from "./interfaces/IJBPayRouteResolver.sol";
|
|
16
16
|
import {IWETH9} from "./interfaces/IWETH9.sol";
|
|
@@ -340,7 +340,9 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
340
340
|
return _normalizedTokenOf(tokenA) == _normalizedTokenOf(tokenB);
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
-
/// @notice Whether previewing through a terminal would
|
|
343
|
+
/// @notice Whether previewing through a terminal would cycle back into the router.
|
|
344
|
+
/// @dev Delegates to `JBForwardingCheck.isCircularTerminal` — shared with `JBRouterTerminal` so that
|
|
345
|
+
/// preview and execution use identical cycle-detection logic.
|
|
344
346
|
/// @param router The router whose preview path is being evaluated.
|
|
345
347
|
/// @param projectId The project whose forwarding terminal would be resolved.
|
|
346
348
|
/// @param terminal The terminal that would receive the previewed route.
|
|
@@ -354,30 +356,7 @@ contract JBPayRouteResolver is IJBPayRouteResolver {
|
|
|
354
356
|
view
|
|
355
357
|
returns (bool isCircular)
|
|
356
358
|
{
|
|
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;
|
|
359
|
+
return JBForwardingCheck.isCircularTerminal({target: address(router), projectId: projectId, terminal: terminal});
|
|
381
360
|
}
|
|
382
361
|
|
|
383
362
|
/// @notice Normalize a token into the form the router uses for routing comparisons.
|
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";
|
|
@@ -356,11 +357,6 @@ contract JBRouterTerminal is
|
|
|
356
357
|
// native.
|
|
357
358
|
uint256 payValue = _beforeTransferFor({to: address(destTerminal), token: token, amount: amount});
|
|
358
359
|
|
|
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
360
|
// Execute the final payment on the destination terminal and bubble back the beneficiary token count it
|
|
365
361
|
// returned.
|
|
366
362
|
beneficiaryTokenCount = destTerminal.pay{value: payValue}({
|
|
@@ -375,14 +371,11 @@ contract JBRouterTerminal is
|
|
|
375
371
|
|
|
376
372
|
_afterTransferFor({destTerminal: destTerminal, token: token});
|
|
377
373
|
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
receiptBaseline: terminalReceiptBaseline,
|
|
384
|
-
isForwarding: isForwarding
|
|
385
|
-
});
|
|
374
|
+
// NOTE: No ERC-20 receipt enforcement here (unlike addToBalanceOf).
|
|
375
|
+
// Pay hooks attached to the destination terminal may legitimately consume tokens during
|
|
376
|
+
// pay(), making a balance-delta check produce false reverts. Fee-on-transfer (FOT) tokens
|
|
377
|
+
// are therefore NOT supported for routed payments — the terminal will receive fewer tokens
|
|
378
|
+
// than `amount` but the router cannot detect or prevent this. See RISKS.md for details.
|
|
386
379
|
}
|
|
387
380
|
|
|
388
381
|
/// @notice The Uniswap v3 pool callback where the token transfer is expected to happen.
|
|
@@ -436,7 +429,7 @@ contract JBRouterTerminal is
|
|
|
436
429
|
params: SwapParams({
|
|
437
430
|
zeroForOne: zeroForOne, amountSpecified: amountSpecified, sqrtPriceLimitX96: sqrtPriceLimitX96
|
|
438
431
|
}),
|
|
439
|
-
hookData: ""
|
|
432
|
+
hookData: address(key.hooks) != address(0) ? abi.encode(minAmountOut) : bytes("")
|
|
440
433
|
});
|
|
441
434
|
|
|
442
435
|
// Determine input/output amounts from the delta.
|
|
@@ -994,13 +987,16 @@ contract JBRouterTerminal is
|
|
|
994
987
|
terminal = DIRECTORY.primaryTerminalOf({projectId: projectId, token: token});
|
|
995
988
|
|
|
996
989
|
// Drop terminals that would route straight back into the router (circular).
|
|
997
|
-
if (
|
|
990
|
+
if (
|
|
991
|
+
address(terminal) == address(0)
|
|
992
|
+
|| JBForwardingCheck.isCircularTerminal({
|
|
993
|
+
target: address(this), projectId: projectId, terminal: terminal
|
|
994
|
+
})
|
|
995
|
+
) {
|
|
998
996
|
return IJBTerminal(address(0));
|
|
999
997
|
}
|
|
1000
998
|
|
|
1001
999
|
// 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
1000
|
// slither-disable-next-line calls-loop
|
|
1005
1001
|
(bool ok, bytes memory data) =
|
|
1006
1002
|
address(terminal).staticcall(abi.encodeCall(IJBForwardingTerminal.terminalOf, (projectId)));
|
|
@@ -1031,13 +1027,6 @@ contract JBRouterTerminal is
|
|
|
1031
1027
|
}
|
|
1032
1028
|
}
|
|
1033
1029
|
|
|
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
1030
|
/// @notice Accepts a token being paid in.
|
|
1042
1031
|
/// @param token The address of the token being paid in.
|
|
1043
1032
|
/// @param amount The amount of tokens being paid in.
|
|
@@ -1213,15 +1202,16 @@ contract JBRouterTerminal is
|
|
|
1213
1202
|
// Cash out the source project's tokens.
|
|
1214
1203
|
// Don't rely on the terminal return value here. Buyback-hook sell-side execution returns 0 reclaimAmount
|
|
1215
1204
|
// from nana-core, then transfers the real proceeds during the hook callback.
|
|
1216
|
-
// Pass
|
|
1217
|
-
//
|
|
1205
|
+
// Pass minTokensReclaimed=0 to the terminal because the buyback hook's sell-side delivers tokens via
|
|
1206
|
+
// callback (reclaimAmount=0 from the terminal's perspective), which would fail the terminal's own min
|
|
1207
|
+
// check. The router enforces the user's minimum via the balance-delta check below instead.
|
|
1218
1208
|
// slither-disable-next-line unused-return,calls-loop
|
|
1219
1209
|
cashOutTerminal.cashOutTokensOf({
|
|
1220
1210
|
holder: address(this),
|
|
1221
1211
|
projectId: sourceProjectId,
|
|
1222
1212
|
cashOutCount: amount,
|
|
1223
1213
|
tokenToReclaim: tokenToReclaim,
|
|
1224
|
-
minTokensReclaimed:
|
|
1214
|
+
minTokensReclaimed: 0,
|
|
1225
1215
|
beneficiary: payable(address(this)),
|
|
1226
1216
|
metadata: ""
|
|
1227
1217
|
});
|
|
@@ -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
|
+
}
|
|
@@ -2500,8 +2500,8 @@ contract RouterTerminalTest is Test {
|
|
|
2500
2500
|
);
|
|
2501
2501
|
}
|
|
2502
2502
|
|
|
2503
|
-
//
|
|
2504
|
-
//
|
|
2503
|
+
// The router now passes minTokensReclaimed=0 to the terminal and enforces the user's
|
|
2504
|
+
// minimum via the balance-delta check instead (to support buyback-hook sell-side flows).
|
|
2505
2505
|
vm.expectCall(
|
|
2506
2506
|
address(mockCashOutTerminal),
|
|
2507
2507
|
abi.encodeCall(
|
|
@@ -2511,7 +2511,7 @@ contract RouterTerminalTest is Test {
|
|
|
2511
2511
|
2, // sourceProjectId
|
|
2512
2512
|
100e18, // amount
|
|
2513
2513
|
JBConstants.NATIVE_TOKEN,
|
|
2514
|
-
|
|
2514
|
+
0, // router passes 0 and enforces via balance-delta
|
|
2515
2515
|
payable(address(routerTerminal)),
|
|
2516
2516
|
bytes("")
|
|
2517
2517
|
)
|
|
@@ -557,10 +557,12 @@ contract CreditCashoutSpoofingIntermediary is IJBPayerTracker {
|
|
|
557
557
|
uint256 result = routerTerminal.pay(destProjectId, JBConstants.NATIVE_TOKEN, 0, payer, 0, "", metadata);
|
|
558
558
|
|
|
559
559
|
assertEq(result, 200, "pay should return dest terminal token count");
|
|
560
|
+
// The router now passes minTokensReclaimed=0 to the terminal and enforces the user's
|
|
561
|
+
// minimum via the balance-delta check instead (to support buyback-hook sell-side flows).
|
|
560
562
|
assertEq(
|
|
561
563
|
cashOutTerminal.lastMinTokensReclaimed(),
|
|
562
|
-
|
|
563
|
-
"router should
|
|
564
|
+
0,
|
|
565
|
+
"router should pass 0 to the terminal and enforce via balance-delta"
|
|
564
566
|
);
|
|
565
567
|
assertEq(destTerminal.lastAmount(), 60e18, "dest terminal should receive the reclaimed amount");
|
|
566
568
|
assertEq(destTerminal.lastValue(), 60e18, "dest terminal should receive ETH value");
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
7
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
8
|
+
import {IJBToken} from "@bananapus/core-v6/src/interfaces/IJBToken.sol";
|
|
9
|
+
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
10
|
+
import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
|
|
11
|
+
import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
|
|
12
|
+
import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
|
|
13
|
+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
|
14
|
+
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.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 {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
22
|
+
|
|
23
|
+
contract CodexNemesisERC20 is ERC20 {
|
|
24
|
+
constructor() ERC20("Codex Nemesis Token", "CNT") {}
|
|
25
|
+
|
|
26
|
+
function mint(address account, uint256 amount) external {
|
|
27
|
+
_mint(account, amount);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
contract HookForwardingTerminal is IJBTerminal {
|
|
32
|
+
IERC20 public immutable TOKEN;
|
|
33
|
+
address public immutable HOOK;
|
|
34
|
+
uint256 public immutable HOOK_AMOUNT;
|
|
35
|
+
|
|
36
|
+
constructor(IERC20 token_, address hook_, uint256 hookAmount_) {
|
|
37
|
+
TOKEN = token_;
|
|
38
|
+
HOOK = hook_;
|
|
39
|
+
HOOK_AMOUNT = hookAmount_;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function accountingContextForTokenOf(uint256, address token_) external pure returns (JBAccountingContext memory) {
|
|
43
|
+
return JBAccountingContext({token: token_, decimals: 18, currency: 0});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function accountingContextsOf(uint256) external view returns (JBAccountingContext[] memory contexts) {
|
|
47
|
+
contexts = new JBAccountingContext[](1);
|
|
48
|
+
contexts[0] = JBAccountingContext({token: address(TOKEN), decimals: 18, currency: 0});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function currentSurplusOf(uint256, address[] calldata, uint256, uint256) external pure returns (uint256) {
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function previewPayFor(
|
|
56
|
+
uint256,
|
|
57
|
+
address,
|
|
58
|
+
uint256,
|
|
59
|
+
address,
|
|
60
|
+
bytes calldata
|
|
61
|
+
)
|
|
62
|
+
external
|
|
63
|
+
view
|
|
64
|
+
returns (
|
|
65
|
+
JBRuleset memory ruleset,
|
|
66
|
+
uint256 beneficiaryTokenCount,
|
|
67
|
+
uint256 terminalTokenAmount,
|
|
68
|
+
JBPayHookSpecification[] memory specs
|
|
69
|
+
)
|
|
70
|
+
{
|
|
71
|
+
specs = new JBPayHookSpecification[](1);
|
|
72
|
+
specs[0].amount = HOOK_AMOUNT;
|
|
73
|
+
beneficiaryTokenCount = 1;
|
|
74
|
+
terminalTokenAmount = HOOK_AMOUNT;
|
|
75
|
+
ruleset.id = 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addAccountingContextsFor(uint256, JBAccountingContext[] calldata) external {}
|
|
79
|
+
|
|
80
|
+
function addToBalanceOf(uint256, address, uint256, bool, string calldata, bytes calldata) external payable {}
|
|
81
|
+
|
|
82
|
+
function migrateBalanceOf(uint256, address, IJBTerminal) external pure returns (uint256) {
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pay(
|
|
87
|
+
uint256,
|
|
88
|
+
address,
|
|
89
|
+
uint256 amount,
|
|
90
|
+
address,
|
|
91
|
+
uint256,
|
|
92
|
+
string calldata,
|
|
93
|
+
bytes calldata
|
|
94
|
+
)
|
|
95
|
+
external
|
|
96
|
+
payable
|
|
97
|
+
returns (uint256)
|
|
98
|
+
{
|
|
99
|
+
require(TOKEN.transferFrom(msg.sender, address(this), amount));
|
|
100
|
+
require(TOKEN.transfer(HOOK, HOOK_AMOUNT));
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
|
|
105
|
+
return interfaceId == type(IJBTerminal).interfaceId || interfaceId == type(IERC165).interfaceId;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
contract CodexNemesisPayHookReceiptDoSTest is Test {
|
|
110
|
+
/// @notice After M-39 fix: pay() no longer enforces _enforceStandardTerminalReceipt, so a terminal
|
|
111
|
+
/// that forwards tokens to a pay hook should succeed rather than revert.
|
|
112
|
+
function test_routerAllowsErc20TerminalThatForwardsToPayHook() external {
|
|
113
|
+
uint256 projectId = 1;
|
|
114
|
+
address payer = address(0xA11CE);
|
|
115
|
+
address beneficiary = address(0xB0B);
|
|
116
|
+
address hook = address(0xCAFE);
|
|
117
|
+
uint256 amount = 100 ether;
|
|
118
|
+
uint256 hookAmount = 40 ether;
|
|
119
|
+
|
|
120
|
+
CodexNemesisERC20 token = new CodexNemesisERC20();
|
|
121
|
+
HookForwardingTerminal terminal = new HookForwardingTerminal(token, hook, hookAmount);
|
|
122
|
+
|
|
123
|
+
address directory = address(0xD1);
|
|
124
|
+
address tokens = address(0x70);
|
|
125
|
+
vm.etch(directory, hex"00");
|
|
126
|
+
vm.etch(tokens, hex"00");
|
|
127
|
+
|
|
128
|
+
IJBTerminal[] memory terminals = new IJBTerminal[](1);
|
|
129
|
+
terminals[0] = IJBTerminal(address(terminal));
|
|
130
|
+
|
|
131
|
+
vm.mockCall(directory, abi.encodeCall(IJBDirectory.terminalsOf, (projectId)), abi.encode(terminals));
|
|
132
|
+
vm.mockCall(
|
|
133
|
+
directory,
|
|
134
|
+
abi.encodeCall(IJBDirectory.primaryTerminalOf, (projectId, address(token))),
|
|
135
|
+
abi.encode(address(terminal))
|
|
136
|
+
);
|
|
137
|
+
vm.mockCall(tokens, abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(address(token)))), abi.encode(uint256(0)));
|
|
138
|
+
|
|
139
|
+
JBRouterTerminal router = new JBRouterTerminal({
|
|
140
|
+
directory: IJBDirectory(directory),
|
|
141
|
+
tokens: IJBTokens(tokens),
|
|
142
|
+
permit2: IPermit2(address(0x22)),
|
|
143
|
+
weth: IWETH9(address(0x33)),
|
|
144
|
+
factory: IUniswapV3Factory(address(0x44)),
|
|
145
|
+
poolManager: IPoolManager(address(0)),
|
|
146
|
+
buybackHook: address(0),
|
|
147
|
+
univ4Hook: address(0),
|
|
148
|
+
trustedForwarder: address(0)
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
token.mint(payer, amount);
|
|
152
|
+
|
|
153
|
+
vm.startPrank(payer);
|
|
154
|
+
token.approve(address(router), amount);
|
|
155
|
+
// M-39 fix: pay() no longer reverts when hooks consume tokens — should succeed.
|
|
156
|
+
uint256 beneficiaryTokenCount = router.pay({
|
|
157
|
+
projectId: projectId,
|
|
158
|
+
token: address(token),
|
|
159
|
+
amount: amount,
|
|
160
|
+
beneficiary: beneficiary,
|
|
161
|
+
minReturnedTokens: 0,
|
|
162
|
+
memo: "",
|
|
163
|
+
metadata: ""
|
|
164
|
+
});
|
|
165
|
+
vm.stopPrank();
|
|
166
|
+
|
|
167
|
+
assertEq(beneficiaryTokenCount, 1, "beneficiary should receive 1 token from mock terminal");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
3
|
+
|
|
4
|
+
import {Test} from "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
7
|
+
import {IJBTokens} from "@bananapus/core-v6/src/interfaces/IJBTokens.sol";
|
|
8
|
+
import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
|
|
9
|
+
import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
|
|
10
|
+
import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
|
|
11
|
+
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
|
|
12
|
+
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
|
|
13
|
+
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
|
|
14
|
+
import {BalanceDelta, toBalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
|
|
15
|
+
import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
|
|
16
|
+
|
|
17
|
+
import {JBRouterTerminal} from "../../src/JBRouterTerminal.sol";
|
|
18
|
+
import {IWETH9} from "../../src/interfaces/IWETH9.sol";
|
|
19
|
+
|
|
20
|
+
/// @notice Mock PoolManager that captures the hookData argument from swap() calls.
|
|
21
|
+
/// It returns a valid BalanceDelta so the unlock callback can run to completion.
|
|
22
|
+
contract CapturingPoolManager {
|
|
23
|
+
bytes public capturedHookData;
|
|
24
|
+
bool public swapCalled;
|
|
25
|
+
|
|
26
|
+
/// @notice Captures hookData and returns a delta representing a swap of 1000 in, 900 out (zeroForOne).
|
|
27
|
+
function swap(PoolKey memory, SwapParams memory, bytes calldata hookData) external returns (BalanceDelta) {
|
|
28
|
+
capturedHookData = hookData;
|
|
29
|
+
swapCalled = true;
|
|
30
|
+
// Return delta: amount0 = -1000 (input consumed), amount1 = +900 (output received)
|
|
31
|
+
// zeroForOne: input is currency0 (negative delta), output is currency1 (positive delta)
|
|
32
|
+
return toBalanceDelta(-1000, 900);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// @notice No-op settle for ERC-20 path.
|
|
36
|
+
function settle() external payable returns (uint256) {
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// @notice No-op sync for ERC-20 path.
|
|
41
|
+
function sync(Currency) external {}
|
|
42
|
+
|
|
43
|
+
/// @notice No-op take for output side.
|
|
44
|
+
function take(Currency, address, uint256) external {}
|
|
45
|
+
|
|
46
|
+
/// @notice Fallback so vm.etch and other calls don't revert.
|
|
47
|
+
fallback() external payable {}
|
|
48
|
+
receive() external payable {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// @notice When the V4 pool key has hooks != address(0), the hookData passed to PoolManager.swap()
|
|
52
|
+
/// must contain abi.encode(minAmountOut), not empty bytes. Otherwise hooks like JBUniswapV4Hook will revert.
|
|
53
|
+
contract HookDataEncodingTest is Test {
|
|
54
|
+
JBRouterTerminal internal router;
|
|
55
|
+
CapturingPoolManager internal poolManager;
|
|
56
|
+
|
|
57
|
+
// Use distinct non-zero addresses for ERC-20 tokens (avoid native-ETH paths for simplicity).
|
|
58
|
+
address internal tokenA = address(0xAAAA);
|
|
59
|
+
address internal tokenB = address(0xBBBB);
|
|
60
|
+
// Hook address — any non-zero address.
|
|
61
|
+
address internal hook = address(0xCC01);
|
|
62
|
+
|
|
63
|
+
function setUp() public {
|
|
64
|
+
poolManager = new CapturingPoolManager();
|
|
65
|
+
|
|
66
|
+
// Deploy the router with the capturing pool manager.
|
|
67
|
+
router = new JBRouterTerminal({
|
|
68
|
+
directory: IJBDirectory(address(1)),
|
|
69
|
+
tokens: IJBTokens(address(2)),
|
|
70
|
+
permit2: IPermit2(address(3)),
|
|
71
|
+
weth: IWETH9(address(4)),
|
|
72
|
+
factory: IUniswapV3Factory(address(5)),
|
|
73
|
+
poolManager: IPoolManager(address(poolManager)),
|
|
74
|
+
buybackHook: address(0),
|
|
75
|
+
univ4Hook: hook,
|
|
76
|
+
trustedForwarder: address(0)
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Mock the IERC20.transfer call that _settleV4 makes (safeTransfer to pool manager).
|
|
80
|
+
vm.mockCall(
|
|
81
|
+
tokenA,
|
|
82
|
+
abi.encodeWithSignature("transfer(address,uint256)", address(poolManager), uint256(1000)),
|
|
83
|
+
abi.encode(true)
|
|
84
|
+
);
|
|
85
|
+
// Mock the IERC20.transfer for output side _takeV4 (not actually called since take is no-op, but be safe).
|
|
86
|
+
vm.mockCall(
|
|
87
|
+
tokenB,
|
|
88
|
+
abi.encodeWithSignature("transfer(address,uint256)", address(router), uint256(900)),
|
|
89
|
+
abi.encode(true)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// @notice When a hooked V4 pool is used, hookData must contain abi.encode(minAmountOut).
|
|
94
|
+
function test_hookData_containsMinAmountOut_whenHooksConfigured() public {
|
|
95
|
+
// Build a pool key with hooks.
|
|
96
|
+
PoolKey memory key = PoolKey({
|
|
97
|
+
currency0: Currency.wrap(tokenA),
|
|
98
|
+
currency1: Currency.wrap(tokenB),
|
|
99
|
+
fee: 3000,
|
|
100
|
+
tickSpacing: 60,
|
|
101
|
+
hooks: IHooks(hook)
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
uint256 minAmountOut = 800;
|
|
105
|
+
bool zeroForOne = true;
|
|
106
|
+
int256 amountSpecified = -1000; // exact input of 1000
|
|
107
|
+
uint160 sqrtPriceLimitX96 = 4_295_128_740; // TickMath.MIN_SQRT_RATIO + 1
|
|
108
|
+
bool canUseExistingNativeBalance = false;
|
|
109
|
+
|
|
110
|
+
// Encode the callback data exactly as _executeV4Swap does.
|
|
111
|
+
bytes memory callbackData =
|
|
112
|
+
abi.encode(key, zeroForOne, amountSpecified, sqrtPriceLimitX96, minAmountOut, canUseExistingNativeBalance);
|
|
113
|
+
|
|
114
|
+
// Call unlockCallback as the PoolManager (required by the msg.sender check).
|
|
115
|
+
vm.prank(address(poolManager));
|
|
116
|
+
router.unlockCallback(callbackData);
|
|
117
|
+
|
|
118
|
+
// Verify swap was called.
|
|
119
|
+
assertTrue(poolManager.swapCalled(), "swap should have been called");
|
|
120
|
+
|
|
121
|
+
// The key assertion: hookData should contain abi.encode(minAmountOut), not be empty.
|
|
122
|
+
bytes memory expectedHookData = abi.encode(minAmountOut);
|
|
123
|
+
assertEq(poolManager.capturedHookData(), expectedHookData, "hookData must encode minAmountOut for hooked pools");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// @notice When a pool has no hooks (address(0)), hookData should remain empty.
|
|
127
|
+
function test_hookData_isEmpty_whenNoHooks() public {
|
|
128
|
+
// Build a pool key WITHOUT hooks.
|
|
129
|
+
PoolKey memory key = PoolKey({
|
|
130
|
+
currency0: Currency.wrap(tokenA),
|
|
131
|
+
currency1: Currency.wrap(tokenB),
|
|
132
|
+
fee: 3000,
|
|
133
|
+
tickSpacing: 60,
|
|
134
|
+
hooks: IHooks(address(0))
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
uint256 minAmountOut = 800;
|
|
138
|
+
bool zeroForOne = true;
|
|
139
|
+
int256 amountSpecified = -1000;
|
|
140
|
+
uint160 sqrtPriceLimitX96 = 4_295_128_740;
|
|
141
|
+
bool canUseExistingNativeBalance = false;
|
|
142
|
+
|
|
143
|
+
bytes memory callbackData =
|
|
144
|
+
abi.encode(key, zeroForOne, amountSpecified, sqrtPriceLimitX96, minAmountOut, canUseExistingNativeBalance);
|
|
145
|
+
|
|
146
|
+
vm.prank(address(poolManager));
|
|
147
|
+
router.unlockCallback(callbackData);
|
|
148
|
+
|
|
149
|
+
assertTrue(poolManager.swapCalled(), "swap should have been called");
|
|
150
|
+
|
|
151
|
+
// With no hooks, hookData should be empty.
|
|
152
|
+
assertEq(poolManager.capturedHookData(), "", "hookData must be empty when no hooks configured");
|
|
153
|
+
}
|
|
154
|
+
}
|