@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/router-terminal-v6",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 {IJBForwardingTerminal} from "./interfaces/IJBForwardingTerminal.sol";
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 immediately cycle back into the router.
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
- // Follow the forwarding chain up to 5 hops to detect circular routes back to the router.
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.
@@ -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
- // Reject fee-on-transfer or otherwise lossy ERC20 terminal pulls on the final forwarded hop.
379
- _enforceStandardTerminalReceipt({
380
- terminal: destTerminal,
381
- token: token,
382
- expectedAmount: amount,
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 (address(terminal) == address(0) || _isCircularTerminal(terminal)) {
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 the metadata-level minimum only on the first hop, while the amount is still expressed in the unit
1217
- // the caller supplied. Later hops may reclaim different assets, so reusing this floor would be unsound.
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: 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
- // Router now applies the reclaim minimum on the first concrete cashout hop only.
2504
- // Later hops intentionally drop the floor because the route may change token units.
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
- 50e18, // first hop receives the user-specified reclaim minimum directly
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
- minReclaimed,
563
- "router should forward the metadata floor on the first hop"
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
+ }