Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 32 additions & 21 deletions api/swap/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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";
Expand Down Expand Up @@ -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({
Expand All @@ -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:
Expand Down Expand Up @@ -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);

Expand Down
7 changes: 7 additions & 0 deletions e2e-api/swap/execute-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand All @@ -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;

Expand All @@ -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;

Expand All @@ -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: {
Expand All @@ -120,6 +125,7 @@ describe("execute response of GET /swap/approval", () => {
outputToken,
amounts,
slippage,
refundOnOrigin,
} = testCase;
const amount = amounts[tradeType];
const depositor = opts?.freshDepositorWallet
Expand Down Expand Up @@ -158,6 +164,7 @@ describe("execute response of GET /swap/approval", () => {
depositor,
recipient,
slippage,
refundOnOrigin,
}),
getBalance(originChainId, inputTokenAddress, depositor),
getBalance(destinationChainId, outputTokenAddress, recipient),
Expand Down
25 changes: 23 additions & 2 deletions e2e-api/swap/fetch-approval.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -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
);
Expand Down
94 changes: 93 additions & 1 deletion test/api/swap/_utils.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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);
});
});
});
Loading