@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 +3 -0
- package/package.json +1 -1
- package/src/JBRouterTerminal.sol +48 -5
- package/test/RouterTerminal.t.sol +193 -0
- package/test/RouterTerminalFork.t.sol +509 -0
package/foundry.toml
CHANGED
package/package.json
CHANGED
package/src/JBRouterTerminal.sol
CHANGED
|
@@ -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:
|
|
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
|
+
}
|