@bananapus/router-terminal-v6 0.0.5 → 0.0.6

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/foundry.toml CHANGED
@@ -16,6 +16,9 @@ runs = 1024
16
16
  depth = 100
17
17
  fail_on_revert = false
18
18
 
19
+ [rpc_endpoints]
20
+ mainnet = "${RPC_ETHEREUM_MAINNET}"
21
+
19
22
  [fmt]
20
23
  number_underscore = "thousands"
21
24
  multiline_func_header = "all"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/router-terminal-v6",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -483,7 +483,8 @@ contract JBRouterTerminal is
483
483
  destProjectId: destProjectId,
484
484
  token: tokenIn,
485
485
  amount: amount,
486
- sourceProjectIdOverride: sourceProjectIdOverride
486
+ sourceProjectIdOverride: sourceProjectIdOverride,
487
+ metadata: metadata
487
488
  });
488
489
 
489
490
  // If the cashout loop found a terminal that accepts the reclaimed token, we're done.
@@ -711,6 +712,12 @@ contract JBRouterTerminal is
711
712
  callbackData: abi.encode(projectId, tokenIn, tokenOut)
712
713
  });
713
714
 
715
+ // For native token inputs, wrap any raw ETH remaining from partial fills so the leftover check catches it.
716
+ // In partial fills, the swap callback only wraps the amount the pool consumed, leaving excess as raw ETH.
717
+ if (tokenIn == JBConstants.NATIVE_TOKEN && address(this).balance != 0) {
718
+ WETH.deposit{value: address(this).balance}();
719
+ }
720
+
714
721
  // Unwrap if output is native token.
715
722
  if (tokenOut == JBConstants.NATIVE_TOKEN) WETH.withdraw(amountOut);
716
723
 
@@ -804,8 +811,9 @@ contract JBRouterTerminal is
804
811
 
805
812
  uint160 sqrtPriceLimitX96 = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
806
813
 
814
+ // V4 sign convention: negative = exact input, positive = exact output.
807
815
  bytes memory result =
808
- POOL_MANAGER.unlock(abi.encode(key, zeroForOne, int256(amount), sqrtPriceLimitX96, minAmountOut));
816
+ POOL_MANAGER.unlock(abi.encode(key, zeroForOne, -int256(amount), sqrtPriceLimitX96, minAmountOut));
809
817
 
810
818
  amountOut = abi.decode(result, (uint256));
811
819
  }
@@ -1094,6 +1102,7 @@ contract JBRouterTerminal is
1094
1102
  /// @param amount The amount of the current token.
1095
1103
  /// @param sourceProjectIdOverride When non-zero, use this as the source project ID instead of looking up via
1096
1104
  /// `TOKENS.projectIdOf()`. Reset to 0 after first use.
1105
+ /// @param metadata Bytes in `JBMetadataResolver`'s format (may contain cashOutMinReclaimed).
1097
1106
  /// @return destTerminal The terminal that accepts the final token (address(0) if no direct acceptance found).
1098
1107
  /// @return finalToken The token after all cashouts.
1099
1108
  /// @return finalAmount The amount of the final token.
@@ -1101,11 +1110,20 @@ contract JBRouterTerminal is
1101
1110
  uint256 destProjectId,
1102
1111
  address token,
1103
1112
  uint256 amount,
1104
- uint256 sourceProjectIdOverride
1113
+ uint256 sourceProjectIdOverride,
1114
+ bytes calldata metadata
1105
1115
  )
1106
1116
  internal
1107
1117
  returns (IJBTerminal destTerminal, address finalToken, uint256 finalAmount)
1108
1118
  {
1119
+ // Check for a user-provided minimum cashout reclaim amount (slippage protection).
1120
+ uint256 minTokensReclaimed;
1121
+ {
1122
+ (bool exists, bytes memory minData) =
1123
+ JBMetadataResolver.getDataFor({id: JBMetadataResolver.getId("cashOutMinReclaimed"), metadata: metadata});
1124
+ if (exists) minTokensReclaimed = abi.decode(minData, (uint256));
1125
+ }
1126
+
1109
1127
  while (true) {
1110
1128
  // Skip the destination check on the first iteration if we have a credit override.
1111
1129
  if (sourceProjectIdOverride == 0) {
@@ -1137,11 +1155,14 @@ contract JBRouterTerminal is
1137
1155
  projectId: sourceProjectId,
1138
1156
  cashOutCount: amount,
1139
1157
  tokenToReclaim: tokenToReclaim,
1140
- minTokensReclaimed: 0,
1158
+ minTokensReclaimed: minTokensReclaimed,
1141
1159
  beneficiary: payable(address(this)),
1142
1160
  metadata: bytes("")
1143
1161
  });
1144
1162
 
1163
+ // Only apply the minimum to the first cashout step.
1164
+ minTokensReclaimed = 0;
1165
+
1145
1166
  // Update for next iteration.
1146
1167
  token = tokenToReclaim;
1147
1168
  sourceProjectIdOverride = 0;
@@ -1236,9 +1257,12 @@ contract JBRouterTerminal is
1236
1257
  JBMetadataResolver.getDataFor({id: JBMetadataResolver.getId("cashOutSource"), metadata: metadata});
1237
1258
 
1238
1259
  if (creditExists) {
1260
+ // Credit cashouts don't use msg.value — revert if ETH was sent to prevent it being trapped.
1261
+ if (msg.value != 0) revert JBRouterTerminal_NoMsgValueAllowed(msg.value);
1262
+
1239
1263
  (uint256 sourceProjectId, uint256 creditAmount) = abi.decode(creditData, (uint256, uint256));
1240
1264
 
1241
- // Pull credits from the payer.
1265
+ // Pull credits from the payer (requires payer to have granted TRANSFER_CREDITS to this contract).
1242
1266
  TOKENS.transferCreditsFrom({
1243
1267
  holder: _msgSender(), projectId: sourceProjectId, recipient: address(this), count: creditAmount
1244
1268
  });
@@ -823,4 +823,197 @@ contract RouterTerminalTest is Test {
823
823
  _mockV4PoolNotExists(sorted0, sorted1, 10_000, int24(200));
824
824
  _mockV4PoolNotExists(sorted0, sorted1, 100, int24(1));
825
825
  }
826
+
827
+ //*********************************************************************//
828
+ // ---------- Bug fix regression tests: ETH + credit revert ---------- //
829
+ //*********************************************************************//
830
+
831
+ /// @notice Sending msg.value alongside cashOutSource credit metadata should revert (fix #2).
832
+ function test_pay_revertsWhenETHSentWithCreditMetadata() public {
833
+ uint256 projectId = 1;
834
+ address payer = makeAddr("payer");
835
+
836
+ // Build metadata with cashOutSource — must use router address for getId.
837
+ bytes4 metadataId = JBMetadataResolver.getId("cashOutSource", address(routerTerminal));
838
+ bytes memory metadata = JBMetadataResolver.addToMetadata("", metadataId, abi.encode(uint256(2), uint256(1e18)));
839
+
840
+ vm.deal(payer, 1 ether);
841
+ vm.prank(payer);
842
+ vm.expectRevert(abi.encodeWithSelector(IJBRouterTerminal.JBRouterTerminal_NoMsgValueAllowed.selector, 1 ether));
843
+ routerTerminal.pay{value: 1 ether}(projectId, JBConstants.NATIVE_TOKEN, 1 ether, payer, 0, "", metadata);
844
+ }
845
+
846
+ /// @notice addToBalanceOf should also revert when ETH sent with credit metadata.
847
+ function test_addToBalanceOf_revertsWhenETHSentWithCreditMetadata() public {
848
+ address payer = makeAddr("payer");
849
+
850
+ // Build metadata with cashOutSource — must use router address for getId.
851
+ bytes4 metadataId = JBMetadataResolver.getId("cashOutSource", address(routerTerminal));
852
+ bytes memory metadata = JBMetadataResolver.addToMetadata("", metadataId, abi.encode(uint256(2), uint256(1e18)));
853
+
854
+ vm.deal(payer, 1 ether);
855
+ vm.prank(payer);
856
+ vm.expectRevert(abi.encodeWithSelector(IJBRouterTerminal.JBRouterTerminal_NoMsgValueAllowed.selector, 1 ether));
857
+ routerTerminal.addToBalanceOf{value: 1 ether}(1, JBConstants.NATIVE_TOKEN, 1 ether, false, "", metadata);
858
+ }
859
+
860
+ //*********************************************************************//
861
+ // ----------- Bug fix regression tests: V4 sign convention ---------- //
862
+ //*********************************************************************//
863
+
864
+ /// @notice The V4 unlock callback should receive a negative amountSpecified for exact-input (fix #1).
865
+ function test_unlockCallback_negativeAmountSpecified() public {
866
+ address tokenA = makeAddr("tokenA");
867
+ address tokenB = makeAddr("tokenB");
868
+ (address sorted0, address sorted1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
869
+
870
+ PoolKey memory key = PoolKey({
871
+ currency0: Currency.wrap(sorted0),
872
+ currency1: Currency.wrap(sorted1),
873
+ fee: 3000,
874
+ tickSpacing: int24(60),
875
+ hooks: IHooks(address(0))
876
+ });
877
+
878
+ uint256 amount = 1e18;
879
+ int256 amountSpecified = -int256(amount); // exact-input: NEGATIVE
880
+ uint160 sqrtPriceLimitX96 = 4_295_128_740; // MIN_SQRT_RATIO + 1
881
+ uint256 minAmountOut = 0;
882
+
883
+ // Encode the data as the contract would encode it (with the sign fix).
884
+ bytes memory callbackData = abi.encode(key, true, amountSpecified, sqrtPriceLimitX96, minAmountOut);
885
+
886
+ // Mock the PoolManager.swap call — it should receive a negative amountSpecified.
887
+ // We construct the expected SwapParams to verify the sign.
888
+ vm.mockCall(
889
+ address(mockPoolManager),
890
+ abi.encodeWithSelector(IPoolManager.swap.selector),
891
+ // Return a BalanceDelta where token0 goes in (-1e18) and token1 comes out (+5e17).
892
+ abi.encode(int256(-1e18) << 128 | int256(uint256(5e17)))
893
+ );
894
+
895
+ // Mock settle and take.
896
+ vm.mockCall(address(mockPoolManager), abi.encodeWithSignature("settle()"), abi.encode(uint256(1e18)));
897
+ vm.mockCall(address(mockPoolManager), abi.encodeWithSignature("settle{value}()"), abi.encode(uint256(1e18)));
898
+ vm.mockCall(address(mockPoolManager), abi.encodeWithSignature("sync(address)"), abi.encode());
899
+ vm.mockCall(address(mockPoolManager), abi.encodeWithSignature("take(address,address,uint256)"), abi.encode());
900
+ vm.mockCall(sorted0, abi.encodeCall(IERC20.transfer, (address(mockPoolManager), 1e18)), abi.encode(true));
901
+
902
+ // Call from the PoolManager (authorized).
903
+ vm.prank(address(mockPoolManager));
904
+
905
+ // The callback should decode a NEGATIVE amountSpecified and process the swap.
906
+ // If the old bug existed (positive), the swap behavior would be different.
907
+ bytes memory result = routerTerminal.unlockCallback(callbackData);
908
+
909
+ // Verify amountOut is decoded correctly.
910
+ uint256 amountOut = abi.decode(result, (uint256));
911
+ assertEq(amountOut, 5e17);
912
+ }
913
+
914
+ //*********************************************************************//
915
+ // --------- Bug fix regression tests: cashout slippage -------------- //
916
+ //*********************************************************************//
917
+
918
+ /// @notice cashOutMinReclaimed metadata should be forwarded to the cashout terminal (fix #4).
919
+ function test_pay_cashOutMinReclaimedMetadata() public {
920
+ uint256 destProjectId = 1;
921
+ address payer = makeAddr("payer");
922
+ address jbToken = makeAddr("jbToken");
923
+ vm.etch(jbToken, hex"00");
924
+ uint256 sourceProjectId = 2;
925
+ uint256 amount = 100e18;
926
+ uint256 minReclaimed = 50e18;
927
+ address mockTerminal = makeAddr("destTerminal");
928
+ vm.etch(mockTerminal, hex"00");
929
+ address mockCashOutTerminal = makeAddr("cashOutTerminal");
930
+ vm.etch(mockCashOutTerminal, hex"00");
931
+
932
+ // Build metadata with cashOutMinReclaimed — must use router address for getId.
933
+ bytes4 metadataId = JBMetadataResolver.getId("cashOutMinReclaimed", address(routerTerminal));
934
+ bytes memory metadata = JBMetadataResolver.addToMetadata("", metadataId, abi.encode(minReclaimed));
935
+
936
+ // jbToken is a JB project token for sourceProjectId.
937
+ vm.mockCall(
938
+ address(mockTokens), abi.encodeCall(IJBTokens.projectIdOf, (IJBToken(jbToken))), abi.encode(sourceProjectId)
939
+ );
940
+
941
+ // Dest project accepts NATIVE_TOKEN.
942
+ vm.mockCall(
943
+ address(mockDirectory),
944
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (destProjectId, JBConstants.NATIVE_TOKEN)),
945
+ abi.encode(mockTerminal)
946
+ );
947
+
948
+ // Dest project doesn't accept jbToken directly.
949
+ vm.mockCall(
950
+ address(mockDirectory),
951
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (destProjectId, jbToken)),
952
+ abi.encode(address(0))
953
+ );
954
+
955
+ // Source project's terminals (for _findCashOutPath).
956
+ IJBTerminal[] memory sourceTerminals = new IJBTerminal[](1);
957
+ sourceTerminals[0] = IJBTerminal(mockCashOutTerminal);
958
+ vm.mockCall(
959
+ address(mockDirectory),
960
+ abi.encodeCall(IJBDirectory.terminalsOf, (sourceProjectId)),
961
+ abi.encode(sourceTerminals)
962
+ );
963
+
964
+ // Mock supportsInterface for IJBCashOutTerminal.
965
+ vm.mockCall(
966
+ mockCashOutTerminal,
967
+ abi.encodeCall(IERC165.supportsInterface, (type(IJBCashOutTerminal).interfaceId)),
968
+ abi.encode(true)
969
+ );
970
+
971
+ // Accounting context: source project terminal accepts NATIVE_TOKEN.
972
+ JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
973
+ contexts[0] = JBAccountingContext({
974
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
975
+ });
976
+ vm.mockCall(
977
+ mockCashOutTerminal,
978
+ abi.encodeCall(IJBTerminal.accountingContextsOf, (sourceProjectId)),
979
+ abi.encode(contexts)
980
+ );
981
+
982
+ // Mock cashOutTokensOf — use broad selector matching since vm.mockCall matches by prefix.
983
+ // The key assertion: the call should use minReclaimed (not 0).
984
+ vm.mockCall(
985
+ mockCashOutTerminal,
986
+ abi.encodeWithSelector(IJBCashOutTerminal.cashOutTokensOf.selector),
987
+ abi.encode(uint256(60e18))
988
+ );
989
+
990
+ // Expect the specific cashOutTokensOf call with minReclaimed = minReclaimed.
991
+ vm.expectCall(
992
+ mockCashOutTerminal,
993
+ abi.encodeCall(
994
+ IJBCashOutTerminal.cashOutTokensOf,
995
+ (
996
+ address(routerTerminal),
997
+ sourceProjectId,
998
+ amount,
999
+ JBConstants.NATIVE_TOKEN,
1000
+ minReclaimed,
1001
+ payable(address(routerTerminal)),
1002
+ bytes("")
1003
+ )
1004
+ )
1005
+ );
1006
+
1007
+ // Mock token transfer from payer.
1008
+ vm.mockCall(jbToken, abi.encodeCall(IERC20.allowance, (payer, address(routerTerminal))), abi.encode(amount));
1009
+ vm.mockCall(
1010
+ jbToken, abi.encodeCall(IERC20.transferFrom, (payer, address(routerTerminal), amount)), abi.encode(true)
1011
+ );
1012
+
1013
+ // Mock dest terminal pay.
1014
+ vm.mockCall(mockTerminal, abi.encodeWithSelector(IJBTerminal.pay.selector), abi.encode(uint256(10)));
1015
+
1016
+ vm.prank(payer);
1017
+ routerTerminal.pay(destProjectId, jbToken, amount, payer, 0, "", metadata);
1018
+ }
826
1019
  }
@@ -0,0 +1,509 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "forge-std/Test.sol";
5
+
6
+ // JB core (via TestBaseWorkflow pattern — deploy fresh within fork).
7
+ import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
8
+ import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
9
+ import {JBDirectory} from "@bananapus/core-v6/src/JBDirectory.sol";
10
+ import {JBRulesets} from "@bananapus/core-v6/src/JBRulesets.sol";
11
+ import {JBTokens} from "@bananapus/core-v6/src/JBTokens.sol";
12
+ import {JBERC20} from "@bananapus/core-v6/src/JBERC20.sol";
13
+ import {JBSplits} from "@bananapus/core-v6/src/JBSplits.sol";
14
+ import {JBPrices} from "@bananapus/core-v6/src/JBPrices.sol";
15
+ import {JBController} from "@bananapus/core-v6/src/JBController.sol";
16
+ import {JBFundAccessLimits} from "@bananapus/core-v6/src/JBFundAccessLimits.sol";
17
+ import {JBFeelessAddresses} from "@bananapus/core-v6/src/JBFeelessAddresses.sol";
18
+ import {JBTerminalStore} from "@bananapus/core-v6/src/JBTerminalStore.sol";
19
+ import {JBMultiTerminal} from "@bananapus/core-v6/src/JBMultiTerminal.sol";
20
+ import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
21
+ import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
22
+ import {JBAccountingContext} from "@bananapus/core-v6/src/structs/JBAccountingContext.sol";
23
+ import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
24
+ import {JBRulesetMetadata} from "@bananapus/core-v6/src/structs/JBRulesetMetadata.sol";
25
+ import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
26
+ import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
27
+ import {JBFundAccessLimitGroup} from "@bananapus/core-v6/src/structs/JBFundAccessLimitGroup.sol";
28
+ import {JBCurrencyAmount} from "@bananapus/core-v6/src/structs/JBCurrencyAmount.sol";
29
+ import {IJBRulesetApprovalHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetApprovalHook.sol";
30
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
31
+
32
+ // Uniswap.
33
+ import {IUniswapV3Factory} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Factory.sol";
34
+ import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
35
+ import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
36
+ import {IPermit2} from "@uniswap/permit2/src/interfaces/IPermit2.sol";
37
+
38
+ // OpenZeppelin.
39
+ import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
40
+
41
+ // Router terminal.
42
+ import {JBRouterTerminal} from "../src/JBRouterTerminal.sol";
43
+ import {IWETH9} from "../src/interfaces/IWETH9.sol";
44
+
45
+ /// @notice Fork tests for JBRouterTerminal against real Uniswap V3 pools on Ethereum mainnet.
46
+ /// @dev Uses a pinned block for determinism. JB core is deployed fresh within the fork so we control project state.
47
+ contract RouterTerminalForkTest is Test {
48
+ // ───────────────────────── Mainnet addresses
49
+ // ──────────────────────────
50
+
51
+ // Post-V4-deployment block (V4 PoolManager deployed ~21,690,000) with good TWAP history.
52
+ uint256 constant BLOCK_NUMBER = 21_700_000;
53
+
54
+ IWETH9 constant WETH = IWETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
55
+ IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
56
+ IERC20 constant DAI = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F);
57
+ IUniswapV3Factory constant V3_FACTORY = IUniswapV3Factory(0x1F98431c8aD98523631AE4a59f267346ea31F984);
58
+ IPermit2 constant PERMIT2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
59
+ IPoolManager constant V4_POOL_MANAGER = IPoolManager(0x000000000004444c5dc75cB358380D2e3dE08A90);
60
+
61
+ // Well-known V3 pools (we don't create them — they exist on mainnet).
62
+ // WETH/USDC 0.05%: 0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640
63
+ // WETH/DAI 0.3%: 0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8
64
+
65
+ // ───────────────────────── JB core (deployed fresh)
66
+ // ────────────────────
67
+
68
+ address multisig = address(0xBEEF);
69
+ address payer = makeAddr("payer");
70
+ address beneficiary = makeAddr("beneficiary");
71
+ address trustedForwarder = address(0);
72
+
73
+ JBPermissions jbPermissions;
74
+ JBProjects jbProjects;
75
+ JBDirectory jbDirectory;
76
+ JBRulesets jbRulesets;
77
+ JBTokens jbTokens;
78
+ JBSplits jbSplits;
79
+ JBPrices jbPrices;
80
+ JBFundAccessLimits jbFundAccessLimits;
81
+ JBFeelessAddresses jbFeelessAddresses;
82
+ JBController jbController;
83
+ JBTerminalStore jbTerminalStore;
84
+ JBMultiTerminal jbMultiTerminal;
85
+ JBRouterTerminal routerTerminal;
86
+
87
+ // Project IDs.
88
+ uint256 feeProjectId; // Project 1 (fee recipient).
89
+ uint256 ethProjectId; // Accepts NATIVE_TOKEN (ETH).
90
+ uint256 usdcProjectId; // Accepts USDC.
91
+ uint256 daiProjectId; // Accepts DAI.
92
+
93
+ // ───────────────────────── Setup
94
+ // ──────────────────────────────────────
95
+
96
+ function setUp() public {
97
+ // Skip fork tests in CI when no RPC URL is configured.
98
+ string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
99
+ if (bytes(rpcUrl).length == 0) {
100
+ vm.skip(true);
101
+ return;
102
+ }
103
+ vm.createSelectFork(rpcUrl, BLOCK_NUMBER);
104
+
105
+ // Deploy all JB core contracts fresh within the fork.
106
+ _deployJBCore();
107
+
108
+ // Deploy the router terminal with real Uniswap + real Permit2, but fresh JB core.
109
+ routerTerminal = new JBRouterTerminal({
110
+ directory: jbDirectory,
111
+ permissions: jbPermissions,
112
+ projects: jbProjects,
113
+ tokens: jbTokens,
114
+ permit2: PERMIT2,
115
+ owner: multisig,
116
+ weth: WETH,
117
+ factory: V3_FACTORY,
118
+ poolManager: V4_POOL_MANAGER,
119
+ trustedForwarder: trustedForwarder
120
+ });
121
+
122
+ // Create test projects.
123
+ feeProjectId = _launchProject({acceptedToken: JBConstants.NATIVE_TOKEN, decimals: 18});
124
+ ethProjectId = _launchProject({acceptedToken: JBConstants.NATIVE_TOKEN, decimals: 18});
125
+ usdcProjectId = _launchProject({acceptedToken: address(USDC), decimals: 6});
126
+ daiProjectId = _launchProject({acceptedToken: address(DAI), decimals: 18});
127
+
128
+ // Labels for trace readability.
129
+ vm.label(address(WETH), "WETH");
130
+ vm.label(address(USDC), "USDC");
131
+ vm.label(address(DAI), "DAI");
132
+ vm.label(address(routerTerminal), "RouterTerminal");
133
+ vm.label(address(jbMultiTerminal), "JBMultiTerminal");
134
+ vm.label(payer, "payer");
135
+ vm.label(beneficiary, "beneficiary");
136
+ }
137
+
138
+ // ═══════════════════════════════════════════════════════════════════════
139
+ // V3 SWAP: ETH → USDC (pay project that accepts USDC with ETH)
140
+ // ═══════════════════════════════════════════════════════════════════════
141
+
142
+ function test_fork_payETH_projectAcceptsUSDC_small() public {
143
+ uint256 amountIn = 0.01 ether;
144
+ _payETHAndAssert(usdcProjectId, amountIn, address(USDC));
145
+ }
146
+
147
+ function test_fork_payETH_projectAcceptsUSDC_medium() public {
148
+ uint256 amountIn = 1 ether;
149
+ _payETHAndAssert(usdcProjectId, amountIn, address(USDC));
150
+ }
151
+
152
+ function test_fork_payETH_projectAcceptsUSDC_large() public {
153
+ uint256 amountIn = 100 ether;
154
+ _payETHAndAssert(usdcProjectId, amountIn, address(USDC));
155
+ }
156
+
157
+ function test_fork_payETH_projectAcceptsUSDC_veryLarge() public {
158
+ uint256 amountIn = 1000 ether;
159
+ _payETHAndAssert(usdcProjectId, amountIn, address(USDC));
160
+ }
161
+
162
+ // ═══════════════════════════════════════════════════════════════════════
163
+ // V3 SWAP: USDC → ETH (pay project that accepts ETH with USDC)
164
+ // ═══════════════════════════════════════════════════════════════════════
165
+
166
+ function test_fork_payUSDC_projectAcceptsETH_small() public {
167
+ uint256 amountIn = 10e6; // 10 USDC
168
+ _payERC20AndAssert(usdcProjectId, ethProjectId, address(USDC), amountIn, JBConstants.NATIVE_TOKEN);
169
+ }
170
+
171
+ function test_fork_payUSDC_projectAcceptsETH_large() public {
172
+ uint256 amountIn = 1_000_000e6; // 1M USDC
173
+ _payERC20AndAssert(usdcProjectId, ethProjectId, address(USDC), amountIn, JBConstants.NATIVE_TOKEN);
174
+ }
175
+
176
+ // ═══════════════════════════════════════════════════════════════════════
177
+ // DIRECT FORWARD (no swap needed)
178
+ // ═══════════════════════════════════════════════════════════════════════
179
+
180
+ function test_fork_payETH_projectAcceptsETH() public {
181
+ uint256 amountIn = 1 ether;
182
+
183
+ vm.deal(payer, amountIn);
184
+ uint256 payerBalBefore = payer.balance;
185
+
186
+ vm.prank(payer);
187
+ uint256 tokenCount = routerTerminal.pay{value: amountIn}({
188
+ projectId: ethProjectId,
189
+ token: JBConstants.NATIVE_TOKEN,
190
+ amount: amountIn,
191
+ beneficiary: beneficiary,
192
+ minReturnedTokens: 0,
193
+ memo: "direct ETH forward",
194
+ metadata: ""
195
+ });
196
+
197
+ // Project tokens minted.
198
+ assertGt(tokenCount, 0, "no tokens minted for direct ETH forward");
199
+
200
+ // Payer spent exactly amountIn.
201
+ assertEq(payerBalBefore - payer.balance, amountIn, "payer balance mismatch");
202
+
203
+ // Terminal received the ETH.
204
+ uint256 terminalBal =
205
+ jbTerminalStore.balanceOf(address(jbMultiTerminal), ethProjectId, JBConstants.NATIVE_TOKEN);
206
+ assertGt(terminalBal, 0, "terminal has no ETH balance");
207
+
208
+ // Router has no leftover.
209
+ assertEq(address(routerTerminal).balance, 0, "router has leftover ETH");
210
+ }
211
+
212
+ function test_fork_payUSDC_projectAcceptsUSDC() public {
213
+ uint256 amountIn = 1000e6; // 1000 USDC
214
+
215
+ deal(address(USDC), payer, amountIn);
216
+
217
+ vm.startPrank(payer);
218
+ USDC.approve(address(routerTerminal), amountIn);
219
+ uint256 tokenCount = routerTerminal.pay({
220
+ projectId: usdcProjectId,
221
+ token: address(USDC),
222
+ amount: amountIn,
223
+ beneficiary: beneficiary,
224
+ minReturnedTokens: 0,
225
+ memo: "direct USDC forward",
226
+ metadata: ""
227
+ });
228
+ vm.stopPrank();
229
+
230
+ assertGt(tokenCount, 0, "no tokens minted for direct USDC forward");
231
+
232
+ // Terminal received the USDC.
233
+ uint256 terminalBal = jbTerminalStore.balanceOf(address(jbMultiTerminal), usdcProjectId, address(USDC));
234
+ assertGt(terminalBal, 0, "terminal has no USDC balance");
235
+
236
+ // Router has no leftover.
237
+ assertEq(USDC.balanceOf(address(routerTerminal)), 0, "router has leftover USDC");
238
+ }
239
+
240
+ // ═══════════════════════════════════════════════════════════════════════
241
+ // V3 SWAP: ETH → DAI
242
+ // ═══════════════════════════════════════════════════════════════════════
243
+
244
+ function test_fork_payETH_projectAcceptsDAI() public {
245
+ uint256 amountIn = 1 ether;
246
+ _payETHAndAssert(daiProjectId, amountIn, address(DAI));
247
+ }
248
+
249
+ // ═══════════════════════════════════════════════════════════════════════
250
+ // addToBalanceOf
251
+ // ═══════════════════════════════════════════════════════════════════════
252
+
253
+ function test_fork_addToBalance_ETHtoUSDC() public {
254
+ uint256 amountIn = 1 ether;
255
+
256
+ vm.deal(payer, amountIn);
257
+ uint256 terminalBalBefore = jbTerminalStore.balanceOf(address(jbMultiTerminal), usdcProjectId, address(USDC));
258
+
259
+ vm.prank(payer);
260
+ routerTerminal.addToBalanceOf{value: amountIn}({
261
+ projectId: usdcProjectId,
262
+ token: JBConstants.NATIVE_TOKEN,
263
+ amount: amountIn,
264
+ shouldReturnHeldFees: false,
265
+ memo: "add to balance ETH->USDC",
266
+ metadata: ""
267
+ });
268
+
269
+ uint256 terminalBalAfter = jbTerminalStore.balanceOf(address(jbMultiTerminal), usdcProjectId, address(USDC));
270
+
271
+ assertGt(terminalBalAfter, terminalBalBefore, "terminal balance did not increase");
272
+ assertEq(address(routerTerminal).balance, 0, "router has leftover ETH");
273
+ assertEq(USDC.balanceOf(address(routerTerminal)), 0, "router has leftover USDC");
274
+ }
275
+
276
+ // ═══════════════════════════════════════════════════════════════════════
277
+ // User-provided quote (metadata override)
278
+ // ═══════════════════════════════════════════════════════════════════════
279
+
280
+ function test_fork_payETH_withQuoteMetadata() public {
281
+ uint256 amountIn = 1 ether;
282
+
283
+ // Set a generous quote (1 USDC minimum — well below market, should succeed).
284
+ bytes4 quoteId = JBMetadataResolver.getId("quoteForSwap", address(routerTerminal));
285
+ bytes memory metadata = JBMetadataResolver.addToMetadata("", quoteId, abi.encode(uint256(1e6)));
286
+
287
+ vm.deal(payer, amountIn);
288
+
289
+ vm.prank(payer);
290
+ uint256 tokenCount = routerTerminal.pay{value: amountIn}({
291
+ projectId: usdcProjectId,
292
+ token: JBConstants.NATIVE_TOKEN,
293
+ amount: amountIn,
294
+ beneficiary: beneficiary,
295
+ minReturnedTokens: 0,
296
+ memo: "ETH->USDC with quote metadata",
297
+ metadata: metadata
298
+ });
299
+
300
+ assertGt(tokenCount, 0, "no tokens minted with quote metadata");
301
+
302
+ // Terminal received USDC.
303
+ uint256 terminalBal = jbTerminalStore.balanceOf(address(jbMultiTerminal), usdcProjectId, address(USDC));
304
+ assertGt(terminalBal, 0, "terminal has no USDC balance after quote metadata pay");
305
+
306
+ // Router clean.
307
+ assertEq(address(routerTerminal).balance, 0, "router has leftover ETH");
308
+ assertEq(USDC.balanceOf(address(routerTerminal)), 0, "router has leftover USDC");
309
+ }
310
+
311
+ // ═══════════════════════════════════════════════════════════════════════
312
+ // Slippage / revert scenarios
313
+ // ═══════════════════════════════════════════════════════════════════════
314
+
315
+ function test_fork_payETH_tightQuote_reverts() public {
316
+ uint256 amountIn = 1 ether;
317
+
318
+ // Set a quote 3x above market (~3,300 USDC/ETH → require 10,000 USDC).
319
+ // This is tight enough to trigger SlippageExceeded but not so extreme that
320
+ // sqrtPriceLimitFromAmounts falls back to "no limit".
321
+ bytes4 quoteId = JBMetadataResolver.getId("quoteForSwap", address(routerTerminal));
322
+ bytes memory metadata = JBMetadataResolver.addToMetadata("", quoteId, abi.encode(uint256(10_000e6)));
323
+
324
+ vm.deal(payer, amountIn);
325
+
326
+ vm.prank(payer);
327
+ vm.expectRevert();
328
+ routerTerminal.pay{value: amountIn}({
329
+ projectId: usdcProjectId,
330
+ token: JBConstants.NATIVE_TOKEN,
331
+ amount: amountIn,
332
+ beneficiary: beneficiary,
333
+ minReturnedTokens: 0,
334
+ memo: "should revert - tight quote",
335
+ metadata: metadata
336
+ });
337
+ }
338
+
339
+ // ═══════════════════════════════════════════════════════════════════════
340
+ // Internal helpers
341
+ // ═══════════════════════════════════════════════════════════════════════
342
+
343
+ /// @dev Pay ETH via the router terminal to a project that accepts `expectedTokenOut`. Asserts swap happened.
344
+ function _payETHAndAssert(uint256 projectId, uint256 amountIn, address expectedTokenOut) internal {
345
+ vm.deal(payer, amountIn);
346
+ uint256 payerBalBefore = payer.balance;
347
+
348
+ vm.prank(payer);
349
+ uint256 tokenCount = routerTerminal.pay{value: amountIn}({
350
+ projectId: projectId,
351
+ token: JBConstants.NATIVE_TOKEN,
352
+ amount: amountIn,
353
+ beneficiary: beneficiary,
354
+ minReturnedTokens: 0,
355
+ memo: "fork test ETH swap",
356
+ metadata: ""
357
+ });
358
+
359
+ // 1. Swap produced output — tokens minted.
360
+ assertGt(tokenCount, 0, "no tokens minted");
361
+
362
+ // 2. Destination terminal received the output tokens (project balance increased).
363
+ uint256 terminalBal = jbTerminalStore.balanceOf(address(jbMultiTerminal), projectId, expectedTokenOut);
364
+ assertGt(terminalBal, 0, "terminal has no balance in expected token");
365
+
366
+ // 3. No leftover tokens stuck in the router.
367
+ assertEq(address(routerTerminal).balance, 0, "router has leftover ETH");
368
+ assertEq(IERC20(address(WETH)).balanceOf(address(routerTerminal)), 0, "router has leftover WETH");
369
+ assertEq(IERC20(expectedTokenOut).balanceOf(address(routerTerminal)), 0, "router has leftover output token");
370
+
371
+ // 4. Payer's balance decreased by exactly the input amount.
372
+ assertEq(payerBalBefore - payer.balance, amountIn, "payer balance mismatch");
373
+ }
374
+
375
+ /// @dev Pay ERC-20 via the router terminal to a project that accepts a different token.
376
+ function _payERC20AndAssert(
377
+ uint256, /* sourceProjectIdForDeal — unused */
378
+ uint256 destProjectId,
379
+ address tokenIn,
380
+ uint256 amountIn,
381
+ address expectedTokenOut
382
+ )
383
+ internal
384
+ {
385
+ deal(tokenIn, payer, amountIn);
386
+
387
+ vm.startPrank(payer);
388
+ IERC20(tokenIn).approve(address(routerTerminal), amountIn);
389
+ uint256 tokenCount = routerTerminal.pay({
390
+ projectId: destProjectId,
391
+ token: tokenIn,
392
+ amount: amountIn,
393
+ beneficiary: beneficiary,
394
+ minReturnedTokens: 0,
395
+ memo: "fork test ERC20 swap",
396
+ metadata: ""
397
+ });
398
+ vm.stopPrank();
399
+
400
+ // Tokens minted.
401
+ assertGt(tokenCount, 0, "no tokens minted for ERC20 swap");
402
+
403
+ // Terminal received expected output token.
404
+ uint256 terminalBal = jbTerminalStore.balanceOf(address(jbMultiTerminal), destProjectId, expectedTokenOut);
405
+ assertGt(terminalBal, 0, "terminal has no balance in expected token");
406
+
407
+ // Router clean.
408
+ assertEq(IERC20(tokenIn).balanceOf(address(routerTerminal)), 0, "router has leftover tokenIn");
409
+ if (expectedTokenOut != JBConstants.NATIVE_TOKEN) {
410
+ assertEq(IERC20(expectedTokenOut).balanceOf(address(routerTerminal)), 0, "router has leftover tokenOut");
411
+ }
412
+ assertEq(address(routerTerminal).balance, 0, "router has leftover ETH");
413
+ }
414
+
415
+ // ───────────────────────── JB Core Deployment
416
+ // ─────────────────────────
417
+
418
+ function _deployJBCore() internal {
419
+ jbPermissions = new JBPermissions(trustedForwarder);
420
+ jbProjects = new JBProjects(multisig, address(0), trustedForwarder);
421
+ jbDirectory = new JBDirectory(jbPermissions, jbProjects, multisig);
422
+ JBERC20 jbErc20 = new JBERC20();
423
+ jbTokens = new JBTokens(jbDirectory, jbErc20);
424
+ jbRulesets = new JBRulesets(jbDirectory);
425
+ jbPrices = new JBPrices(jbDirectory, jbPermissions, jbProjects, multisig, trustedForwarder);
426
+ jbSplits = new JBSplits(jbDirectory);
427
+ jbFundAccessLimits = new JBFundAccessLimits(jbDirectory);
428
+ jbFeelessAddresses = new JBFeelessAddresses(multisig);
429
+
430
+ jbController = new JBController(
431
+ jbDirectory,
432
+ jbFundAccessLimits,
433
+ jbPermissions,
434
+ jbPrices,
435
+ jbProjects,
436
+ jbRulesets,
437
+ jbSplits,
438
+ jbTokens,
439
+ address(0), // omnichainRulesetOperator
440
+ trustedForwarder
441
+ );
442
+
443
+ vm.prank(multisig);
444
+ jbDirectory.setIsAllowedToSetFirstController(address(jbController), true);
445
+
446
+ jbTerminalStore = new JBTerminalStore(jbDirectory, jbPrices, jbRulesets);
447
+
448
+ jbMultiTerminal = new JBMultiTerminal(
449
+ jbFeelessAddresses,
450
+ jbPermissions,
451
+ jbProjects,
452
+ jbSplits,
453
+ jbTerminalStore,
454
+ jbTokens,
455
+ PERMIT2,
456
+ trustedForwarder
457
+ );
458
+ }
459
+
460
+ /// @dev Launch a JB project that accepts `acceptedToken` via the multi terminal.
461
+ function _launchProject(address acceptedToken, uint8 decimals) internal returns (uint256 projectId) {
462
+ JBRulesetMetadata memory metadata = JBRulesetMetadata({
463
+ reservedPercent: 0,
464
+ cashOutTaxRate: 0,
465
+ baseCurrency: uint32(uint160(acceptedToken)),
466
+ pausePay: false,
467
+ pauseCreditTransfers: false,
468
+ allowOwnerMinting: false,
469
+ allowSetCustomToken: false,
470
+ allowTerminalMigration: false,
471
+ allowSetTerminals: false,
472
+ allowSetController: false,
473
+ allowAddAccountingContext: true,
474
+ allowAddPriceFeed: false,
475
+ ownerMustSendPayouts: false,
476
+ holdFees: false,
477
+ useTotalSurplusForCashOuts: false,
478
+ useDataHookForPay: false,
479
+ useDataHookForCashOut: false,
480
+ dataHook: address(0),
481
+ metadata: 0
482
+ });
483
+
484
+ JBRulesetConfig[] memory rulesetConfigs = new JBRulesetConfig[](1);
485
+ rulesetConfigs[0].mustStartAtOrAfter = 0;
486
+ rulesetConfigs[0].duration = 0;
487
+ rulesetConfigs[0].weight = 1_000_000e18; // 1M tokens per unit of currency
488
+ rulesetConfigs[0].weightCutPercent = 0;
489
+ rulesetConfigs[0].approvalHook = IJBRulesetApprovalHook(address(0));
490
+ rulesetConfigs[0].metadata = metadata;
491
+ rulesetConfigs[0].splitGroups = new JBSplitGroup[](0);
492
+ rulesetConfigs[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
493
+
494
+ JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
495
+ tokensToAccept[0] =
496
+ JBAccountingContext({token: acceptedToken, decimals: decimals, currency: uint32(uint160(acceptedToken))});
497
+
498
+ JBTerminalConfig[] memory terminalConfigs = new JBTerminalConfig[](1);
499
+ terminalConfigs[0] = JBTerminalConfig({terminal: jbMultiTerminal, accountingContextsToAccept: tokensToAccept});
500
+
501
+ projectId = jbController.launchProjectFor({
502
+ owner: multisig,
503
+ projectUri: "test-project",
504
+ rulesetConfigurations: rulesetConfigs,
505
+ terminalConfigurations: terminalConfigs,
506
+ memo: ""
507
+ });
508
+ }
509
+ }