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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>...`.
- 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.
1 change: 0 additions & 1 deletion apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
3 changes: 3 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
43 changes: 43 additions & 0 deletions packages/swapper/src/chain/evm/allowance.ts
Original file line number Diff line number Diff line change
@@ -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<bigint> {
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<string>(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<ApprovalResult | undefined> {
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 };
}
27 changes: 27 additions & 0 deletions packages/swapper/src/chain/evm/jsonrpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Chain } from "@gemwallet/types";

const DEFAULT_RPC_URLS: Partial<Record<string, string>> = {
[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<T> {
result?: T;
error?: { code: number; message: string };
}

export async function jsonRpcCall<T>(rpcUrl: string, method: string, params: unknown[]): Promise<T | undefined> {
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<T>;
return json.result;
}
22 changes: 21 additions & 1 deletion packages/swapper/src/okx/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import { Chain } from "@gemwallet/types";

export const CHAIN_INDEX: Record<string, string> = {
[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
Expand All @@ -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<Record<string, string>> = {
[Chain.Manta]: "600000",
[Chain.Mantle]: "2000000000",
[Chain.XLayer]: "800000",
};

export function evmGasLimit(chain: Chain): string | undefined {
return EVM_GAS_LIMITS[chain];
}
106 changes: 89 additions & 17 deletions packages/swapper/src/okx/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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"];
Expand All @@ -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}`);
});
});
});
Loading
Loading