diff --git a/api/swap/_utils.ts b/api/swap/_utils.ts index 5ca047801..db2e20aab 100644 --- a/api/swap/_utils.ts +++ b/api/swap/_utils.ts @@ -105,7 +105,7 @@ export async function handleBaseSwapQueryParams( depositor, integratorId, refundAddress, - refundOnOrigin: _refundOnOrigin = "true", + refundOnOrigin: _refundOnOrigin, slippageTolerance, slippage = "auto", // Default to auto slippage skipOriginTxEstimation: _skipOriginTxEstimation = "false", @@ -120,7 +120,6 @@ export async function handleBaseSwapQueryParams( const originChainId = Number(_originChainId); const destinationChainId = Number(_destinationChainId); - const refundOnOrigin = _refundOnOrigin === "true"; const skipOriginTxEstimation = _skipOriginTxEstimation === "true"; const skipChecks = _skipChecks === "true"; const strictTradeType = _strictTradeType === "true"; @@ -168,6 +167,28 @@ export async function handleBaseSwapQueryParams( ? paramToArray(_includeSources) : undefined; + // Check if output token is bridgeable (used for refundOnOrigin default and SVM validation) + const outputBridgeable = isOutputTokenBridgeable( + outputTokenAddress, + originChainId, + destinationChainId + ); + + // Whitelisted output tokens that behave like bridgeable tokens + const isToWhitelistedOutputToken = !![ + TOKEN_SYMBOLS_MAP["USDH-SPOT"].addresses[destinationChainId], + TOKEN_SYMBOLS_MAP.USDH.addresses[destinationChainId], + TOKEN_SYMBOLS_MAP["USDC-SPOT"].addresses[destinationChainId], + TOKEN_SYMBOLS_MAP["USDT-SPOT"].addresses[destinationChainId], + ] + .filter(Boolean) + .find( + (address) => address.toLowerCase() === outputTokenAddress.toLowerCase() + ); + + const isOutputBridgeableOrWhitelisted = + outputBridgeable || isToWhitelistedOutputToken; + if (isOriginSvm || isDestinationSvm) { if (!recipient) { throw new InvalidParamError({ @@ -184,25 +205,7 @@ export async function handleBaseSwapQueryParams( } // Restrict SVM ↔ EVM combinations that require a destination swap - const outputBridgeable = isOutputTokenBridgeable( - outputTokenAddress, - originChainId, - destinationChainId - ); - - // Allows whitelisted output tokens with origin SVM - const isToWhitelistedOutputToken = !![ - TOKEN_SYMBOLS_MAP["USDH-SPOT"].addresses[destinationChainId], - TOKEN_SYMBOLS_MAP.USDH.addresses[destinationChainId], - TOKEN_SYMBOLS_MAP["USDC-SPOT"].addresses[destinationChainId], - TOKEN_SYMBOLS_MAP["USDT-SPOT"].addresses[destinationChainId], - ] - .filter(Boolean) - .find( - (address) => address.toLowerCase() === outputTokenAddress.toLowerCase() - ); - - if (!outputBridgeable && !isToWhitelistedOutputToken) { + if (!isOutputBridgeableOrWhitelisted) { throw new InvalidParamError({ param: "outputToken", message: @@ -263,6 +266,14 @@ export async function handleBaseSwapQueryParams( }); } + // For refundOnOrigin, use explicit value if provided, otherwise default based on output bridgeability: + // - Bridgeable output (B2B, B2BI, A2B): refund on origin (true) + // - Non-bridgeable output (B2A, A2A): refund on destination (false) + const refundOnOrigin = + _refundOnOrigin !== undefined + ? _refundOnOrigin === "true" + : isOutputBridgeableOrWhitelisted; + const amountType = tradeType as AmountType; const amount = BigNumber.from(_amount); diff --git a/e2e-api/swap/execute-approval.test.ts b/e2e-api/swap/execute-approval.test.ts index f04344118..2a2ccf45d 100644 --- a/e2e-api/swap/execute-approval.test.ts +++ b/e2e-api/swap/execute-approval.test.ts @@ -37,6 +37,7 @@ const B2B_BASE_TEST_CASE = { outputToken: TOKEN_SYMBOLS_MAP.USDC, originChainId: CHAIN_IDs.BASE, destinationChainId: CHAIN_IDs.OPTIMISM, + refundOnOrigin: true, slippage: SLIPPAGE, } as const; @@ -50,6 +51,7 @@ const B2A_BASE_TEST_CASE = { outputToken: USDS, originChainId: CHAIN_IDs.OPTIMISM, destinationChainId: CHAIN_IDs.BASE, + refundOnOrigin: true, slippage: SLIPPAGE, } as const; @@ -63,6 +65,7 @@ const A2B_BASE_TEST_CASE = { outputToken: TOKEN_SYMBOLS_MAP.USDC, originChainId: CHAIN_IDs.BASE, destinationChainId: CHAIN_IDs.OPTIMISM, + refundOnOrigin: true, slippage: SLIPPAGE, } as const; @@ -76,6 +79,7 @@ const A2A_BASE_TEST_CASE = { outputToken: USDS, originChainId: CHAIN_IDs.OPTIMISM, destinationChainId: CHAIN_IDs.BASE, + refundOnOrigin: true, slippage: SLIPPAGE, } as const; @@ -96,6 +100,7 @@ describe("execute response of GET /swap/approval", () => { depositor: string; recipient: string; slippage: number | "auto"; + refundOnOrigin: boolean; }) { const response = await axios.get(SWAP_API_URL, { params: { @@ -120,6 +125,7 @@ describe("execute response of GET /swap/approval", () => { outputToken, amounts, slippage, + refundOnOrigin, } = testCase; const amount = amounts[tradeType]; const depositor = opts?.freshDepositorWallet @@ -158,6 +164,7 @@ describe("execute response of GET /swap/approval", () => { depositor, recipient, slippage, + refundOnOrigin, }), getBalance(originChainId, inputTokenAddress, depositor), getBalance(destinationChainId, outputTokenAddress, recipient), diff --git a/e2e-api/swap/fetch-approval.test.ts b/e2e-api/swap/fetch-approval.test.ts index 11655d0de..6551a9a4a 100644 --- a/e2e-api/swap/fetch-approval.test.ts +++ b/e2e-api/swap/fetch-approval.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, test } from "vitest"; import { CHAIN_IDs, TOKEN_SYMBOLS_MAP } from "../../api/_constants"; import { compactAxiosError, ENABLED_ROUTES } from "../../api/_utils"; import { axiosInstance, e2eConfig, JEST_TIMEOUT_MS } from "../utils/config"; +import { AcrossErrorCode } from "../../api/_errors"; const SWAP_API_BASE_URL = e2eConfig.swapApiBaseUrl; const SWAP_API_URL = `${SWAP_API_BASE_URL}/api/swap/approval`; @@ -464,12 +465,32 @@ describe("GET /swap/approval", () => { const response = await axiosInstance.get(SWAP_API_URL, { params: { ...baseParams, - slippage: 0.01, + slippage: 0.06, }, }); expect(response.status).toBe(200); expect(response.data.steps.destinationSwap).toBeDefined(); - expect(response.data.steps.destinationSwap.slippage).toBe(0.01); + expect(response.data.steps.destinationSwap.slippage).toBe(0.06); + }, + JEST_TIMEOUT_MS + ); + + test( + "should error if slippage is less than auto slippage", + async () => { + try { + await axiosInstance.get(SWAP_API_URL, { + params: { + ...baseParams, + slippage: 0.01, + }, + }); + } catch (error: any) { + expect(error.response?.status).toBe(400); + expect(error.response?.data.code).toBe( + AcrossErrorCode.SWAP_SLIPPAGE_INSUFFICIENT + ); + } }, JEST_TIMEOUT_MS ); diff --git a/test/api/swap/_utils.test.ts b/test/api/swap/_utils.test.ts index 357f7d931..6996aff30 100644 --- a/test/api/swap/_utils.test.ts +++ b/test/api/swap/_utils.test.ts @@ -1,5 +1,9 @@ import { BigNumber } from "ethers"; -import { stringifyBigNumProps } from "../../../api/swap/_utils"; +import { + stringifyBigNumProps, + handleBaseSwapQueryParams, +} from "../../../api/swap/_utils"; +import { TOKEN_SYMBOLS_MAP, CHAIN_IDs } from "../../../api/_constants"; describe("stringifyBigNumProps", () => { describe("BigNumber detection and conversion", () => { @@ -227,3 +231,91 @@ describe("stringifyBigNumProps", () => { }); }); }); + +describe("handleBaseSwapQueryParams - refundOnOrigin default behavior", () => { + const baseQueryParams = { + amount: "1000000", + originChainId: CHAIN_IDs.MAINNET.toString(), + destinationChainId: CHAIN_IDs.ARBITRUM.toString(), + depositor: "0x9A8f92a830A5cB89a3816e3D267CB7791c16b04D", + }; + + describe("Routes without destination swaps (should default to refundOnOrigin=true)", () => { + test("should default refundOnOrigin to true for B2B route (USDC->USDC) when not specified", async () => { + const result = await handleBaseSwapQueryParams({ + ...baseQueryParams, + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET], + outputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + // refundOnOrigin not specified + }); + + expect(result.refundOnOrigin).toBe(true); + }); + + test("should default refundOnOrigin to true for B2B route (USDT->USDT)", async () => { + const result = await handleBaseSwapQueryParams({ + ...baseQueryParams, + inputToken: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.MAINNET], + outputToken: TOKEN_SYMBOLS_MAP.USDT.addresses[CHAIN_IDs.ARBITRUM], + }); + + expect(result.refundOnOrigin).toBe(true); + }); + + test("should respect explicit refundOnOrigin=true for B2B route", async () => { + const result = await handleBaseSwapQueryParams({ + ...baseQueryParams, + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET], + outputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + refundOnOrigin: "true", + }); + + expect(result.refundOnOrigin).toBe(true); + }); + + test("should respect explicit refundOnOrigin=false for B2B route even though it will fail validation", async () => { + const result = await handleBaseSwapQueryParams({ + ...baseQueryParams, + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET], + outputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.ARBITRUM], + refundOnOrigin: "false", + }); + + expect(result.refundOnOrigin).toBe(false); + }); + }); + + describe("Routes with destination swaps (should default to refundOnOrigin=false)", () => { + test("should default refundOnOrigin to false for B2A route (USDC->AAVE) when not specified", async () => { + const result = await handleBaseSwapQueryParams({ + ...baseQueryParams, + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET], + outputToken: "0xba5DdD1f9d7F570dc94a51479a000E3BCE967196", // AAVE on Arbitrum (not bridgeable) + }); + + expect(result.refundOnOrigin).toBe(false); + }); + + test("should respect explicit refundOnOrigin=true for B2A route", async () => { + const result = await handleBaseSwapQueryParams({ + ...baseQueryParams, + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET], + outputToken: "0xba5DdD1f9d7F570dc94a51479a000E3BCE967196", // AAVE on Arbitrum (not bridgeable) + refundOnOrigin: "true", + }); + + expect(result.refundOnOrigin).toBe(true); + }); + + test("should respect explicit refundOnOrigin=false for B2A route", async () => { + const result = await handleBaseSwapQueryParams({ + ...baseQueryParams, + inputToken: TOKEN_SYMBOLS_MAP.USDC.addresses[CHAIN_IDs.MAINNET], + outputToken: "0xba5DdD1f9d7F570dc94a51479a000E3BCE967196", // AAVE on Arbitrum (not bridgeable) + refundOnOrigin: "false", + }); + + expect(result.refundOnOrigin).toBe(false); + }); + }); +});