From 18bbb3fc262e027004047478db411baacf2f9715 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:26:36 +0900 Subject: [PATCH 1/5] Add EVM support to OKX provider Extend OKX integration to support EVM chains (Manta) alongside Solana. Add EVM constants (chain index map and native token sentinel), new helper utilities (chainIndex, isEvmChain, dexIds) and update asset/address resolution and referral handling per chain. Refactor buildSwapParams/get_quote/get_quote_data to pass chain context and split quote-data construction into EVM (hex calldata, gas, optional approval) and Solana (base58->base64 serialization, compute unit estimation) flows. Add test fixtures and mocks for Manta (MANTA_USDC_ADDRESS, test wallet, createOkxEvmQuoteRequest) and expand unit/integration tests to cover EVM native/token swaps, approval behavior, and parameter propagation. --- packages/swapper/src/okx/constants.ts | 10 + packages/swapper/src/okx/integration.test.ts | 87 +++++- packages/swapper/src/okx/provider.test.ts | 294 ++++++++++++++++--- packages/swapper/src/okx/provider.ts | 94 ++++-- packages/swapper/src/testkit/mock.ts | 25 ++ 5 files changed, 426 insertions(+), 84 deletions(-) diff --git a/packages/swapper/src/okx/constants.ts b/packages/swapper/src/okx/constants.ts index 5da9aca..5ca9933 100644 --- a/packages/swapper/src/okx/constants.ts +++ b/packages/swapper/src/okx/constants.ts @@ -1,3 +1,13 @@ +import { Chain } from "@gemwallet/types"; + +export const EVM_CHAIN_INDEX: Record = { + [Chain.Manta]: "169", + [Chain.Mantle]: "5000", + [Chain.XLayer]: "196", +}; + +export const EVM_NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; + const SOLANA_DEX_IDS = [ "277", // Raydium "278", // Raydium CL diff --git a/packages/swapper/src/okx/integration.test.ts b/packages/swapper/src/okx/integration.test.ts index d2c109b..cc9419c 100644 --- a/packages/swapper/src/okx/integration.test.ts +++ b/packages/swapper/src/okx/integration.test.ts @@ -1,6 +1,6 @@ -import { QuoteRequest } from "@gemwallet/types"; +import { Chain, QuoteRequest } from "@gemwallet/types"; -import { createSolanaUsdcQuoteRequest } from "../testkit/mock"; +import { createOkxEvmQuoteRequest, createSolanaUsdcQuoteRequest, MANTA_USDC_ADDRESS } from "../testkit/mock"; import { OkxProvider } from "./provider"; const OKX_ENV_KEYS = ["OKX_API_KEY", "OKX_SECRET_KEY", "OKX_API_PASSPHRASE", "OKX_PROJECT_ID"]; @@ -13,27 +13,82 @@ const hasAuth = hasAuthEnv(); const runIntegration = process.env.OKX_INTEGRATION_TEST === "1" && hasAuth; const itIntegration = runIntegration ? it : it.skip; -const REQUEST_TEMPLATE: QuoteRequest = createSolanaUsdcQuoteRequest(); +const SOLANA_REQUEST: QuoteRequest = createSolanaUsdcQuoteRequest(); + +const MANTA_NATIVE_TO_USDC_REQUEST: QuoteRequest = createOkxEvmQuoteRequest({ + from_value: "10000000000000000", +}); + +const MANTA_USDC_TO_NATIVE_REQUEST: QuoteRequest = createOkxEvmQuoteRequest({ + from_asset: { + id: `${Chain.Manta}_${MANTA_USDC_ADDRESS}`, + symbol: "USDC", + decimals: 6, + }, + to_asset: { + id: Chain.Manta, + symbol: "ETH", + decimals: 18, + }, + from_value: "1000000", +}); describe("OKX live integration", () => { jest.setTimeout(60_000); - itIntegration("fetches a live quote and builds quote data", async () => { - const provider = new OkxProvider(process.env.SOLANA_URL || "https://solana-rpc.publicnode.com"); - const quote = await provider.get_quote(REQUEST_TEMPLATE); + describe("Solana", () => { + itIntegration("fetches a live quote and builds quote data", async () => { + const provider = new OkxProvider(process.env.SOLANA_URL || "https://solana-rpc.publicnode.com"); + const quote = await provider.get_quote(SOLANA_REQUEST); + + expect(BigInt(quote.output_value) > BigInt(0)).toBe(true); + expect(quote.route_data).toBeDefined(); + + const quoteData = await provider.get_quote_data(quote); + + expect(quoteData.dataType).toBe("contract"); + expect(typeof quoteData.data).toBe("string"); + expect(quoteData.data.length).toBeGreaterThan(0); + expect(typeof quoteData.to).toBe("string"); + expect(quoteData.to.length).toBeGreaterThan(0); + + const serialized = Buffer.from(quoteData.data, "base64"); + expect(serialized.length).toBeGreaterThan(0); + }); + }); + + describe("EVM (Manta)", () => { + itIntegration("fetches a live quote for native to token swap", async () => { + const provider = new OkxProvider(process.env.SOLANA_URL || "https://solana-rpc.publicnode.com"); + const quote = await provider.get_quote(MANTA_NATIVE_TO_USDC_REQUEST); + + expect(BigInt(quote.output_value) > BigInt(0)).toBe(true); + expect(quote.route_data).toBeDefined(); + }); - expect(BigInt(quote.output_value) > BigInt(0)).toBe(true); - expect(quote.route_data).toBeDefined(); + itIntegration("builds quote data for native to token swap", async () => { + const provider = new OkxProvider(process.env.SOLANA_URL || "https://solana-rpc.publicnode.com"); + const quote = await provider.get_quote(MANTA_NATIVE_TO_USDC_REQUEST); + const quoteData = await provider.get_quote_data(quote); - const quoteData = await provider.get_quote_data(quote); + expect(quoteData.dataType).toBe("contract"); + expect(quoteData.data).toMatch(/^0x/); + expect(quoteData.to).toMatch(/^0x/); + expect(quoteData.value).toBeDefined(); + expect(quoteData.approval).toBeUndefined(); + }); - expect(quoteData.dataType).toBe("contract"); - expect(typeof quoteData.data).toBe("string"); - expect(quoteData.data.length).toBeGreaterThan(0); - expect(typeof quoteData.to).toBe("string"); - expect(quoteData.to.length).toBeGreaterThan(0); + itIntegration("builds quote data with approval for token to native swap", async () => { + const provider = new OkxProvider(process.env.SOLANA_URL || "https://solana-rpc.publicnode.com"); + const quote = await provider.get_quote(MANTA_USDC_TO_NATIVE_REQUEST); + const quoteData = await provider.get_quote_data(quote); - const serialized = Buffer.from(quoteData.data, "base64"); - expect(serialized.length).toBeGreaterThan(0); + expect(quoteData.dataType).toBe("contract"); + expect(quoteData.data).toMatch(/^0x/); + expect(quoteData.approval).toBeDefined(); + expect(quoteData.approval!.token).toBe(MANTA_USDC_ADDRESS); + expect(quoteData.approval!.spender).toMatch(/^0x/); + expect(quoteData.approval!.value).toBe("1000000"); + }); }); }); diff --git a/packages/swapper/src/okx/provider.test.ts b/packages/swapper/src/okx/provider.test.ts index 021df77..cea4da3 100644 --- a/packages/swapper/src/okx/provider.test.ts +++ b/packages/swapper/src/okx/provider.test.ts @@ -1,13 +1,13 @@ -import { Quote } from "@gemwallet/types"; +import { Chain, Quote } from "@gemwallet/types"; import type { OKXDexClient } from "@okx-dex/okx-dex-sdk"; -import { createSolanaUsdcQuoteRequest } from "../testkit/mock"; +import { createOkxEvmQuoteRequest, createSolanaUsdcQuoteRequest, MANTA_USDC_ADDRESS } from "../testkit/mock"; import { OkxProvider } from "./provider"; const SOL_MINT = "11111111111111111111111111111111"; const USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; -function createRequest(slippageBps = 100) { +function createSolanaRequest(slippageBps = 100) { return createSolanaUsdcQuoteRequest({ slippage_bps: slippageBps }); } @@ -19,20 +19,34 @@ function createProvider() { return { provider, getQuote, getSwapData }; } -const mockRoute = { +const solanaRoute = { fromTokenAmount: "1000000", toTokenAmount: "120000000", fromToken: { tokenContractAddress: SOL_MINT }, toToken: { tokenContractAddress: USDC_MINT }, }; -function mockSwapResponse(overrides?: Record) { +const evmRoute = { + fromTokenAmount: "1000000000000000000", + toTokenAmount: "2500000000", + fromToken: { tokenContractAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" }, + toToken: { tokenContractAddress: MANTA_USDC_ADDRESS }, +}; + +const evmTokenRoute = { + fromTokenAmount: "1000000", + toTokenAmount: "950000", + fromToken: { tokenContractAddress: MANTA_USDC_ADDRESS }, + toToken: { tokenContractAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" }, +}; + +function mockSolanaSwapResponse(overrides?: Record) { return { code: "0", msg: "", data: [ { - routerResult: mockRoute, + routerResult: solanaRoute, tx: { from: "SenderAddress", to: "JUP4Fb2cqiRUcaTHdrPC8h2gNsA2ETXiPDD33WcGuJB", @@ -46,77 +60,261 @@ function mockSwapResponse(overrides?: Record) { }; } -function mockQuote(request = createRequest()): Quote { +function mockEvmSwapResponse(overrides?: Record) { + return { + code: "0", + msg: "", + data: [ + { + routerResult: evmRoute, + tx: { + from: "0x1234567890abcdef1234567890abcdef12345678", + to: "0xDEXRouterAddress", + data: "0xabcdef1234567890", + value: "1000000000000000000", + gas: "250000", + ...overrides, + }, + }, + ], + }; +} + +function mockSolanaQuote(request = createSolanaRequest()): Quote { return { quote: request, output_value: "120000000", output_min_value: "120000000", eta_in_seconds: 0, - route_data: mockRoute, + route_data: solanaRoute, + }; +} + +function mockEvmQuote(request = createOkxEvmQuoteRequest()): Quote { + return { + quote: request, + output_value: "2500000000", + output_min_value: "2475000000", + eta_in_seconds: 0, + route_data: evmRoute, + }; +} + +function mockEvmTokenQuote(): Quote { + const request = createOkxEvmQuoteRequest({ + from_asset: { + id: `${Chain.Manta}_${MANTA_USDC_ADDRESS}`, + symbol: "USDC", + decimals: 6, + }, + to_asset: { + id: Chain.Manta, + symbol: "ETH", + decimals: 18, + }, + from_value: "1000000", + }); + return { + quote: request, + output_value: "950000", + output_min_value: "940000", + eta_in_seconds: 0, + route_data: evmTokenRoute, }; } describe("OkxProvider", () => { - describe("get_quote", () => { - it("returns quote from getQuote", async () => { - const { provider, getQuote, getSwapData } = createProvider(); - getQuote.mockResolvedValue({ code: "0", msg: "", data: [mockRoute] }); + describe("Solana", () => { + describe("get_quote", () => { + it("returns quote from getQuote", async () => { + const { provider, getQuote, getSwapData } = createProvider(); + getQuote.mockResolvedValue({ code: "0", msg: "", data: [solanaRoute] }); + + const quote = await provider.get_quote(createSolanaRequest()); + + expect(quote.output_value).toBe("120000000"); + expect(quote.output_min_value).toBe("118800000"); + expect(getSwapData).not.toHaveBeenCalled(); + }); - const quote = await provider.get_quote(createRequest()); + it("passes Solana chain index and dexIds", async () => { + const { provider, getQuote } = createProvider(); + getQuote.mockResolvedValue({ code: "0", msg: "", data: [solanaRoute] }); - expect(quote.output_value).toBe("120000000"); - expect(quote.output_min_value).toBe("118800000"); - expect(getSwapData).not.toHaveBeenCalled(); + await provider.get_quote(createSolanaRequest()); + + const params = getQuote.mock.calls[0][0] as Record; + expect(params.chainIndex).toBe("501"); + expect(params.dexIds).toBeDefined(); + }); + + it("throws when no quote is available", async () => { + const { provider, getQuote } = createProvider(); + getQuote.mockResolvedValue({ code: "0", msg: "", data: [] }); + + await expect(provider.get_quote(createSolanaRequest())).rejects.toThrow(); + }); }); - it("throws when no quote is available", async () => { - const { provider, getQuote } = createProvider(); - getQuote.mockResolvedValue({ code: "0", msg: "", data: [] }); + describe("get_quote_data", () => { + it("calls getSwapData with auto slippage params", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockSolanaSwapResponse()); + + await provider.get_quote_data(mockSolanaQuote()); + + const swapParams = getSwapData.mock.calls[0][0] as Record; + expect(swapParams.autoSlippage).toBe(true); + expect(swapParams.maxAutoSlippagePercent).toBe("2"); + expect(swapParams.slippagePercent).toBe("1"); + }); + + it("returns undefined gasLimit when simulation fails", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockSolanaSwapResponse()); + + const result = await provider.get_quote_data(mockSolanaQuote()); + + expect(result.gasLimit).toBeUndefined(); + }); + + it("falls back to 1% slippage when slippage_bps is 0", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockSolanaSwapResponse()); - await expect(provider.get_quote(createRequest())).rejects.toThrow(); + await provider.get_quote_data(mockSolanaQuote(createSolanaRequest(0))); + + const swapParams = getSwapData.mock.calls[0][0] as Record; + expect(swapParams.slippagePercent).toBe("1"); + expect(swapParams.maxAutoSlippagePercent).toBeUndefined(); + }); + + it("handles simulation failure gracefully", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockSolanaSwapResponse()); + + const result = await provider.get_quote_data(mockSolanaQuote()); + + expect(result.gasLimit).toBeUndefined(); + }); }); }); - describe("get_quote_data", () => { - it("calls getSwapData with auto slippage params", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockSwapResponse()); + describe("EVM", () => { + describe("get_quote", () => { + it("returns quote with Manta chain index", async () => { + const { provider, getQuote } = createProvider(); + getQuote.mockResolvedValue({ code: "0", msg: "", data: [evmRoute] }); - await provider.get_quote_data(mockQuote()); + const quote = await provider.get_quote(createOkxEvmQuoteRequest()); - const swapParams = getSwapData.mock.calls[0][0] as Record; - expect(swapParams.autoSlippage).toBe(true); - expect(swapParams.maxAutoSlippagePercent).toBe("2"); - expect(swapParams.slippagePercent).toBe("1"); - }); + expect(quote.output_value).toBe("2500000000"); + const params = getQuote.mock.calls[0][0] as Record; + expect(params.chainIndex).toBe("169"); + }); - it("returns undefined gasLimit when simulation fails", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockSwapResponse()); + it("does not pass dexIds for EVM chains", async () => { + const { provider, getQuote } = createProvider(); + getQuote.mockResolvedValue({ code: "0", msg: "", data: [evmRoute] }); - const result = await provider.get_quote_data(mockQuote()); + await provider.get_quote(createOkxEvmQuoteRequest()); - expect(result.gasLimit).toBeUndefined(); - }); + const params = getQuote.mock.calls[0][0] as Record; + expect(params.dexIds).toBeUndefined(); + }); - it("falls back to 1% slippage when slippage_bps is 0", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockSwapResponse()); + it("throws for unsupported chain", async () => { + const { provider, getQuote } = createProvider(); + getQuote.mockResolvedValue({ code: "0", msg: "", data: [] }); - await provider.get_quote_data(mockQuote(createRequest(0))); + const request = createOkxEvmQuoteRequest({ + from_asset: { id: Chain.Bitcoin, symbol: "BTC", decimals: 8 }, + }); - const swapParams = getSwapData.mock.calls[0][0] as Record; - expect(swapParams.slippagePercent).toBe("1"); - expect(swapParams.maxAutoSlippagePercent).toBeUndefined(); + await expect(provider.get_quote(request)).rejects.toThrow(); + }); }); - it("handles simulation failure gracefully", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockSwapResponse()); + describe("get_quote_data", () => { + it("returns hex data directly without base58 decoding", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockEvmSwapResponse()); + + const result = await provider.get_quote_data(mockEvmQuote()); + + expect(result.data).toBe("0xabcdef1234567890"); + expect(result.to).toBe("0xDEXRouterAddress"); + expect(result.value).toBe("1000000000000000000"); + }); + + it("uses gas from tx response as gasLimit", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockEvmSwapResponse()); + + const result = await provider.get_quote_data(mockEvmQuote()); + + expect(result.gasLimit).toBe("250000"); + }); + + it("uses gas from tx for EVM chains without RPC simulation", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockEvmSwapResponse()); + + const result = await provider.get_quote_data(mockEvmQuote()); + + expect(result.gasLimit).toBe("250000"); + }); + + it("includes approval data for token swaps", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue( + mockEvmSwapResponse({ + from: "0x1234567890abcdef1234567890abcdef12345678", + to: "0xDEXRouterAddress", + data: "0xswapCalldata", + value: "0", + gas: "300000", + }), + ); + + const result = await provider.get_quote_data(mockEvmTokenQuote()); + + expect(result.approval).toEqual({ + token: MANTA_USDC_ADDRESS, + spender: "0xDEXRouterAddress", + value: "1000000", + }); + }); + + it("does not include approval data for native token swaps", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockEvmSwapResponse()); + + const result = await provider.get_quote_data(mockEvmQuote()); + + expect(result.approval).toBeUndefined(); + }); + + it("passes EVM chain index in swap params", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockEvmSwapResponse()); + + await provider.get_quote_data(mockEvmQuote()); + + const params = getSwapData.mock.calls[0][0] as Record; + expect(params.chainIndex).toBe("169"); + expect(params.dexIds).toBeUndefined(); + }); + + it("uses EVM referrer address", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockEvmSwapResponse()); - const result = await provider.get_quote_data(mockQuote()); + await provider.get_quote_data(mockEvmQuote()); - expect(result.gasLimit).toBeUndefined(); + const params = getSwapData.mock.calls[0][0] as Record; + expect(params.fromTokenReferrerWalletAddress).toBe("0x0D9DAB1A248f63B0a48965bA8435e4de7497a3dC"); + }); }); }); }); diff --git a/packages/swapper/src/okx/provider.ts b/packages/swapper/src/okx/provider.ts index 02fdb0d..23a9704 100644 --- a/packages/swapper/src/okx/provider.ts +++ b/packages/swapper/src/okx/provider.ts @@ -1,6 +1,6 @@ import { AssetId, Chain, Quote, QuoteRequest, SwapQuoteData, SwapQuoteDataType } from "@gemwallet/types"; import { OKXDexClient } from "@okx-dex/okx-dex-sdk"; -import type { QuoteData, SwapParams } from "@okx-dex/okx-dex-sdk"; +import type { QuoteData, SwapParams, TransactionData } from "@okx-dex/okx-dex-sdk"; import bs58 from "bs58"; import { Connection, VersionedTransaction } from "@solana/web3.js"; @@ -12,24 +12,41 @@ import { SwapperException } from "../error"; import { Protocol } from "../protocol"; import { getReferrerAddresses } from "../referrer"; import { + DEFAULT_SLIPPAGE_PERCENT, + EVM_CHAIN_INDEX, + EVM_NATIVE_TOKEN_ADDRESS, SOLANA_CHAIN_INDEX, - SOLANA_NATIVE_TOKEN_ADDRESS, SOLANA_DEX_IDS_PARAM, - DEFAULT_SLIPPAGE_PERCENT, + SOLANA_NATIVE_TOKEN_ADDRESS, } from "./constants"; function bpsToPercent(bps: number): string { return (bps / 100).toString(); } +function chainIndex(chain: Chain): string { + if (chain === Chain.Solana) return SOLANA_CHAIN_INDEX; + const index = EVM_CHAIN_INDEX[chain]; + if (!index) throw new SwapperException({ type: "not_supported_chain" }); + return index; +} + +function isEvmChain(chain: Chain): boolean { + return chain in EVM_CHAIN_INDEX; +} + +function dexIds(chain: Chain): string | undefined { + return chain === Chain.Solana ? SOLANA_DEX_IDS_PARAM : undefined; +} + function assetToTokenAddress(assetId: AssetId): string { - if (assetId.chain !== Chain.Solana) { - throw new SwapperException({ type: "not_supported_chain" }); + if (assetId.chain === Chain.Solana) { + return assetId.tokenId || SOLANA_NATIVE_TOKEN_ADDRESS; } - if (!assetId.tokenId) { - return SOLANA_NATIVE_TOKEN_ADDRESS; + if (isEvmChain(assetId.chain)) { + return assetId.tokenId || EVM_NATIVE_TOKEN_ADDRESS; } - return assetId.tokenId; + throw new SwapperException({ type: "not_supported_chain" }); } function referralFeePercent(request: QuoteRequest): string | undefined { @@ -39,11 +56,17 @@ function referralFeePercent(request: QuoteRequest): string | undefined { return bpsToPercent(request.referral_bps); } -function referralFeeAddress(request: QuoteRequest): string | undefined { +function referralFeeAddress(request: QuoteRequest, chain: Chain): string | undefined { if (request.referral_bps <= 0) { return undefined; } - return getReferrerAddresses().solana || undefined; + const referrers = getReferrerAddresses(); + switch (chain) { + case Chain.Solana: + return referrers.solana || undefined; + default: + return referrers.evm || undefined; + } } function slippagePercent(request: QuoteRequest): string { @@ -61,19 +84,19 @@ function maxAutoSlippagePercent(request: QuoteRequest): string | undefined { return bpsToPercent(request.slippage_bps * 2); } -function buildSwapParams(request: QuoteRequest, route: QuoteData): SwapParams { +function buildSwapParams(request: QuoteRequest, route: QuoteData, chain: Chain): SwapParams { return { - chainIndex: SOLANA_CHAIN_INDEX, + chainIndex: chainIndex(chain), amount: request.from_value, fromTokenAddress: route.fromToken.tokenContractAddress, toTokenAddress: route.toToken.tokenContractAddress, userWalletAddress: request.from_address, - dexIds: SOLANA_DEX_IDS_PARAM, + dexIds: dexIds(chain), slippagePercent: slippagePercent(request), autoSlippage: true, maxAutoSlippagePercent: maxAutoSlippagePercent(request), feePercent: referralFeePercent(request), - fromTokenReferrerWalletAddress: referralFeeAddress(request), + fromTokenReferrerWalletAddress: referralFeeAddress(request, chain), }; } @@ -134,16 +157,17 @@ export class OkxProvider implements Protocol { async get_quote(quoteRequest: QuoteRequest): Promise { const fromAsset = AssetId.fromString(quoteRequest.from_asset.id); const toAsset = AssetId.fromString(quoteRequest.to_asset.id); + const chain = fromAsset.chain; const fromTokenAddress = assetToTokenAddress(fromAsset); const toTokenAddress = assetToTokenAddress(toAsset); const response = await this.client.dex.getQuote({ - chainIndex: SOLANA_CHAIN_INDEX, + chainIndex: chainIndex(chain), amount: quoteRequest.from_value, fromTokenAddress, toTokenAddress, - dexIds: SOLANA_DEX_IDS_PARAM, + dexIds: dexIds(chain), slippagePercent: slippagePercent(quoteRequest), feePercent: referralFeePercent(quoteRequest), }); @@ -175,7 +199,10 @@ export class OkxProvider implements Protocol { throw new SwapperException({ type: "invalid_route" }); } - const response = await this.client.dex.getSwapData(buildSwapParams(quote.quote, route)); + const fromAsset = AssetId.fromString(quote.quote.from_asset.id); + const chain = fromAsset.chain; + + const response = await this.client.dex.getSwapData(buildSwapParams(quote.quote, route, chain)); if (response.code !== "0") { throw new SwapperException({ @@ -189,11 +216,38 @@ export class OkxProvider implements Protocol { throw new SwapperException({ type: "invalid_route" }); } - const gasLimit = await this.estimateComputeUnitLimit(swapData.tx.data); + if (isEvmChain(chain)) { + return this.buildEvmQuoteData(swapData.tx, fromAsset, quote.quote.from_value); + } + + return this.buildSolanaQuoteData(swapData.tx); + } + + private buildEvmQuoteData(tx: TransactionData, fromAsset: AssetId, fromValue: string): SwapQuoteData { + const approval: SwapQuoteData["approval"] = fromAsset.tokenId + ? { + token: fromAsset.tokenId, + spender: tx.to, + value: fromValue, + } + : undefined; + + return { + to: tx.to, + value: tx.value || "0", + data: tx.data, + dataType: SwapQuoteDataType.Contract, + gasLimit: tx.gas || undefined, + approval, + }; + } + + private async buildSolanaQuoteData(tx: TransactionData): Promise { + const gasLimit = await this.estimateComputeUnitLimit(tx.data); let serializedBase64: string; try { - serializedBase64 = Buffer.from(bs58.decode(swapData.tx.data)).toString("base64"); + serializedBase64 = Buffer.from(bs58.decode(tx.data)).toString("base64"); } catch (error) { throw new SwapperException({ type: "transaction_error", @@ -202,7 +256,7 @@ export class OkxProvider implements Protocol { } return { - to: swapData.tx.to, + to: tx.to, value: "0", data: serializedBase64, dataType: SwapQuoteDataType.Contract, diff --git a/packages/swapper/src/testkit/mock.ts b/packages/swapper/src/testkit/mock.ts index c433ee7..2791d95 100644 --- a/packages/swapper/src/testkit/mock.ts +++ b/packages/swapper/src/testkit/mock.ts @@ -9,6 +9,9 @@ export const SOLANA_USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; export const APTOS_USDC_FA = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"; export const APTOS_USDT_FA = "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"; +export const MANTA_TEST_WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; +export const MANTA_USDC_ADDRESS = "0xb73603C5d87fA094B7314C74ACE2e64D165016fb"; + export const SOL_ASSET = { id: Chain.Solana, symbol: "SOL", @@ -89,6 +92,28 @@ export const APTOS_USDC_REQUEST_TEMPLATE: QuoteRequest = { slippage_bps: 100, }; +export const OKX_MANTA_USDC_REQUEST_TEMPLATE: QuoteRequest = { + from_address: MANTA_TEST_WALLET_ADDRESS, + to_address: MANTA_TEST_WALLET_ADDRESS, + from_asset: { + id: Chain.Manta, + symbol: "ETH", + decimals: 18, + }, + to_asset: { + id: `${Chain.Manta}_${MANTA_USDC_ADDRESS}`, + symbol: "USDC", + decimals: 6, + }, + from_value: "1000000000000000000", + referral_bps: 50, + slippage_bps: 100, +}; + +export function createOkxEvmQuoteRequest(overrides: Partial = {}): QuoteRequest { + return createQuoteRequest(OKX_MANTA_USDC_REQUEST_TEMPLATE, overrides); +} + export function createQuoteRequest(base: QuoteRequest, overrides: Partial = {}): QuoteRequest { return { ...base, From 975b122e6960de0323d835bca54477a938efe8e7 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:44:03 +0900 Subject: [PATCH 2/5] code cleanup --- AGENTS.md | 1 + packages/swapper/src/okx/constants.ts | 5 +- packages/swapper/src/okx/provider.test.ts | 94 ++--------------------- packages/swapper/src/okx/provider.ts | 14 ++-- 4 files changed, 18 insertions(+), 96 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b497af2..1ca6e99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ ## Agent‑Specific Instructions (all code agents) - Use `pnpm` workspace filters (`--filter`) and Justfile tasks; avoid changing file layout. - Keep edits minimal and focused; update adjacent docs/tests when touching APIs or providers. +- Keep changes concise to reduce reviewer burden: avoid redundant code, consolidate related tests, and minimize the diff surface area. - Fix any lint issues in files you touch: `pnpm oxlint ...`. - Prefer mocks for external calls; do not add unvetted network dependencies. - Reflect provider additions/removals in `apps/api/src/index.ts` and docs; exclude unimplemented protocols. diff --git a/packages/swapper/src/okx/constants.ts b/packages/swapper/src/okx/constants.ts index 5ca9933..f66d704 100644 --- a/packages/swapper/src/okx/constants.ts +++ b/packages/swapper/src/okx/constants.ts @@ -1,6 +1,7 @@ import { Chain } from "@gemwallet/types"; -export const EVM_CHAIN_INDEX: Record = { +export const CHAIN_INDEX: Record = { + [Chain.Solana]: "501", [Chain.Manta]: "169", [Chain.Mantle]: "5000", [Chain.XLayer]: "196", @@ -25,7 +26,7 @@ const SOLANA_DEX_IDS = [ "345", // OpenBook V2 ]; -export const SOLANA_CHAIN_INDEX = "501"; +export const SOLANA_CHAIN_INDEX = CHAIN_INDEX[Chain.Solana]; export const SOLANA_NATIVE_TOKEN_ADDRESS = "11111111111111111111111111111111"; export const SOLANA_DEX_IDS_PARAM = SOLANA_DEX_IDS.join(","); export const DEFAULT_SLIPPAGE_PERCENT = "1"; diff --git a/packages/swapper/src/okx/provider.test.ts b/packages/swapper/src/okx/provider.test.ts index cea4da3..551f50f 100644 --- a/packages/swapper/src/okx/provider.test.ts +++ b/packages/swapper/src/okx/provider.test.ts @@ -126,7 +126,7 @@ function mockEvmTokenQuote(): Quote { describe("OkxProvider", () => { describe("Solana", () => { describe("get_quote", () => { - it("returns quote from getQuote", async () => { + it("returns quote with chain index and dexIds", async () => { const { provider, getQuote, getSwapData } = createProvider(); getQuote.mockResolvedValue({ code: "0", msg: "", data: [solanaRoute] }); @@ -135,13 +135,6 @@ describe("OkxProvider", () => { expect(quote.output_value).toBe("120000000"); expect(quote.output_min_value).toBe("118800000"); expect(getSwapData).not.toHaveBeenCalled(); - }); - - it("passes Solana chain index and dexIds", async () => { - const { provider, getQuote } = createProvider(); - getQuote.mockResolvedValue({ code: "0", msg: "", data: [solanaRoute] }); - - await provider.get_quote(createSolanaRequest()); const params = getQuote.mock.calls[0][0] as Record; expect(params.chainIndex).toBe("501"); @@ -188,21 +181,12 @@ describe("OkxProvider", () => { expect(swapParams.slippagePercent).toBe("1"); expect(swapParams.maxAutoSlippagePercent).toBeUndefined(); }); - - it("handles simulation failure gracefully", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockSolanaSwapResponse()); - - const result = await provider.get_quote_data(mockSolanaQuote()); - - expect(result.gasLimit).toBeUndefined(); - }); }); }); describe("EVM", () => { describe("get_quote", () => { - it("returns quote with Manta chain index", async () => { + it("returns quote with Manta chain index and no dexIds", async () => { const { provider, getQuote } = createProvider(); getQuote.mockResolvedValue({ code: "0", msg: "", data: [evmRoute] }); @@ -211,32 +195,12 @@ describe("OkxProvider", () => { expect(quote.output_value).toBe("2500000000"); const params = getQuote.mock.calls[0][0] as Record; expect(params.chainIndex).toBe("169"); - }); - - it("does not pass dexIds for EVM chains", async () => { - const { provider, getQuote } = createProvider(); - getQuote.mockResolvedValue({ code: "0", msg: "", data: [evmRoute] }); - - await provider.get_quote(createOkxEvmQuoteRequest()); - - const params = getQuote.mock.calls[0][0] as Record; expect(params.dexIds).toBeUndefined(); }); - - it("throws for unsupported chain", async () => { - const { provider, getQuote } = createProvider(); - getQuote.mockResolvedValue({ code: "0", msg: "", data: [] }); - - const request = createOkxEvmQuoteRequest({ - from_asset: { id: Chain.Bitcoin, symbol: "BTC", decimals: 8 }, - }); - - await expect(provider.get_quote(request)).rejects.toThrow(); - }); }); describe("get_quote_data", () => { - it("returns hex data directly without base58 decoding", async () => { + it("returns hex data without gasLimit for native swaps", async () => { const { provider, getSwapData } = createProvider(); getSwapData.mockResolvedValue(mockEvmSwapResponse()); @@ -245,24 +209,11 @@ describe("OkxProvider", () => { expect(result.data).toBe("0xabcdef1234567890"); expect(result.to).toBe("0xDEXRouterAddress"); expect(result.value).toBe("1000000000000000000"); - }); - - it("uses gas from tx response as gasLimit", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockEvmSwapResponse()); - - const result = await provider.get_quote_data(mockEvmQuote()); - - expect(result.gasLimit).toBe("250000"); - }); - - it("uses gas from tx for EVM chains without RPC simulation", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockEvmSwapResponse()); - - const result = await provider.get_quote_data(mockEvmQuote()); + expect(result.gasLimit).toBeUndefined(); + expect(result.approval).toBeUndefined(); - expect(result.gasLimit).toBe("250000"); + const params = getSwapData.mock.calls[0][0] as Record; + expect(params.fromTokenReferrerWalletAddress).toBe("0x0D9DAB1A248f63B0a48965bA8435e4de7497a3dC"); }); it("includes approval data for token swaps", async () => { @@ -279,42 +230,13 @@ describe("OkxProvider", () => { const result = await provider.get_quote_data(mockEvmTokenQuote()); + expect(result.gasLimit).toBe("300000"); expect(result.approval).toEqual({ token: MANTA_USDC_ADDRESS, spender: "0xDEXRouterAddress", value: "1000000", }); }); - - it("does not include approval data for native token swaps", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockEvmSwapResponse()); - - const result = await provider.get_quote_data(mockEvmQuote()); - - expect(result.approval).toBeUndefined(); - }); - - it("passes EVM chain index in swap params", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockEvmSwapResponse()); - - await provider.get_quote_data(mockEvmQuote()); - - const params = getSwapData.mock.calls[0][0] as Record; - expect(params.chainIndex).toBe("169"); - expect(params.dexIds).toBeUndefined(); - }); - - it("uses EVM referrer address", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockEvmSwapResponse()); - - await provider.get_quote_data(mockEvmQuote()); - - const params = getSwapData.mock.calls[0][0] as Record; - expect(params.fromTokenReferrerWalletAddress).toBe("0x0D9DAB1A248f63B0a48965bA8435e4de7497a3dC"); - }); }); }); }); diff --git a/packages/swapper/src/okx/provider.ts b/packages/swapper/src/okx/provider.ts index 23a9704..76cc976 100644 --- a/packages/swapper/src/okx/provider.ts +++ b/packages/swapper/src/okx/provider.ts @@ -12,10 +12,9 @@ import { SwapperException } from "../error"; import { Protocol } from "../protocol"; import { getReferrerAddresses } from "../referrer"; import { + CHAIN_INDEX, DEFAULT_SLIPPAGE_PERCENT, - EVM_CHAIN_INDEX, EVM_NATIVE_TOKEN_ADDRESS, - SOLANA_CHAIN_INDEX, SOLANA_DEX_IDS_PARAM, SOLANA_NATIVE_TOKEN_ADDRESS, } from "./constants"; @@ -25,14 +24,13 @@ function bpsToPercent(bps: number): string { } function chainIndex(chain: Chain): string { - if (chain === Chain.Solana) return SOLANA_CHAIN_INDEX; - const index = EVM_CHAIN_INDEX[chain]; + const index = CHAIN_INDEX[chain]; if (!index) throw new SwapperException({ type: "not_supported_chain" }); return index; } function isEvmChain(chain: Chain): boolean { - return chain in EVM_CHAIN_INDEX; + return chain in CHAIN_INDEX && chain !== Chain.Solana; } function dexIds(chain: Chain): string | undefined { @@ -63,9 +61,9 @@ function referralFeeAddress(request: QuoteRequest, chain: Chain): string | undef const referrers = getReferrerAddresses(); switch (chain) { case Chain.Solana: - return referrers.solana || undefined; + return referrers.solana; default: - return referrers.evm || undefined; + return referrers.evm; } } @@ -237,7 +235,7 @@ export class OkxProvider implements Protocol { value: tx.value || "0", data: tx.data, dataType: SwapQuoteDataType.Contract, - gasLimit: tx.gas || undefined, + gasLimit: approval ? tx.gas : undefined, approval, }; } From eb6175c4d781c2c794bcf7cb8342271d79d9b7a2 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:41:08 +0900 Subject: [PATCH 3/5] Add EVM approval checks & XLayer integration Add EVM allowance/json-rpc helpers and integrate approval checks into Okx provider; add default RPC URLs and gas limits for Manta/Mantle/XLayer. Update Okx provider to fetch dexTokenApproveAddress from chain data, use it as spender, and call checkEvmApproval to avoid unnecessary approvals; set gasLimit from a per-chain mapping when approval is required. Update tests and mocks to target XLayer (replace Manta fixtures), switch integration tests to use a shared INTEGRATION_TEST env flag, and add a new integration test to fetch chain data. Add .env.example entries for MANTA, MANTLE and XLAYER RPCs and a justfile target test-integration. Minor config tweak: remove baseUrl from apps/api/tsconfig.json. --- .env.example | 3 + apps/api/tsconfig.json | 1 - justfile | 3 + packages/swapper/src/chain/evm/allowance.ts | 43 ++++++++++++ packages/swapper/src/chain/evm/jsonrpc.ts | 27 ++++++++ packages/swapper/src/okx/constants.ts | 9 +++ packages/swapper/src/okx/integration.test.ts | 67 ++++++++++++------- packages/swapper/src/okx/provider.test.ts | 55 ++++++++------- packages/swapper/src/okx/provider.ts | 33 ++++++--- packages/swapper/src/orca/integration.test.ts | 2 +- .../swapper/src/stonfi/integration.test.ts | 2 +- packages/swapper/src/testkit/mock.ts | 22 +++--- 12 files changed, 192 insertions(+), 75 deletions(-) create mode 100644 packages/swapper/src/chain/evm/allowance.ts create mode 100644 packages/swapper/src/chain/evm/jsonrpc.ts diff --git a/.env.example b/.env.example index 740d0d6..7f76707 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,9 @@ SOLANA_URL=https://solana-rpc.publicnode.com SUI_URL=https://fullnode.mainnet.sui.io TON_URL=https://toncenter.com APTOS_URL=https://fullnode.mainnet.aptoslabs.com/v1 +MANTA_URL=https://pacific-rpc.manta.network/http +MANTLE_URL=https://rpc.mantle.xyz +XLAYER_URL=https://rpc.xlayer.tech OKX_API_KEY= OKX_SECRET_KEY= OKX_API_PASSPHRASE= diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index c588c95..e2b3f90 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -3,7 +3,6 @@ "target": "ES2016", "module": "CommonJS", "outDir": "./dist", - "baseUrl": ".", "paths": { "@gemwallet/swapper": ["../../packages/swapper/src/index.ts"], "@gemwallet/types": ["../../packages/types/src/index.ts"] diff --git a/justfile b/justfile index b041bf2..413bc03 100644 --- a/justfile +++ b/justfile @@ -28,6 +28,9 @@ format-check: dead-code: pnpm run dead-code +test-integration: + cd packages/swapper && INTEGRATION_TEST=1 npx jest --testPathPatterns='integration' + check: lint format-check build test bench PROVIDER="orca" ITERATIONS="2": diff --git a/packages/swapper/src/chain/evm/allowance.ts b/packages/swapper/src/chain/evm/allowance.ts new file mode 100644 index 0000000..9f0abc1 --- /dev/null +++ b/packages/swapper/src/chain/evm/allowance.ts @@ -0,0 +1,43 @@ +import { Chain } from "@gemwallet/types"; + +import { evmRpcUrl, jsonRpcCall } from "./jsonrpc"; + +export async function getErc20Allowance(rpcUrl: string, token: string, owner: string, spender: string): Promise { + const ownerPadded = owner.slice(2).toLowerCase().padStart(64, "0"); + const spenderPadded = spender.slice(2).toLowerCase().padStart(64, "0"); + const data = `0xdd62ed3e${ownerPadded}${spenderPadded}`; + + const result = await jsonRpcCall(rpcUrl, "eth_call", [{ to: token, data }, "latest"]); + if (!result || result === "0x") { + return BigInt(0); + } + return BigInt(result); +} + +export interface ApprovalResult { + token: string; + spender: string; + value: string; +} + +export async function checkEvmApproval( + chain: Chain, + tokenId: string | undefined, + owner: string, + fromValue: string, + spender?: string, +): Promise { + if (!tokenId || !spender) { + return undefined; + } + const rpcUrl = evmRpcUrl(chain); + if (rpcUrl) { + try { + const allowance = await getErc20Allowance(rpcUrl, tokenId, owner, spender); + if (allowance >= BigInt(fromValue)) { + return undefined; + } + } catch { /* fall through to return approval */ } + } + return { token: tokenId, spender, value: fromValue }; +} diff --git a/packages/swapper/src/chain/evm/jsonrpc.ts b/packages/swapper/src/chain/evm/jsonrpc.ts new file mode 100644 index 0000000..6f8752f --- /dev/null +++ b/packages/swapper/src/chain/evm/jsonrpc.ts @@ -0,0 +1,27 @@ +import { Chain } from "@gemwallet/types"; + +const DEFAULT_RPC_URLS: Partial> = { + [Chain.Manta]: "https://pacific-rpc.manta.network/http", + [Chain.Mantle]: "https://rpc.mantle.xyz", + [Chain.XLayer]: "https://rpc.xlayer.tech", +}; + +export function evmRpcUrl(chain: Chain): string | undefined { + const envKey = `${chain.toUpperCase()}_URL`; + return process.env[envKey] || DEFAULT_RPC_URLS[chain]; +} + +interface JsonRpcResponse { + result?: T; + error?: { code: number; message: string }; +} + +export async function jsonRpcCall(rpcUrl: string, method: string, params: unknown[]): Promise { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const json = (await response.json()) as JsonRpcResponse; + return json.result; +} diff --git a/packages/swapper/src/okx/constants.ts b/packages/swapper/src/okx/constants.ts index f66d704..b1d0884 100644 --- a/packages/swapper/src/okx/constants.ts +++ b/packages/swapper/src/okx/constants.ts @@ -30,3 +30,12 @@ export const SOLANA_CHAIN_INDEX = CHAIN_INDEX[Chain.Solana]; export const SOLANA_NATIVE_TOKEN_ADDRESS = "11111111111111111111111111111111"; export const SOLANA_DEX_IDS_PARAM = SOLANA_DEX_IDS.join(","); export const DEFAULT_SLIPPAGE_PERCENT = "1"; +const EVM_GAS_LIMITS: Partial> = { + [Chain.Manta]: "600000", + [Chain.Mantle]: "500000000", + [Chain.XLayer]: "800000", +}; + +export function evmGasLimit(chain: Chain): string | undefined { + return EVM_GAS_LIMITS[chain]; +} diff --git a/packages/swapper/src/okx/integration.test.ts b/packages/swapper/src/okx/integration.test.ts index cc9419c..b49fdf0 100644 --- a/packages/swapper/src/okx/integration.test.ts +++ b/packages/swapper/src/okx/integration.test.ts @@ -1,6 +1,11 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("dotenv").config({ path: "../../.env" }); + import { Chain, QuoteRequest } from "@gemwallet/types"; +import { OKXDexClient } from "@okx-dex/okx-dex-sdk"; -import { createOkxEvmQuoteRequest, createSolanaUsdcQuoteRequest, MANTA_USDC_ADDRESS } from "../testkit/mock"; +import { createOkxEvmQuoteRequest, createSolanaUsdcQuoteRequest, XLAYER_USD0_ADDRESS } from "../testkit/mock"; +import { CHAIN_INDEX } from "./constants"; import { OkxProvider } from "./provider"; const OKX_ENV_KEYS = ["OKX_API_KEY", "OKX_SECRET_KEY", "OKX_API_PASSPHRASE", "OKX_PROJECT_ID"]; @@ -10,27 +15,26 @@ function hasAuthEnv(): boolean { } const hasAuth = hasAuthEnv(); -const runIntegration = process.env.OKX_INTEGRATION_TEST === "1" && hasAuth; +const runIntegration = process.env.INTEGRATION_TEST === "1" && hasAuth; const itIntegration = runIntegration ? it : it.skip; +function createClient(): OKXDexClient { + return new OKXDexClient({ + apiKey: process.env.OKX_API_KEY!, + secretKey: process.env.OKX_SECRET_KEY!, + apiPassphrase: process.env.OKX_API_PASSPHRASE!, + projectId: process.env.OKX_PROJECT_ID!, + }); +} + const SOLANA_REQUEST: QuoteRequest = createSolanaUsdcQuoteRequest(); -const MANTA_NATIVE_TO_USDC_REQUEST: QuoteRequest = createOkxEvmQuoteRequest({ +const XLAYER_NATIVE_TO_USD0_REQUEST: QuoteRequest = createOkxEvmQuoteRequest({ from_value: "10000000000000000", }); -const MANTA_USDC_TO_NATIVE_REQUEST: QuoteRequest = createOkxEvmQuoteRequest({ - from_asset: { - id: `${Chain.Manta}_${MANTA_USDC_ADDRESS}`, - symbol: "USDC", - decimals: 6, - }, - to_asset: { - id: Chain.Manta, - symbol: "ETH", - decimals: 18, - }, - from_value: "1000000", +const XLAYER_NATIVE_TO_USD0_LARGE_REQUEST: QuoteRequest = createOkxEvmQuoteRequest({ + from_value: "100000000000000000", }); describe("OKX live integration", () => { @@ -57,10 +61,10 @@ describe("OKX live integration", () => { }); }); - describe("EVM (Manta)", () => { + describe("EVM (XLayer)", () => { itIntegration("fetches a live quote for native to token swap", async () => { const provider = new OkxProvider(process.env.SOLANA_URL || "https://solana-rpc.publicnode.com"); - const quote = await provider.get_quote(MANTA_NATIVE_TO_USDC_REQUEST); + const quote = await provider.get_quote(XLAYER_NATIVE_TO_USD0_REQUEST); expect(BigInt(quote.output_value) > BigInt(0)).toBe(true); expect(quote.route_data).toBeDefined(); @@ -68,27 +72,40 @@ describe("OKX live integration", () => { itIntegration("builds quote data for native to token swap", async () => { const provider = new OkxProvider(process.env.SOLANA_URL || "https://solana-rpc.publicnode.com"); - const quote = await provider.get_quote(MANTA_NATIVE_TO_USDC_REQUEST); + const quote = await provider.get_quote(XLAYER_NATIVE_TO_USD0_REQUEST); const quoteData = await provider.get_quote_data(quote); expect(quoteData.dataType).toBe("contract"); expect(quoteData.data).toMatch(/^0x/); expect(quoteData.to).toMatch(/^0x/); expect(quoteData.value).toBeDefined(); - expect(quoteData.approval).toBeUndefined(); }); - itIntegration("builds quote data with approval for token to native swap", async () => { + itIntegration("builds quote data with gasLimit for larger native swap", async () => { const provider = new OkxProvider(process.env.SOLANA_URL || "https://solana-rpc.publicnode.com"); - const quote = await provider.get_quote(MANTA_USDC_TO_NATIVE_REQUEST); + const quote = await provider.get_quote(XLAYER_NATIVE_TO_USD0_LARGE_REQUEST); const quoteData = await provider.get_quote_data(quote); expect(quoteData.dataType).toBe("contract"); expect(quoteData.data).toMatch(/^0x/); - expect(quoteData.approval).toBeDefined(); - expect(quoteData.approval!.token).toBe(MANTA_USDC_ADDRESS); - expect(quoteData.approval!.spender).toMatch(/^0x/); - expect(quoteData.approval!.value).toBe("1000000"); + expect(quoteData.gasLimit).toBe("800000"); + expect(quoteData.approval).toBeUndefined(); + }); + }); + + describe("Chain Data", () => { + itIntegration("fetches XLayer approve spender address", async () => { + const client = createClient(); + const response = await client.dex.getChainData(CHAIN_INDEX[Chain.XLayer]); + + expect(response.code).toBe("0"); + expect(response.data.length).toBeGreaterThan(0); + + const chainData = response.data[0]; + console.log(`\nXLayer chain data:`); + console.log(` chainIndex: ${chainData.chainIndex}`); + console.log(` chainName: ${chainData.chainName}`); + console.log(` dexTokenApproveAddress: ${chainData.dexTokenApproveAddress}`); }); }); }); diff --git a/packages/swapper/src/okx/provider.test.ts b/packages/swapper/src/okx/provider.test.ts index 551f50f..9fac996 100644 --- a/packages/swapper/src/okx/provider.test.ts +++ b/packages/swapper/src/okx/provider.test.ts @@ -1,7 +1,7 @@ import { Chain, Quote } from "@gemwallet/types"; import type { OKXDexClient } from "@okx-dex/okx-dex-sdk"; -import { createOkxEvmQuoteRequest, createSolanaUsdcQuoteRequest, MANTA_USDC_ADDRESS } from "../testkit/mock"; +import { createOkxEvmQuoteRequest, createSolanaUsdcQuoteRequest, XLAYER_USD0_ADDRESS } from "../testkit/mock"; import { OkxProvider } from "./provider"; const SOL_MINT = "11111111111111111111111111111111"; @@ -11,12 +11,18 @@ function createSolanaRequest(slippageBps = 100) { return createSolanaUsdcQuoteRequest({ slippage_bps: slippageBps }); } +const MOCK_APPROVE_ADDRESS = "0x57df6092665eb6058DE53939612413ff4B09114E"; + function createProvider() { const getQuote = jest.fn(); const getSwapData = jest.fn(); - const client = { dex: { getQuote, getSwapData } } as unknown as OKXDexClient; + const getChainData = jest.fn().mockResolvedValue({ + code: "0", + data: [{ dexTokenApproveAddress: MOCK_APPROVE_ADDRESS }], + }); + const client = { dex: { getQuote, getSwapData, getChainData } } as unknown as OKXDexClient; const provider = new OkxProvider("https://localhost:8899", client); - return { provider, getQuote, getSwapData }; + return { provider, getQuote, getSwapData, getChainData }; } const solanaRoute = { @@ -30,13 +36,13 @@ const evmRoute = { fromTokenAmount: "1000000000000000000", toTokenAmount: "2500000000", fromToken: { tokenContractAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" }, - toToken: { tokenContractAddress: MANTA_USDC_ADDRESS }, + toToken: { tokenContractAddress: XLAYER_USD0_ADDRESS }, }; const evmTokenRoute = { - fromTokenAmount: "1000000", - toTokenAmount: "950000", - fromToken: { tokenContractAddress: MANTA_USDC_ADDRESS }, + fromTokenAmount: "1000000000000000000", + toTokenAmount: "950000000000000000", + fromToken: { tokenContractAddress: XLAYER_USD0_ADDRESS }, toToken: { tokenContractAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" }, }; @@ -103,16 +109,16 @@ function mockEvmQuote(request = createOkxEvmQuoteRequest()): Quote { function mockEvmTokenQuote(): Quote { const request = createOkxEvmQuoteRequest({ from_asset: { - id: `${Chain.Manta}_${MANTA_USDC_ADDRESS}`, - symbol: "USDC", - decimals: 6, + id: `${Chain.XLayer}_${XLAYER_USD0_ADDRESS}`, + symbol: "USD0", + decimals: 18, }, to_asset: { - id: Chain.Manta, - symbol: "ETH", + id: Chain.XLayer, + symbol: "OKB", decimals: 18, }, - from_value: "1000000", + from_value: "1000000000000000000", }); return { quote: request, @@ -186,7 +192,7 @@ describe("OkxProvider", () => { describe("EVM", () => { describe("get_quote", () => { - it("returns quote with Manta chain index and no dexIds", async () => { + it("returns quote with XLayer chain index and no dexIds", async () => { const { provider, getQuote } = createProvider(); getQuote.mockResolvedValue({ code: "0", msg: "", data: [evmRoute] }); @@ -194,13 +200,13 @@ describe("OkxProvider", () => { expect(quote.output_value).toBe("2500000000"); const params = getQuote.mock.calls[0][0] as Record; - expect(params.chainIndex).toBe("169"); + expect(params.chainIndex).toBe("196"); expect(params.dexIds).toBeUndefined(); }); }); describe("get_quote_data", () => { - it("returns hex data without gasLimit for native swaps", async () => { + it("returns swap gasLimit for native swaps", async () => { const { provider, getSwapData } = createProvider(); getSwapData.mockResolvedValue(mockEvmSwapResponse()); @@ -209,32 +215,31 @@ describe("OkxProvider", () => { expect(result.data).toBe("0xabcdef1234567890"); expect(result.to).toBe("0xDEXRouterAddress"); expect(result.value).toBe("1000000000000000000"); - expect(result.gasLimit).toBeUndefined(); + expect(result.gasLimit).toBe("800000"); expect(result.approval).toBeUndefined(); const params = getSwapData.mock.calls[0][0] as Record; expect(params.fromTokenReferrerWalletAddress).toBe("0x0D9DAB1A248f63B0a48965bA8435e4de7497a3dC"); }); - it("includes approval data for token swaps", async () => { + it("returns approval with spender from chain data for token swaps", async () => { const { provider, getSwapData } = createProvider(); getSwapData.mockResolvedValue( mockEvmSwapResponse({ - from: "0x1234567890abcdef1234567890abcdef12345678", - to: "0xDEXRouterAddress", data: "0xswapCalldata", value: "0", - gas: "300000", }), ); const result = await provider.get_quote_data(mockEvmTokenQuote()); - expect(result.gasLimit).toBe("300000"); + expect(result.data).toBe("0xswapCalldata"); + expect(result.value).toBe("0"); + expect(result.gasLimit).toBe("800000"); expect(result.approval).toEqual({ - token: MANTA_USDC_ADDRESS, - spender: "0xDEXRouterAddress", - value: "1000000", + token: XLAYER_USD0_ADDRESS, + spender: MOCK_APPROVE_ADDRESS, + value: "1000000000000000000", }); }); }); diff --git a/packages/swapper/src/okx/provider.ts b/packages/swapper/src/okx/provider.ts index 76cc976..b7ae071 100644 --- a/packages/swapper/src/okx/provider.ts +++ b/packages/swapper/src/okx/provider.ts @@ -6,6 +6,7 @@ import bs58 from "bs58"; import { Connection, VersionedTransaction } from "@solana/web3.js"; import { BigIntMath } from "../bigint_math"; +import { checkEvmApproval } from "../chain/evm/allowance"; import { DEFAULT_COMMITMENT } from "../chain/solana/constants"; import { estimateComputeUnitLimit as simulateComputeUnits } from "../chain/solana/tx_builder"; import { SwapperException } from "../error"; @@ -17,6 +18,7 @@ import { EVM_NATIVE_TOKEN_ADDRESS, SOLANA_DEX_IDS_PARAM, SOLANA_NATIVE_TOKEN_ADDRESS, + evmGasLimit, } from "./constants"; function bpsToPercent(bps: number): string { @@ -140,6 +142,7 @@ export class OkxProvider implements Protocol { apiPassphrase: apiPassphrase!, projectId: projectId!, }); + } private async estimateComputeUnitLimit(txData: string): Promise { @@ -199,8 +202,12 @@ export class OkxProvider implements Protocol { const fromAsset = AssetId.fromString(quote.quote.from_asset.id); const chain = fromAsset.chain; + const isTokenSwap = isEvmChain(chain) && !!fromAsset.tokenId; - const response = await this.client.dex.getSwapData(buildSwapParams(quote.quote, route, chain)); + const [response, approveSpender] = await Promise.all([ + this.client.dex.getSwapData(buildSwapParams(quote.quote, route, chain)), + isTokenSwap ? this.getApproveSpender(chain) : Promise.resolve(undefined), + ]); if (response.code !== "0") { throw new SwapperException({ @@ -215,27 +222,31 @@ export class OkxProvider implements Protocol { } if (isEvmChain(chain)) { - return this.buildEvmQuoteData(swapData.tx, fromAsset, quote.quote.from_value); + return this.buildEvmQuoteData(swapData.tx, fromAsset, quote.quote.from_address, quote.quote.from_value, approveSpender); } return this.buildSolanaQuoteData(swapData.tx); } - private buildEvmQuoteData(tx: TransactionData, fromAsset: AssetId, fromValue: string): SwapQuoteData { - const approval: SwapQuoteData["approval"] = fromAsset.tokenId - ? { - token: fromAsset.tokenId, - spender: tx.to, - value: fromValue, - } - : undefined; + private async getApproveSpender(chain: Chain): Promise { + const chainData = await this.client.dex.getChainData(chainIndex(chain)); + return chainData.data?.[0]?.dexTokenApproveAddress ?? undefined; + } + private async buildEvmQuoteData( + tx: TransactionData, + fromAsset: AssetId, + owner: string, + fromValue: string, + approveSpender?: string, + ): Promise { + const approval = await checkEvmApproval(fromAsset.chain, fromAsset.tokenId, owner, fromValue, approveSpender); return { to: tx.to, value: tx.value || "0", data: tx.data, dataType: SwapQuoteDataType.Contract, - gasLimit: approval ? tx.gas : undefined, + gasLimit: approval ? evmGasLimit(fromAsset.chain) : undefined, approval, }; } diff --git a/packages/swapper/src/orca/integration.test.ts b/packages/swapper/src/orca/integration.test.ts index f23b36e..977addc 100644 --- a/packages/swapper/src/orca/integration.test.ts +++ b/packages/swapper/src/orca/integration.test.ts @@ -3,7 +3,7 @@ import { Chain, QuoteRequest } from "@gemwallet/types"; import { createOrcaQuoteRequest } from "../testkit/mock"; import { OrcaWhirlpoolProvider } from "./provider"; -const runIntegration = process.env.ORCA_INTEGRATION_TEST === "1"; +const runIntegration = process.env.INTEGRATION_TEST === "1"; const describeIntegration = runIntegration ? describe : describe.skip; const SOLANA_MAINNET_RPC = process.env.SOLANA_RPC || "https://solana-rpc.publicnode.com"; diff --git a/packages/swapper/src/stonfi/integration.test.ts b/packages/swapper/src/stonfi/integration.test.ts index 0f08271..8f20c33 100644 --- a/packages/swapper/src/stonfi/integration.test.ts +++ b/packages/swapper/src/stonfi/integration.test.ts @@ -3,7 +3,7 @@ import { Chain, QuoteRequest } from "@gemwallet/types"; import { TON_ASSET, USDT_TON_ASSET, createStonfiQuoteRequest } from "../testkit/mock"; import { StonfiProvider } from "./index"; -const runIntegration = process.env.STONFI_INTEGRATION_TEST === "1"; +const runIntegration = process.env.INTEGRATION_TEST === "1"; const describeIntegration = runIntegration ? describe : describe.skip; const TON_RPC_ENDPOINT = process.env.TON_URL || "https://toncenter.com"; diff --git a/packages/swapper/src/testkit/mock.ts b/packages/swapper/src/testkit/mock.ts index 2791d95..c5ae0db 100644 --- a/packages/swapper/src/testkit/mock.ts +++ b/packages/swapper/src/testkit/mock.ts @@ -9,8 +9,8 @@ export const SOLANA_USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; export const APTOS_USDC_FA = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"; export const APTOS_USDT_FA = "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"; -export const MANTA_TEST_WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; -export const MANTA_USDC_ADDRESS = "0xb73603C5d87fA094B7314C74ACE2e64D165016fb"; +export const XLAYER_TEST_WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; +export const XLAYER_USD0_ADDRESS = "0x779ded0c9e1022225f8e0630b35a9b54be713736"; export const SOL_ASSET = { id: Chain.Solana, @@ -92,18 +92,18 @@ export const APTOS_USDC_REQUEST_TEMPLATE: QuoteRequest = { slippage_bps: 100, }; -export const OKX_MANTA_USDC_REQUEST_TEMPLATE: QuoteRequest = { - from_address: MANTA_TEST_WALLET_ADDRESS, - to_address: MANTA_TEST_WALLET_ADDRESS, +export const OKX_XLAYER_USD0_REQUEST_TEMPLATE: QuoteRequest = { + from_address: XLAYER_TEST_WALLET_ADDRESS, + to_address: XLAYER_TEST_WALLET_ADDRESS, from_asset: { - id: Chain.Manta, - symbol: "ETH", + id: Chain.XLayer, + symbol: "OKB", decimals: 18, }, to_asset: { - id: `${Chain.Manta}_${MANTA_USDC_ADDRESS}`, - symbol: "USDC", - decimals: 6, + id: `${Chain.XLayer}_${XLAYER_USD0_ADDRESS}`, + symbol: "USD0", + decimals: 18, }, from_value: "1000000000000000000", referral_bps: 50, @@ -111,7 +111,7 @@ export const OKX_MANTA_USDC_REQUEST_TEMPLATE: QuoteRequest = { }; export function createOkxEvmQuoteRequest(overrides: Partial = {}): QuoteRequest { - return createQuoteRequest(OKX_MANTA_USDC_REQUEST_TEMPLATE, overrides); + return createQuoteRequest(OKX_XLAYER_USD0_REQUEST_TEMPLATE, overrides); } export function createQuoteRequest(base: QuoteRequest, overrides: Partial = {}): QuoteRequest { From f7f8292e4a0cac3460a9dfe8fd7151f7a0dcac13 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Fri, 20 Feb 2026 22:46:20 +0900 Subject: [PATCH 4/5] fix test --- packages/swapper/src/okx/provider.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swapper/src/okx/provider.test.ts b/packages/swapper/src/okx/provider.test.ts index 9fac996..331c727 100644 --- a/packages/swapper/src/okx/provider.test.ts +++ b/packages/swapper/src/okx/provider.test.ts @@ -206,7 +206,7 @@ describe("OkxProvider", () => { }); describe("get_quote_data", () => { - it("returns swap gasLimit for native swaps", async () => { + it("native swaps should not return gasLimit and approval", async () => { const { provider, getSwapData } = createProvider(); getSwapData.mockResolvedValue(mockEvmSwapResponse()); @@ -215,7 +215,7 @@ describe("OkxProvider", () => { expect(result.data).toBe("0xabcdef1234567890"); expect(result.to).toBe("0xDEXRouterAddress"); expect(result.value).toBe("1000000000000000000"); - expect(result.gasLimit).toBe("800000"); + expect(result.gasLimit).toBeUndefined(); expect(result.approval).toBeUndefined(); const params = getSwapData.mock.calls[0][0] as Record; From 9c8c349636921a0a9dda1633dac8cd2fbaaa2930 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:38:16 +0900 Subject: [PATCH 5/5] update mantle default gas limit --- packages/swapper/src/okx/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swapper/src/okx/constants.ts b/packages/swapper/src/okx/constants.ts index b1d0884..bc3d684 100644 --- a/packages/swapper/src/okx/constants.ts +++ b/packages/swapper/src/okx/constants.ts @@ -32,7 +32,7 @@ export const SOLANA_DEX_IDS_PARAM = SOLANA_DEX_IDS.join(","); export const DEFAULT_SLIPPAGE_PERCENT = "1"; const EVM_GAS_LIMITS: Partial> = { [Chain.Manta]: "600000", - [Chain.Mantle]: "500000000", + [Chain.Mantle]: "2000000000", [Chain.XLayer]: "800000", };