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/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/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 5da9aca..bc3d684 100644 --- a/packages/swapper/src/okx/constants.ts +++ b/packages/swapper/src/okx/constants.ts @@ -1,3 +1,14 @@ +import { Chain } from "@gemwallet/types"; + +export const CHAIN_INDEX: Record = { + [Chain.Solana]: "501", + [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 @@ -15,7 +26,16 @@ 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"; +const EVM_GAS_LIMITS: Partial> = { + [Chain.Manta]: "600000", + [Chain.Mantle]: "2000000000", + [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 d2c109b..b49fdf0 100644 --- a/packages/swapper/src/okx/integration.test.ts +++ b/packages/swapper/src/okx/integration.test.ts @@ -1,6 +1,11 @@ -import { QuoteRequest } from "@gemwallet/types"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +require("dotenv").config({ path: "../../.env" }); -import { createSolanaUsdcQuoteRequest } from "../testkit/mock"; +import { Chain, QuoteRequest } from "@gemwallet/types"; +import { OKXDexClient } from "@okx-dex/okx-dex-sdk"; + +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,30 +15,97 @@ 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; -const REQUEST_TEMPLATE: QuoteRequest = createSolanaUsdcQuoteRequest(); +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 XLAYER_NATIVE_TO_USD0_REQUEST: QuoteRequest = createOkxEvmQuoteRequest({ + from_value: "10000000000000000", +}); + +const XLAYER_NATIVE_TO_USD0_LARGE_REQUEST: QuoteRequest = createOkxEvmQuoteRequest({ + from_value: "100000000000000000", +}); 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(BigInt(quote.output_value) > BigInt(0)).toBe(true); - expect(quote.route_data).toBeDefined(); + 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 (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(XLAYER_NATIVE_TO_USD0_REQUEST); + + 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(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(); + }); + + 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(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.gasLimit).toBe("800000"); + expect(quoteData.approval).toBeUndefined(); + }); + }); - const quoteData = await provider.get_quote_data(quote); + describe("Chain Data", () => { + itIntegration("fetches XLayer approve spender address", async () => { + const client = createClient(); + const response = await client.dex.getChainData(CHAIN_INDEX[Chain.XLayer]); - 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); + expect(response.code).toBe("0"); + expect(response.data.length).toBeGreaterThan(0); - const serialized = Buffer.from(quoteData.data, "base64"); - expect(serialized.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 021df77..331c727 100644 --- a/packages/swapper/src/okx/provider.test.ts +++ b/packages/swapper/src/okx/provider.test.ts @@ -1,38 +1,58 @@ -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, XLAYER_USD0_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 }); } +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 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: XLAYER_USD0_ADDRESS }, +}; + +const evmTokenRoute = { + fromTokenAmount: "1000000000000000000", + toTokenAmount: "950000000000000000", + fromToken: { tokenContractAddress: XLAYER_USD0_ADDRESS }, + toToken: { tokenContractAddress: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" }, +}; + +function mockSolanaSwapResponse(overrides?: Record) { return { code: "0", msg: "", data: [ { - routerResult: mockRoute, + routerResult: solanaRoute, tx: { from: "SenderAddress", to: "JUP4Fb2cqiRUcaTHdrPC8h2gNsA2ETXiPDD33WcGuJB", @@ -46,77 +66,182 @@ 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.XLayer}_${XLAYER_USD0_ADDRESS}`, + symbol: "USD0", + decimals: 18, + }, + to_asset: { + id: Chain.XLayer, + symbol: "OKB", + decimals: 18, + }, + from_value: "1000000000000000000", + }); + 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 with chain index and dexIds", async () => { + const { provider, getQuote, getSwapData } = createProvider(); + getQuote.mockResolvedValue({ code: "0", msg: "", data: [solanaRoute] }); - const quote = await provider.get_quote(createRequest()); + const quote = await provider.get_quote(createSolanaRequest()); - expect(quote.output_value).toBe("120000000"); - expect(quote.output_min_value).toBe("118800000"); - expect(getSwapData).not.toHaveBeenCalled(); - }); + expect(quote.output_value).toBe("120000000"); + expect(quote.output_min_value).toBe("118800000"); + expect(getSwapData).not.toHaveBeenCalled(); - it("throws when no quote is available", async () => { - const { provider, getQuote } = createProvider(); - getQuote.mockResolvedValue({ code: "0", msg: "", data: [] }); + const params = getQuote.mock.calls[0][0] as Record; + expect(params.chainIndex).toBe("501"); + expect(params.dexIds).toBeDefined(); + }); - await expect(provider.get_quote(createRequest())).rejects.toThrow(); + 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(); + }); }); - }); - describe("get_quote_data", () => { - it("calls getSwapData with auto slippage params", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockSwapResponse()); + describe("get_quote_data", () => { + it("calls getSwapData with auto slippage params", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockSolanaSwapResponse()); - await provider.get_quote_data(mockQuote()); + 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"); - }); + 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(mockSwapResponse()); + it("returns undefined gasLimit when simulation fails", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockSolanaSwapResponse()); - const result = await provider.get_quote_data(mockQuote()); + const result = await provider.get_quote_data(mockSolanaQuote()); - expect(result.gasLimit).toBeUndefined(); - }); + expect(result.gasLimit).toBeUndefined(); + }); - it("falls back to 1% slippage when slippage_bps is 0", async () => { - const { provider, getSwapData } = createProvider(); - getSwapData.mockResolvedValue(mockSwapResponse()); + it("falls back to 1% slippage when slippage_bps is 0", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue(mockSolanaSwapResponse()); - await provider.get_quote_data(mockQuote(createRequest(0))); + 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(); + 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(mockSwapResponse()); + describe("EVM", () => { + describe("get_quote", () => { + it("returns quote with XLayer chain index and no dexIds", async () => { + const { provider, getQuote } = createProvider(); + getQuote.mockResolvedValue({ code: "0", msg: "", data: [evmRoute] }); - const result = await provider.get_quote_data(mockQuote()); + const quote = await provider.get_quote(createOkxEvmQuoteRequest()); + + expect(quote.output_value).toBe("2500000000"); + const params = getQuote.mock.calls[0][0] as Record; + expect(params.chainIndex).toBe("196"); + expect(params.dexIds).toBeUndefined(); + }); + }); - expect(result.gasLimit).toBeUndefined(); + describe("get_quote_data", () => { + it("native swaps should not return gasLimit and approval", 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"); + expect(result.gasLimit).toBeUndefined(); + expect(result.approval).toBeUndefined(); + + const params = getSwapData.mock.calls[0][0] as Record; + expect(params.fromTokenReferrerWalletAddress).toBe("0x0D9DAB1A248f63B0a48965bA8435e4de7497a3dC"); + }); + + it("returns approval with spender from chain data for token swaps", async () => { + const { provider, getSwapData } = createProvider(); + getSwapData.mockResolvedValue( + mockEvmSwapResponse({ + data: "0xswapCalldata", + value: "0", + }), + ); + + const result = await provider.get_quote_data(mockEvmTokenQuote()); + + expect(result.data).toBe("0xswapCalldata"); + expect(result.value).toBe("0"); + expect(result.gasLimit).toBe("800000"); + expect(result.approval).toEqual({ + 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 02fdb0d..b7ae071 100644 --- a/packages/swapper/src/okx/provider.ts +++ b/packages/swapper/src/okx/provider.ts @@ -1,35 +1,52 @@ 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"; 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"; import { Protocol } from "../protocol"; import { getReferrerAddresses } from "../referrer"; import { - SOLANA_CHAIN_INDEX, - SOLANA_NATIVE_TOKEN_ADDRESS, - SOLANA_DEX_IDS_PARAM, + CHAIN_INDEX, DEFAULT_SLIPPAGE_PERCENT, + EVM_NATIVE_TOKEN_ADDRESS, + SOLANA_DEX_IDS_PARAM, + SOLANA_NATIVE_TOKEN_ADDRESS, + evmGasLimit, } from "./constants"; function bpsToPercent(bps: number): string { return (bps / 100).toString(); } +function chainIndex(chain: Chain): string { + const index = CHAIN_INDEX[chain]; + if (!index) throw new SwapperException({ type: "not_supported_chain" }); + return index; +} + +function isEvmChain(chain: Chain): boolean { + return chain in CHAIN_INDEX && chain !== Chain.Solana; +} + +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; + default: + return referrers.evm; + } } 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), }; } @@ -119,6 +142,7 @@ export class OkxProvider implements Protocol { apiPassphrase: apiPassphrase!, projectId: projectId!, }); + } private async estimateComputeUnitLimit(txData: string): Promise { @@ -134,16 +158,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 +200,14 @@ 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 isTokenSwap = isEvmChain(chain) && !!fromAsset.tokenId; + + 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({ @@ -189,11 +221,42 @@ 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_address, quote.quote.from_value, approveSpender); + } + + return this.buildSolanaQuoteData(swapData.tx); + } + + 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 ? evmGasLimit(fromAsset.chain) : 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 +265,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/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 c433ee7..c5ae0db 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 XLAYER_TEST_WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; +export const XLAYER_USD0_ADDRESS = "0x779ded0c9e1022225f8e0630b35a9b54be713736"; + 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_XLAYER_USD0_REQUEST_TEMPLATE: QuoteRequest = { + from_address: XLAYER_TEST_WALLET_ADDRESS, + to_address: XLAYER_TEST_WALLET_ADDRESS, + from_asset: { + id: Chain.XLayer, + symbol: "OKB", + decimals: 18, + }, + to_asset: { + id: `${Chain.XLayer}_${XLAYER_USD0_ADDRESS}`, + symbol: "USD0", + decimals: 18, + }, + from_value: "1000000000000000000", + referral_bps: 50, + slippage_bps: 100, +}; + +export function createOkxEvmQuoteRequest(overrides: Partial = {}): QuoteRequest { + return createQuoteRequest(OKX_XLAYER_USD0_REQUEST_TEMPLATE, overrides); +} + export function createQuoteRequest(base: QuoteRequest, overrides: Partial = {}): QuoteRequest { return { ...base,