@bananapus/router-terminal-v6 0.0.4 → 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.4",
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.
@@ -575,6 +576,7 @@ contract JBRouterTerminal is
575
576
  bool hasFallback;
576
577
 
577
578
  for (uint256 i; i < terminals.length; i++) {
579
+ // slither-disable-next-line calls-loop
578
580
  JBAccountingContext[] memory contexts = terminals[i].accountingContextsOf(projectId);
579
581
 
580
582
  for (uint256 j; j < contexts.length; j++) {
@@ -611,9 +613,11 @@ contract JBRouterTerminal is
611
613
  function _bestPoolLiquidity(address tokenA, address tokenB) internal view returns (uint128 bestLiquidity) {
612
614
  // Search V3.
613
615
  for (uint256 i; i < 4; i++) {
616
+ // slither-disable-next-line calls-loop
614
617
  address poolAddr = FACTORY.getPool(tokenA, tokenB, _FEE_TIERS[i]);
615
618
  if (poolAddr == address(0)) continue;
616
619
 
620
+ // slither-disable-next-line calls-loop
617
621
  uint128 liquidity = IUniswapV3Pool(poolAddr).liquidity();
618
622
  if (liquidity > bestLiquidity) bestLiquidity = liquidity;
619
623
  }
@@ -708,6 +712,12 @@ contract JBRouterTerminal is
708
712
  callbackData: abi.encode(projectId, tokenIn, tokenOut)
709
713
  });
710
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
+
711
721
  // Unwrap if output is native token.
712
722
  if (tokenOut == JBConstants.NATIVE_TOKEN) WETH.withdraw(amountOut);
713
723
 
@@ -801,8 +811,9 @@ contract JBRouterTerminal is
801
811
 
802
812
  uint160 sqrtPriceLimitX96 = zeroForOne ? TickMath.MIN_SQRT_RATIO + 1 : TickMath.MAX_SQRT_RATIO - 1;
803
813
 
814
+ // V4 sign convention: negative = exact input, positive = exact output.
804
815
  bytes memory result =
805
- POOL_MANAGER.unlock(abi.encode(key, zeroForOne, int256(amount), sqrtPriceLimitX96, minAmountOut));
816
+ POOL_MANAGER.unlock(abi.encode(key, zeroForOne, -int256(amount), sqrtPriceLimitX96, minAmountOut));
806
817
 
807
818
  amountOut = abi.decode(result, (uint256));
808
819
  }
@@ -811,11 +822,13 @@ contract JBRouterTerminal is
811
822
  function _settleV4(Currency currency, uint256 amount) internal {
812
823
  if (Currency.unwrap(currency) == address(0)) {
813
824
  // Native ETH: contract already holds raw ETH.
825
+ // slither-disable-next-line unused-return
814
826
  POOL_MANAGER.settle{value: amount}();
815
827
  } else {
816
828
  // ERC20: sync then transfer then settle.
817
829
  POOL_MANAGER.sync(currency);
818
830
  IERC20(Currency.unwrap(currency)).safeTransfer(address(POOL_MANAGER), amount);
831
+ // slither-disable-next-line unused-return
819
832
  POOL_MANAGER.settle();
820
833
  }
821
834
  }
@@ -935,6 +948,7 @@ contract JBRouterTerminal is
935
948
  returns (uint256 minAmountOut)
936
949
  {
937
950
  PoolId id = key.toId();
951
+ // slither-disable-next-line unused-return
938
952
  (, int24 tick,,) = POOL_MANAGER.getSlot0(id);
939
953
  uint128 liquidity = POOL_MANAGER.getLiquidity(id);
940
954
 
@@ -976,10 +990,12 @@ contract JBRouterTerminal is
976
990
 
977
991
  // Search V3.
978
992
  for (uint256 i; i < 4; i++) {
993
+ // slither-disable-next-line calls-loop
979
994
  address poolAddr = FACTORY.getPool(normalizedTokenIn, normalizedTokenOut, _FEE_TIERS[i]);
980
995
 
981
996
  if (poolAddr == address(0)) continue;
982
997
 
998
+ // slither-disable-next-line calls-loop
983
999
  uint128 poolLiquidity = IUniswapV3Pool(poolAddr).liquidity();
984
1000
 
985
1001
  if (poolLiquidity > bestLiquidity) {
@@ -1035,9 +1051,11 @@ contract JBRouterTerminal is
1035
1051
  hooks: IHooks(address(0))
1036
1052
  });
1037
1053
 
1054
+ // slither-disable-next-line unused-return,calls-loop
1038
1055
  (uint160 sqrtPriceX96,,,) = POOL_MANAGER.getSlot0(key.toId());
1039
1056
  if (sqrtPriceX96 == 0) continue;
1040
1057
 
1058
+ // slither-disable-next-line calls-loop
1041
1059
  uint128 poolLiquidity = POOL_MANAGER.getLiquidity(key.toId());
1042
1060
  if (poolLiquidity > currentBestLiquidity) {
1043
1061
  currentBestLiquidity = poolLiquidity;
@@ -1084,6 +1102,7 @@ contract JBRouterTerminal is
1084
1102
  /// @param amount The amount of the current token.
1085
1103
  /// @param sourceProjectIdOverride When non-zero, use this as the source project ID instead of looking up via
1086
1104
  /// `TOKENS.projectIdOf()`. Reset to 0 after first use.
1105
+ /// @param metadata Bytes in `JBMetadataResolver`'s format (may contain cashOutMinReclaimed).
1087
1106
  /// @return destTerminal The terminal that accepts the final token (address(0) if no direct acceptance found).
1088
1107
  /// @return finalToken The token after all cashouts.
1089
1108
  /// @return finalAmount The amount of the final token.
@@ -1091,14 +1110,24 @@ contract JBRouterTerminal is
1091
1110
  uint256 destProjectId,
1092
1111
  address token,
1093
1112
  uint256 amount,
1094
- uint256 sourceProjectIdOverride
1113
+ uint256 sourceProjectIdOverride,
1114
+ bytes calldata metadata
1095
1115
  )
1096
1116
  internal
1097
1117
  returns (IJBTerminal destTerminal, address finalToken, uint256 finalAmount)
1098
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
+
1099
1127
  while (true) {
1100
1128
  // Skip the destination check on the first iteration if we have a credit override.
1101
1129
  if (sourceProjectIdOverride == 0) {
1130
+ // slither-disable-next-line calls-loop
1102
1131
  destTerminal = DIRECTORY.primaryTerminalOf({projectId: destProjectId, token: token});
1103
1132
  if (address(destTerminal) != address(0)) {
1104
1133
  return (destTerminal, token, amount);
@@ -1106,6 +1135,7 @@ contract JBRouterTerminal is
1106
1135
  }
1107
1136
 
1108
1137
  // Use the override if provided, otherwise look up the project ID from the token.
1138
+ // slither-disable-next-line calls-loop
1109
1139
  uint256 sourceProjectId =
1110
1140
  sourceProjectIdOverride != 0 ? sourceProjectIdOverride : TOKENS.projectIdOf(IJBToken(token));
1111
1141
 
@@ -1119,16 +1149,20 @@ contract JBRouterTerminal is
1119
1149
  _findCashOutPath({sourceProjectId: sourceProjectId, destProjectId: destProjectId});
1120
1150
 
1121
1151
  // Cash out the source project's tokens.
1152
+ // slither-disable-next-line calls-loop
1122
1153
  amount = cashOutTerminal.cashOutTokensOf({
1123
1154
  holder: address(this),
1124
1155
  projectId: sourceProjectId,
1125
1156
  cashOutCount: amount,
1126
1157
  tokenToReclaim: tokenToReclaim,
1127
- minTokensReclaimed: 0,
1158
+ minTokensReclaimed: minTokensReclaimed,
1128
1159
  beneficiary: payable(address(this)),
1129
1160
  metadata: bytes("")
1130
1161
  });
1131
1162
 
1163
+ // Only apply the minimum to the first cashout step.
1164
+ minTokensReclaimed = 0;
1165
+
1132
1166
  // Update for next iteration.
1133
1167
  token = tokenToReclaim;
1134
1168
  sourceProjectIdOverride = 0;
@@ -1155,11 +1189,13 @@ contract JBRouterTerminal is
1155
1189
  address baseFallbackToken;
1156
1190
  IJBCashOutTerminal baseFallbackTerminal;
1157
1191
 
1192
+ // slither-disable-next-line calls-loop
1158
1193
  IJBTerminal[] memory terminals = DIRECTORY.terminalsOf(sourceProjectId);
1159
1194
 
1160
1195
  for (uint256 i; i < terminals.length; i++) {
1161
1196
  // Check if this terminal supports the IJBCashOutTerminal interface.
1162
1197
  {
1198
+ // slither-disable-next-line calls-loop
1163
1199
  try IERC165(address(terminals[i])).supportsInterface(type(IJBCashOutTerminal).interfaceId) returns (
1164
1200
  bool supported
1165
1201
  ) {
@@ -1170,6 +1206,7 @@ contract JBRouterTerminal is
1170
1206
  }
1171
1207
 
1172
1208
  IJBCashOutTerminal terminal = IJBCashOutTerminal(address(terminals[i]));
1209
+ // slither-disable-next-line calls-loop
1173
1210
  JBAccountingContext[] memory contexts = terminals[i].accountingContextsOf(sourceProjectId);
1174
1211
 
1175
1212
  for (uint256 j; j < contexts.length; j++) {
@@ -1177,6 +1214,7 @@ contract JBRouterTerminal is
1177
1214
 
1178
1215
  // Priority 1: Does the destination project directly accept this token?
1179
1216
  {
1217
+ // slither-disable-next-line calls-loop
1180
1218
  IJBTerminal destTerminal =
1181
1219
  DIRECTORY.primaryTerminalOf({projectId: destProjectId, token: contextToken});
1182
1220
  if (address(destTerminal) != address(0)) {
@@ -1186,6 +1224,7 @@ contract JBRouterTerminal is
1186
1224
 
1187
1225
  // Priority 2: Is this a JB project token (so we can recurse)?
1188
1226
  if (address(fallbackTerminal) == address(0) && contextToken != JBConstants.NATIVE_TOKEN) {
1227
+ // slither-disable-next-line calls-loop
1189
1228
  if (TOKENS.projectIdOf(IJBToken(contextToken)) != 0) {
1190
1229
  fallbackToken = contextToken;
1191
1230
  fallbackTerminal = terminal;
@@ -1218,9 +1257,12 @@ contract JBRouterTerminal is
1218
1257
  JBMetadataResolver.getDataFor({id: JBMetadataResolver.getId("cashOutSource"), metadata: metadata});
1219
1258
 
1220
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
+
1221
1263
  (uint256 sourceProjectId, uint256 creditAmount) = abi.decode(creditData, (uint256, uint256));
1222
1264
 
1223
- // Pull credits from the payer.
1265
+ // Pull credits from the payer (requires payer to have granted TRANSFER_CREDITS to this contract).
1224
1266
  TOKENS.transferCreditsFrom({
1225
1267
  holder: _msgSender(), projectId: sourceProjectId, recipient: address(this), count: creditAmount
1226
1268
  });
@@ -1258,6 +1300,7 @@ contract JBRouterTerminal is
1258
1300
  sigDeadline: allowance.sigDeadline
1259
1301
  });
1260
1302
 
1303
+ // slither-disable-next-line reentrancy-events
1261
1304
  try PERMIT2.permit({owner: _msgSender(), permitSingle: permitSingle, signature: allowance.signature}) {}
1262
1305
  catch (bytes memory reason) {
1263
1306
  emit Permit2AllowanceFailed(token, _msgSender(), reason);
@@ -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
+ }